From a310ca2ce4c71610139ae0207a0dd8c4350e3ca4 Mon Sep 17 00:00:00 2001 From: minsung Date: Fri, 10 Apr 2026 08:59:22 +0900 Subject: [PATCH] fix: strip trailing recorder-stop noise in PlayerEngine (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit box-v7 패턴: alt+tab → click(PowerShell) → ctrl+c ctrl+c 가 재생 시 SUT 외부 창(브라우저 등)에 입력을 보내는 버그. - PlayerEngine.Run: trailing (alt+tab → optional click → ctrl+c+) 감지 시 제거 - leading alt+tab 제거와 대칭적으로 동작 - ctrl+c 단독으로는 제거하지 않음 (SUT 내 복사 액션과 구분) - 테스트 2건 추가 (138 total) Co-Authored-By: Claude Sonnet 4.6 --- src/Recordingtest.Player/PlayerEngine.cs | 39 +++++++++++++- src/Recordingtest.Runner/Program.cs | 2 + src/Recordingtest.Runner/RunnerOptions.cs | 2 + src/Recordingtest.Runner/TestRunner.cs | 2 + .../PlayerEngineTests.cs | 52 +++++++++++++++++++ 5 files changed, 95 insertions(+), 2 deletions(-) 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