aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorJJ Liebig <jonathan.liebig@gmail.com>2026-09-20 00:21:32 +0200
committerGitHub <noreply@github.com>2026-09-20 00:21:32 +0200
commit9d29441a84858780f80ff1bc35ce9c0c5f43b0c9 (patch)
treea8754c76363bec0b5f5421416067f9a8421d027b /scripts
parent856b64b9bfd41b9d3d82e2375ed5b4bf941fda28 (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.ps118
-rw-r--r--scripts/test_windows_input.py39
-rw-r--r--scripts/windows_input/Common.ps16
-rw-r--r--scripts/windows_input/README.md5
-rw-r--r--scripts/windows_input/report.py46
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"):