diff options
| author | JJ Liebig <jonathan.liebig@gmail.com> | 2026-09-20 00:21:32 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-20 00:21:32 +0200 |
| commit | 9d29441a84858780f80ff1bc35ce9c0c5f43b0c9 (patch) | |
| tree | a8754c76363bec0b5f5421416067f9a8421d027b /scripts | |
| parent | 856b64b9bfd41b9d3d82e2375ed5b4bf941fda28 (diff) | |
test(windows): distinguish clipboard paste origin in input gauntlet (#4394)
* test(windows): distinguish clipboard paste origin in input gauntlet
refs #4314
* test(windows): correct gauntlet paste-origin evidence
refs #4314
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/test_windows_input.ps1 | 18 | ||||
| -rw-r--r-- | scripts/test_windows_input.py | 39 | ||||
| -rw-r--r-- | scripts/windows_input/Common.ps1 | 6 | ||||
| -rw-r--r-- | scripts/windows_input/README.md | 5 | ||||
| -rw-r--r-- | scripts/windows_input/report.py | 46 |
5 files changed, 110 insertions, 4 deletions
diff --git a/scripts/test_windows_input.ps1 b/scripts/test_windows_input.ps1 index 7396bffa..364ce414 100644 --- a/scripts/test_windows_input.ps1 +++ b/scripts/test_windows_input.ps1 @@ -111,6 +111,18 @@ function Outer-State($Plan) { $Plan.outer_sequence = $state.sequence return $state } +function Get-GauntletClientTrace($Plan) { + # The client writes its mapper trace into the named session's data directory. + # The client log grows independently of the transport log, so this keeps its + # own cursor rather than reusing the transport line count. + $log = Join-Path $Plan.config_home "herdr/sessions/$($Plan.session)/herdr-client.log" + if (-not (Test-Path -LiteralPath $log)) { return $null } + $lines = @(Get-Content -LiteralPath $log | Where-Object { $_ -like '*windows input trace: input batch*' }) + $since = if ($Plan.ContainsKey('client_trace_lines')) { [int]$Plan.client_trace_lines } else { 0 } + $Plan.client_trace_lines = $lines.Count + if ($lines.Count -le $since) { return @() } + return @($lines | Select-Object -Skip $since) +} function Set-ObservedGeometry($Plan, $Window, $WindowPid, $Width, $Height) { # Correct against actual console cell measurements, not a claimed pixel size. for ($attempt = 0; $attempt -lt 8; $attempt++) { @@ -351,6 +363,12 @@ try { $row.input_reader = if ($trace.Contains('reader=windows-console')) { 'windows-console' } elseif ($trace.Contains('reader=crossterm')) { 'crossterm' } else { 'unknown' } if ($captureTrace.Contains('transport=win32-serialized')) { $row.input_transport = 'win32-serialized' } } + if ($path -in @('herdr', 'herdr-remote')) { + # The mapper's own client-event view disambiguates a paste the + # terminal issued from a remote-image-bridge reaction (#4314). + $clientEvents = Get-GauntletClientTrace $plan + if ($null -ne $clientEvents) { $row.client_events = $clientEvents } + } if ($null -ne $clipboardSequence) { if (-not [HerdrInputGauntlet.Desktop]::ClearOwnedClipboard($window, $clipboardSequence)) { throw 'Could not clear test-owned clipboard' } $clipboardSequence = $null diff --git a/scripts/test_windows_input.py b/scripts/test_windows_input.py index 38868843..a844ea62 100644 --- a/scripts/test_windows_input.py +++ b/scripts/test_windows_input.py @@ -1,7 +1,7 @@ """Portable tests of the gauntlet's oracle, not Windows input qualification.""" import copy import unittest -from scripts.windows_input.report import catalogue, channel_identity_errors, herdr_protocol_label, known_host_gap, qualification_matrix, summarize, verdict +from scripts.windows_input.report import classification_of_client_events, catalogue, channel_identity_errors, herdr_protocol_label, known_host_gap, qualification_matrix, summarize, verdict class WindowsInputGauntletTests(unittest.TestCase): @@ -95,12 +95,49 @@ class WindowsInputGauntletTests(unittest.TestCase): staged = b"\x1b[200~C:\\Temp\\herdr-clipboard-images-user\\image.png\x1b[201~" self.assertEqual(verdict(case, "legacy", {**self.evidence, "path": "direct", "hex": empty_paste.hex()})[0], "pass") remote = {**self.evidence, "path": "herdr-remote", "hex": staged.hex(), + "paste_origin": "empty-paste", "staged_image_sha256": case["expected"]["legacy"]["sha256"]} self.assertEqual(verdict(case, "legacy", remote)[0], "pass") self.assertEqual(verdict(case, "legacy", {**remote, "staged_image_sha256": "0" * 64})[0], "fail") self.assertEqual(verdict(case, "legacy", {**self.evidence, "path": "herdr-remote", "hex": empty_paste.hex()})[0], "fail") self.assertEqual(verdict(case, "legacy", {**self.evidence, "path": "herdr", "hex": empty_paste.hex()})[0], "not_run") + def test_remote_clipboard_image_rejects_a_paste_the_terminal_issued(self): + # A staged PNG that came from a terminal-issued text paste proves nothing + # about the empty-paste bridge (#4314), and a staged PNG with no mapper + # evidence at all must not qualify the bridge either. + case = self.cases["clipboard-image"] + staged = b"\x1b[200~C:\\Temp\\herdr-clipboard-images-user\\image.png\x1b[201~" + remote = {**self.evidence, "path": "herdr-remote", "hex": staged.hex(), + "staged_image_sha256": case["expected"]["legacy"]["sha256"]} + self.assertEqual(verdict(case, "legacy", {**remote, "paste_origin": "terminal-paste"})[0], "fail") + for weak in ("none", "key-event", None): + self.assertEqual(verdict(case, "legacy", {**remote, "paste_origin": weak})[0], "inconclusive", weak) + self.assertEqual(verdict(case, "legacy", {**remote, "paste_origin": "empty-paste"})[0], "pass") + direct = {**self.evidence, "path": "direct", "hex": b"\x1b[200~\x1b[201~".hex()} + self.assertEqual(verdict(case, "legacy", {**direct, "paste_origin": "terminal-paste"})[0], "fail") + self.assertEqual(verdict(case, "legacy", {**direct, "paste_origin": "empty-paste"})[0], "pass") + + def test_client_event_trace_classifies_paste_origin(self): + self.assertEqual(classification_of_client_events(None), None) + self.assertEqual(classification_of_client_events([]), "none") + self.assertEqual( + classification_of_client_events(['mapped_event_groups=[Paste { text: "" }]']), + "empty-paste") + self.assertEqual( + classification_of_client_events(['mapped_event_groups=[Paste { text: "hello" }]']), + "terminal-paste") + self.assertEqual( + classification_of_client_events( + ["mapped_event_groups=[Key { code: Char('v'), modifiers: 0, kind: Press }]"]), + "key-event") + # A release alone is not a consumed press. + self.assertEqual( + classification_of_client_events( + ["mapped_event_groups=[Key { code: Char('v'), modifiers: 0, kind: Release }]"]), + "none") + self.assertEqual(classification_of_client_events(["not a trace line", 7]), None) + def test_mouse_interleave_requires_ordered_motion_and_paste(self): case = self.cases["mouse-interleave"] motion = b"\x1b[<35;10;5M" diff --git a/scripts/windows_input/Common.ps1 b/scripts/windows_input/Common.ps1 index d944e87b..1fd4943f 100644 --- a/scripts/windows_input/Common.ps1 +++ b/scripts/windows_input/Common.ps1 @@ -41,6 +41,12 @@ function New-GauntletProcess($Exe, $Arguments, $Plan, [switch] $Capture) { $info.Environment['HERDR_SESSION'] = $Plan.session if ($Plan.profile -ne 'default') { $info.Environment['HERDR_WINDOWS_INPUT_PROBE'] = $Plan.profile } if ($Plan.path -in @('herdr', 'herdr-remote')) { $info.Environment['HERDR_WINDOWS_INPUT_TRACE_FILE'] = $Plan.input_trace } + if ($Plan.path -in @('herdr', 'herdr-remote')) { + # Enable the mapper's client-event trace so a report can distinguish a + # terminal-issued paste from a bridge reaction (see README, #4314). + $info.Environment['HERDR_WINDOWS_INPUT_TRACE'] = '1' + $info.Environment['HERDR_LOG'] = 'herdr=info' + } if ($Plan.path -eq 'herdr-remote') { $info.Environment['HERDR_REMOTE_KEYBINDINGS'] = 'local' } diff --git a/scripts/windows_input/README.md b/scripts/windows_input/README.md index 5f8f463f..edebc8fc 100644 --- a/scripts/windows_input/README.md +++ b/scripts/windows_input/README.md @@ -132,6 +132,11 @@ Every run needs a **new** output directory. By default it is the runner does not dismiss unexpected dialogs or rebind Terminal shortcuts. - Image-only Ctrl+V through the remote clipboard bridge, plus a mixed image/text clipboard check that proves Windows Terminal and Herdr preserve the text paste. + The report records `paste_origin`, classified from the client's own mapper trace + (`terminal-paste`, `empty-paste`, `key-event`, or `none`). A staged image only + passes when the origin is a positively observed `empty-paste`: a paste the + terminal issued for clipboard text fails, and a missing or inconclusive trace is + `inconclusive` rather than a qualification (#4314). - A full case pass at an observed 120×30 host size; keyboard/paste sentinels at **80, 119, 120, 121, 132, 160, 240 columns**, at 24 and 50 rows; then return to 80 columns. This exercises narrow→wide→narrow resizing of the actual outer diff --git a/scripts/windows_input/report.py b/scripts/windows_input/report.py index d6c20803..cf37f30e 100644 --- a/scripts/windows_input/report.py +++ b/scripts/windows_input/report.py @@ -126,6 +126,32 @@ def catalogue(): return dict(schema=1, widths=WIDTHS, heights=HEIGHTS, modes=MODES, cases=cases) +def classification_of_client_events(trace_lines): + """Classify how a paste reached the pane from the mapper's client-event trace. + + The pane capture alone cannot tell a paste the terminal issued from a + reaction to a consumed key or an empty bracketed paste (#4314). The mapper + trace records the decoded client events, so the batch that carried the + paste identifies its origin. Returns one of: + "terminal-paste", "empty-paste", "key-event", "none", or None when the + trace is unavailable. Only "terminal-paste" and "empty-paste" are positive + evidence; callers must not treat the others as bridge proof. + """ + if trace_lines is None: + return None + for line in trace_lines: + if not isinstance(line, str): + return None + if 'Paste { text: ""' in line: + return "empty-paste" + if "Paste {" in line: + return "terminal-paste" + for line in trace_lines: + if isinstance(line, str) and 'Key {' in line and "kind: Press" in line: + return "key-event" + return "none" + + def verdict(case, mode, evidence): """The full capture, not a matching prefix, is the primary assertion.""" if evidence.get("status") in {"not_run", "unsupported", "inconclusive"}: @@ -212,10 +238,13 @@ def verdict(case, mode, evidence): except (KeyError, ValueError, TypeError): return "inconclusive", "Missing or malformed raw bytes" if expected.get("clipboard_image"): + origin = evidence.get("paste_origin") if evidence.get("path") == "direct": - return (("pass", "Terminal emitted an empty bracketed paste for image-only clipboard") - if raw == b"\x1b[200~\x1b[201~" else - ("fail", "Terminal did not emit an empty bracketed paste for image-only clipboard")) + if raw != b"\x1b[200~\x1b[201~": + return "fail", "Terminal did not emit an empty bracketed paste for image-only clipboard" + if origin not in (None, "empty-paste"): + return "fail", f"Direct empty paste was decoded as {origin}, not an empty paste" + return "pass", "Terminal emitted an empty bracketed paste for image-only clipboard" if evidence.get("path") != "herdr-remote": return "not_run", "Clipboard image bridge requires the remote-client gauntlet path" if not raw.startswith(b"\x1b[200~") or not raw.endswith(b"\x1b[201~"): @@ -227,6 +256,14 @@ def verdict(case, mode, evidence): valid_path = re.fullmatch(r"[A-Za-z]:\\.*\\herdr-clipboard-images-[^\\]+\\[^\\]+\.png", path) if not valid_path: return "fail", "Pane did not receive a staged clipboard PNG path" + # A staged image must come from the bridge reacting to an empty paste, not + # from a paste the terminal issued for text on the clipboard (#4314). + if origin == "terminal-paste": + return "fail", "Terminal issued the paste; the remote image bridge did not react to the empty paste" + if origin != "empty-paste": + # Without the mapper trace there is no evidence the bridge reacted to + # an empty paste; a staged path alone cannot qualify the bridge. + return "inconclusive", "Missing client trace evidence of an empty-paste bridge reaction" return (("pass", "Exact clipboard PNG was staged and its path reached the pane") if evidence.get("staged_image_sha256") == expected["sha256"] else ("fail", "Staged clipboard image contents differ from the fixture")) @@ -302,6 +339,9 @@ def summarize(document): raise ValueError(f"Duplicate observation identity: {identity}") seen.add(identity) case = cases[observation["case"]] + paste_origin = classification_of_client_events(observation.get("client_events")) + if paste_origin is not None: + observation = {**observation, "paste_origin": paste_origin} status, reason = verdict(case, observation["mode"], observation) scope = "direct_host" if observation.get("path") == "direct" else "through_herdr_not_yet_attributed" if status in ("pass", "fail"): |
