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