From 9d29441a84858780f80ff1bc35ce9c0c5f43b0c9 Mon Sep 17 00:00:00 2001 From: JJ Liebig Date: Sun, 20 Sep 2026 00:21:32 +0200 Subject: 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 --- scripts/windows_input/Common.ps1 | 6 ++++++ scripts/windows_input/README.md | 5 +++++ scripts/windows_input/report.py | 46 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 54 insertions(+), 3 deletions(-) (limited to 'scripts/windows_input') 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"): -- cgit v1.2.3