diff --git a/src/Recordingtest.Player/PlayerEngine.cs b/src/Recordingtest.Player/PlayerEngine.cs
index f5bad98..d744fa5 100644
--- a/src/Recordingtest.Player/PlayerEngine.cs
+++ b/src/Recordingtest.Player/PlayerEngine.cs
@@ -48,13 +48,48 @@ public sealed class PlayerEngine
break;
}
+ // Issue #14: strip trailing recorder-stop noise. The common recording
+ // stop sequence is: alt+tab (switch back to recorder terminal) →
+ // optional click (focus terminal) → ctrl+c (stop recorder).
+ // Replaying this after real work is done would shift focus out of the
+ // SUT and send ctrl+c to a random window (browser, etc.).
+ // Pattern: from the end, remove ctrl+c hotkeys, then remove at most one
+ // click, then remove at most one alt+tab — but ONLY if at least one
+ // alt+tab was found (i.e. the pattern starts with focus-switch noise).
+ int end = scenario.Steps.Count;
+ {
+ int t = end - 1;
+ // strip trailing ctrl+c
+ int ctrlcCount = 0;
+ while (t >= start &&
+ scenario.Steps[t].Kind == StepKind.Hotkey &&
+ string.Equals(scenario.Steps[t].Value, "ctrl+c", StringComparison.OrdinalIgnoreCase))
+ {
+ t--;
+ ctrlcCount++;
+ }
+ // strip optional trailing click
+ if (t >= start && scenario.Steps[t].Kind == StepKind.Click)
+ t--;
+ // strip trailing alt+tab — only commit the trim if we found one
+ if (t >= start &&
+ scenario.Steps[t].Kind == StepKind.Hotkey &&
+ string.Equals(scenario.Steps[t].Value, "alt+tab", StringComparison.OrdinalIgnoreCase) &&
+ ctrlcCount > 0)
+ {
+ end = t; // exclude alt+tab and everything after
+ Console.WriteLine(
+ $"[player] info: stripped trailing recorder-stop noise (steps {t}..{scenario.Steps.Count - 1}) (issue #14)");
+ }
+ }
+
// Seed prevTs so the FIRST executed step also gets a pre-delay
// (MinStepDelay). Without this, step 2's Type can fire before the
// SUT has fully settled after foreground switch.
- long? prevTs = start < scenario.Steps.Count && scenario.Steps[start].Ts is long firstTs
+ long? prevTs = start < end && scenario.Steps[start].Ts is long firstTs
? firstTs - (long)_options.MinStepDelay.TotalMilliseconds
: null;
- for (int i = start; i < scenario.Steps.Count; i++)
+ for (int i = start; i < end; i++)
{
var step = scenario.Steps[i];
diff --git a/src/Recordingtest.Runner/Program.cs b/src/Recordingtest.Runner/Program.cs
index 527b3fa..ef434c5 100644
--- a/src/Recordingtest.Runner/Program.cs
+++ b/src/Recordingtest.Runner/Program.cs
@@ -19,12 +19,14 @@ public static class Program
case "--sidecar-url": sidecarUrl = args[++i]; break;
case "--no-sidecar": noSidecar = true; break;
case "--sidecar-profile": options.SidecarProfile = args[++i]; break;
+ case "--scenario": options.ScenarioFilter = args[++i]; break;
case "-h":
case "--help":
Console.WriteLine("Usage: Recordingtest.Runner --scenarios
--baselines --out ");
Console.WriteLine(" [--profile ] [--no-launch]");
Console.WriteLine(" [--sidecar-url http://localhost:38080] [--no-sidecar]");
Console.WriteLine(" [--sidecar-profile engine-state]");
+ Console.WriteLine(" [--scenario ] (run only this scenario)");
return 0;
}
}
diff --git a/src/Recordingtest.Runner/RunnerOptions.cs b/src/Recordingtest.Runner/RunnerOptions.cs
index 868faf4..c6bebac 100644
--- a/src/Recordingtest.Runner/RunnerOptions.cs
+++ b/src/Recordingtest.Runner/RunnerOptions.cs
@@ -9,4 +9,6 @@ public sealed class RunnerOptions
/// Profile name for engine-state sidecar normalization. Defaults to "engine-state".
public string SidecarProfile { get; set; } = "engine-state";
public bool NoLaunch { get; set; }
+ /// If set, only run scenarios whose name matches this value (no extension).
+ public string? ScenarioFilter { get; set; }
}
diff --git a/src/Recordingtest.Runner/TestRunner.cs b/src/Recordingtest.Runner/TestRunner.cs
index 2a10538..ad24f81 100644
--- a/src/Recordingtest.Runner/TestRunner.cs
+++ b/src/Recordingtest.Runner/TestRunner.cs
@@ -27,6 +27,8 @@ public sealed class TestRunner
var yamlFiles = Directory.Exists(options.ScenariosDir)
? Directory.GetFiles(options.ScenariosDir, "*.yaml", SearchOption.TopDirectoryOnly)
+ .Where(p => options.ScenarioFilter is null ||
+ Path.GetFileNameWithoutExtension(p).Equals(options.ScenarioFilter, StringComparison.OrdinalIgnoreCase))
.OrderBy(p => p, StringComparer.Ordinal).ToArray()
: Array.Empty();
diff --git a/tests/Recordingtest.Player.Tests/PlayerEngineTests.cs b/tests/Recordingtest.Player.Tests/PlayerEngineTests.cs
index 9940285..7fbb874 100644
--- a/tests/Recordingtest.Player.Tests/PlayerEngineTests.cs
+++ b/tests/Recordingtest.Player.Tests/PlayerEngineTests.cs
@@ -221,6 +221,58 @@ steps:
Assert.Equal(StepKind.Focus, s.Steps[1].Kind);
}
+ // ---- trailing recorder-stop noise stripping --------------------------------
+
+ [Fact]
+ public void TrailingRecorderStop_AltTabClickCtrlC_Stripped()
+ {
+ // box-v7 pattern: real work → alt+tab → click → ctrl+c ctrl+c
+ var host = new FakePlayerHost();
+ var engine = new PlayerEngine(new PlayerEngineOptions { PreserveTiming = false });
+ var scenario = new Scenario
+ {
+ Steps =
+ {
+ new Step { Kind = StepKind.Type, Value = "BOX" }, // real work
+ new Step { Kind = StepKind.Hotkey, Value = "enter" }, // real work
+ new Step { Kind = StepKind.Hotkey, Value = "alt+tab" }, // recorder stop →
+ new Step { Kind = StepKind.Click, RawCoord = [771, 833] }, // click terminal
+ new Step { Kind = StepKind.Hotkey, Value = "ctrl+c" }, // stop recorder
+ new Step { Kind = StepKind.Hotkey, Value = "ctrl+c" }, // stop recorder
+ },
+ };
+
+ engine.Run(scenario, host);
+
+ // Only the two real-work steps should have fired — no Clicks, hotkey only "enter"
+ Assert.Empty(host.Clicks); // click (771,833) stripped
+ Assert.Single(host.Types); // only "BOX"
+ Assert.Equal("BOX", host.Types[0]);
+ Assert.Single(host.Hotkeys); // only "enter", not ctrl+c
+ Assert.Equal("enter", host.Hotkeys[0]);
+ }
+
+ [Fact]
+ public void TrailingCtrlCAlone_NotStripped_RequiresAltTab()
+ {
+ // ctrl+c without preceding alt+tab is a legitimate SUT action (copy)
+ var host = new FakePlayerHost();
+ var engine = new PlayerEngine(new PlayerEngineOptions { PreserveTiming = false });
+ var scenario = new Scenario
+ {
+ Steps =
+ {
+ new Step { Kind = StepKind.Type, Value = "BOX" },
+ new Step { Kind = StepKind.Hotkey, Value = "ctrl+c" },
+ },
+ };
+
+ engine.Run(scenario, host);
+ // ctrl+c alone at the end must NOT be stripped (it's a copy action)
+ Assert.Single(host.Hotkeys);
+ Assert.Equal("ctrl+c", host.Hotkeys[0]);
+ }
+
private static string LocateEngineSource([CallerFilePath] string here = "")
{
// here = .../tests/Recordingtest.Player.Tests/PlayerEngineTests.cs