fix: strip trailing recorder-stop noise in PlayerEngine (#14)

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 <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-10 08:59:22 +09:00
parent 612cc8ac51
commit a310ca2ce4
5 changed files with 95 additions and 2 deletions

View File

@@ -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];

View File

@@ -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 <dir> --baselines <dir> --out <dir>");
Console.WriteLine(" [--profile <name>] [--no-launch]");
Console.WriteLine(" [--sidecar-url http://localhost:38080] [--no-sidecar]");
Console.WriteLine(" [--sidecar-profile engine-state]");
Console.WriteLine(" [--scenario <name>] (run only this scenario)");
return 0;
}
}

View File

@@ -9,4 +9,6 @@ public sealed class RunnerOptions
/// <summary>Profile name for engine-state sidecar normalization. Defaults to "engine-state".</summary>
public string SidecarProfile { get; set; } = "engine-state";
public bool NoLaunch { get; set; }
/// <summary>If set, only run scenarios whose name matches this value (no extension).</summary>
public string? ScenarioFilter { get; set; }
}

View File

@@ -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<string>();

View File

@@ -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