aboutsummaryrefslogtreecommitdiff
path: root/scripts/windows_input
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/windows_input')
-rw-r--r--scripts/windows_input/Common.ps16
-rw-r--r--scripts/windows_input/README.md5
-rw-r--r--scripts/windows_input/report.py46
3 files changed, 54 insertions, 3 deletions
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"):