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:
@@ -48,13 +48,48 @@ public sealed class PlayerEngine
|
|||||||
break;
|
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
|
// Seed prevTs so the FIRST executed step also gets a pre-delay
|
||||||
// (MinStepDelay). Without this, step 2's Type can fire before the
|
// (MinStepDelay). Without this, step 2's Type can fire before the
|
||||||
// SUT has fully settled after foreground switch.
|
// 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
|
? firstTs - (long)_options.MinStepDelay.TotalMilliseconds
|
||||||
: null;
|
: null;
|
||||||
for (int i = start; i < scenario.Steps.Count; i++)
|
for (int i = start; i < end; i++)
|
||||||
{
|
{
|
||||||
var step = scenario.Steps[i];
|
var step = scenario.Steps[i];
|
||||||
|
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ public static class Program
|
|||||||
case "--sidecar-url": sidecarUrl = args[++i]; break;
|
case "--sidecar-url": sidecarUrl = args[++i]; break;
|
||||||
case "--no-sidecar": noSidecar = true; break;
|
case "--no-sidecar": noSidecar = true; break;
|
||||||
case "--sidecar-profile": options.SidecarProfile = args[++i]; break;
|
case "--sidecar-profile": options.SidecarProfile = args[++i]; break;
|
||||||
|
case "--scenario": options.ScenarioFilter = args[++i]; break;
|
||||||
case "-h":
|
case "-h":
|
||||||
case "--help":
|
case "--help":
|
||||||
Console.WriteLine("Usage: Recordingtest.Runner --scenarios <dir> --baselines <dir> --out <dir>");
|
Console.WriteLine("Usage: Recordingtest.Runner --scenarios <dir> --baselines <dir> --out <dir>");
|
||||||
Console.WriteLine(" [--profile <name>] [--no-launch]");
|
Console.WriteLine(" [--profile <name>] [--no-launch]");
|
||||||
Console.WriteLine(" [--sidecar-url http://localhost:38080] [--no-sidecar]");
|
Console.WriteLine(" [--sidecar-url http://localhost:38080] [--no-sidecar]");
|
||||||
Console.WriteLine(" [--sidecar-profile engine-state]");
|
Console.WriteLine(" [--sidecar-profile engine-state]");
|
||||||
|
Console.WriteLine(" [--scenario <name>] (run only this scenario)");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ public sealed class RunnerOptions
|
|||||||
/// <summary>Profile name for engine-state sidecar normalization. Defaults to "engine-state".</summary>
|
/// <summary>Profile name for engine-state sidecar normalization. Defaults to "engine-state".</summary>
|
||||||
public string SidecarProfile { get; set; } = "engine-state";
|
public string SidecarProfile { get; set; } = "engine-state";
|
||||||
public bool NoLaunch { get; set; }
|
public bool NoLaunch { get; set; }
|
||||||
|
/// <summary>If set, only run scenarios whose name matches this value (no extension).</summary>
|
||||||
|
public string? ScenarioFilter { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ public sealed class TestRunner
|
|||||||
|
|
||||||
var yamlFiles = Directory.Exists(options.ScenariosDir)
|
var yamlFiles = Directory.Exists(options.ScenariosDir)
|
||||||
? Directory.GetFiles(options.ScenariosDir, "*.yaml", SearchOption.TopDirectoryOnly)
|
? 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()
|
.OrderBy(p => p, StringComparer.Ordinal).ToArray()
|
||||||
: Array.Empty<string>();
|
: Array.Empty<string>();
|
||||||
|
|
||||||
|
|||||||
@@ -221,6 +221,58 @@ steps:
|
|||||||
Assert.Equal(StepKind.Focus, s.Steps[1].Kind);
|
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 = "")
|
private static string LocateEngineSource([CallerFilePath] string here = "")
|
||||||
{
|
{
|
||||||
// here = .../tests/Recordingtest.Player.Tests/PlayerEngineTests.cs
|
// here = .../tests/Recordingtest.Player.Tests/PlayerEngineTests.cs
|
||||||
|
|||||||
Reference in New Issue
Block a user