From 86af6d774e5348d184426907033167301b00aafd Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 17:22:36 +0200 Subject: [PATCH] Add terminal E2E control snapshots --- tools/TERMINAL_E2E.md | 28 ++++++++++++++++ tools/terminal_e2e.py | 78 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 tools/TERMINAL_E2E.md diff --git a/tools/TERMINAL_E2E.md b/tools/TERMINAL_E2E.md new file mode 100644 index 0000000..c89f4f6 --- /dev/null +++ b/tools/TERMINAL_E2E.md @@ -0,0 +1,28 @@ +# Terminal E2E artifacts + +`zig build terminal-e2e` runs `tools/terminal_e2e.py` against the compiled `mim` +binary and writes ignored artifacts under `.zig-cache/terminal-e2e//`. + +Artifact roles: + +- `manifest.tsv`: stable scenario facts, hashes, bare-LF status, and artifact list. +- `visible-controls.normalized.txt`: primary semantic failure artifact. It shows + CR as `␍`, LF as `␊`, ESC as `␛`, tabs as `⇥`, and normalizes volatile temp + paths. +- `transcript.txt`: semantic terminal grid after escape/control interpretation. +- `screenshot.svg` and `terminal.html`: browser-viewable visual evidence. +- `raw.bin`: exact PTY bytes for low-level debugging. + +Golden policy: + +- Investigate before updating any expected artifact or assertion. +- Prefer semantic artifacts (`manifest.tsv`, `visible-controls.normalized.txt`, + `transcript.txt`) for assertions; visual artifacts explain what a user would + see. +- Updating artifacts is safe only after the change is intentional and the receipt + names the changed UI/control-byte behavior. +- Bare LF in raw-mode terminal output is a regression unless a scenario explicitly + proves a non-raw stream. Raw-mode rendering should use CRLF to avoid diagonal + terminal skew. +- Generated artifacts must remain under ignored build output and must not dirty a + clean source tree. diff --git a/tools/terminal_e2e.py b/tools/terminal_e2e.py index 092a82c..6692e9d 100755 --- a/tools/terminal_e2e.py +++ b/tools/terminal_e2e.py @@ -7,9 +7,11 @@ browser-viewable terminal artifacts. No third-party dependencies. from __future__ import annotations import argparse +import hashlib import html import os import pty +import re import select import signal import shutil @@ -19,6 +21,8 @@ import time from pathlib import Path ESC = 0x1B +SCENARIO_ID = "raw-key-chrome-crlf" +TMP_RE = re.compile(r"/tmp/mim-terminal-e2e-[^/]+") def visible_controls(data: bytes) -> str: @@ -35,12 +39,34 @@ def visible_controls(data: bytes) -> str: elif ch == "\t": out.append("⇥") elif code < 0x20: - out.append("␀") + out.append(f"␀{code:02x}") else: out.append(ch) return "".join(out) +def normalize_visible_controls(text: str) -> str: + text = TMP_RE.sub("", text) + text = re.sub(r"/opt/repositories/mim/zig-out/bin/mim", "", text) + return text + + +def has_bare_lf(data: bytes) -> bool: + return any(byte == 0x0A and (i == 0 or data[i - 1] != 0x0D) for i, byte in enumerate(data)) + + +def self_test() -> None: + sample = b"a\nb\r\n\x1b[31m\t\x01" + visible = visible_controls(sample) + expected = "a␊\nb␍␊\n␛[31m⇥␀01" + if visible != expected: + raise SystemExit(f"visible control self-test failed: {visible!r} != {expected!r}") + if not has_bare_lf(b"a\nb"): + raise SystemExit("bare LF self-test failed: did not detect bare LF") + if has_bare_lf(b"a\r\nb"): + raise SystemExit("bare LF self-test failed: flagged CRLF") + + class TerminalGrid: def __init__(self, width: int, height: int) -> None: self.width = width @@ -176,7 +202,29 @@ a {{ color: #7ee787; }} """ +def write_artifacts(out_dir: Path, transcript: bytes, lines: list[str], width: int, height: int, saved: str | None) -> None: + visible = visible_controls(transcript) + normalized_visible = normalize_visible_controls(visible) + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / "raw.bin").write_bytes(transcript) + (out_dir / "visible-controls.txt").write_text(visible, encoding="utf-8") + (out_dir / "visible-controls.normalized.txt").write_text(normalized_visible, encoding="utf-8") + (out_dir / "transcript.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") + (out_dir / "screenshot.svg").write_text(svg_for_lines(lines, width, height), encoding="utf-8") + (out_dir / "terminal.html").write_text(html_for_lines(lines, "screenshot.svg"), encoding="utf-8") + snapshot = [ + f"scenario\t{SCENARIO_ID}", + f"viewport\t{width}x{height}", + f"raw_sha256\t{hashlib.sha256(transcript).hexdigest()}", + f"bare_lf\t{str(has_bare_lf(transcript)).lower()}", + f"saved_sha256\t{hashlib.sha256((saved or '').encode('utf-8')).hexdigest()}", + "artifacts\traw.bin visible-controls.txt visible-controls.normalized.txt transcript.txt screenshot.svg terminal.html manifest.tsv", + ] + (out_dir / "manifest.tsv").write_text("key\tvalue\n" + "\n".join(snapshot) + "\n", encoding="utf-8") + + def run_scenario(mim: Path, out_dir: Path, width: int, height: int) -> None: + self_test() if out_dir.exists(): shutil.rmtree(out_dir) out_dir.mkdir(parents=True, exist_ok=True) @@ -204,10 +252,7 @@ def run_scenario(mim: Path, out_dir: Path, width: int, height: int) -> None: raise SystemExit(f"mim exited non-zero: status={status}") break else: - (out_dir).mkdir(parents=True, exist_ok=True) - (out_dir / "raw-timeout.bin").write_bytes(bytes(transcript)) - (out_dir / "visible-timeout.txt").write_text(visible_controls(bytes(transcript)), encoding="utf-8") - (out_dir / "transcript-timeout.txt").write_text("\n".join(grid.lines()) + "\n", encoding="utf-8") + write_artifacts(out_dir, bytes(transcript), grid.lines(), width, height, None) os.kill(pid, signal.SIGTERM) raise SystemExit(f"mim did not exit after save+quit script; artifacts={out_dir}") finally: @@ -218,17 +263,16 @@ def run_scenario(mim: Path, out_dir: Path, width: int, height: int) -> None: saved = target.read_text(encoding="utf-8") lines = grid.lines() - (out_dir / "raw.bin").write_bytes(bytes(transcript)) - (out_dir / "visible-controls.txt").write_text(visible_controls(bytes(transcript)), encoding="utf-8") - (out_dir / "transcript.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") - (out_dir / "screenshot.svg").write_text(svg_for_lines(lines, width, height), encoding="utf-8") - (out_dir / "terminal.html").write_text(html_for_lines(lines, "screenshot.svg"), encoding="utf-8") + raw = bytes(transcript) + write_artifacts(out_dir, raw, lines, width, height, saved) failures: list[str] = [] if saved != "what is up": failures.append(f"saved file mismatch: {saved!r}") joined = "\n".join(lines) - raw_text = bytes(transcript).decode("utf-8", errors="ignore") + raw_text = raw.decode("utf-8", errors="ignore") + if has_bare_lf(raw): + failures.append("raw terminal output contains bare LF; expected CRLF in raw mode") if "1│" not in raw_text: failures.append("line-number gutter missing from terminal output") if "▌" not in raw_text: @@ -252,9 +296,17 @@ def run_scenario(mim: Path, out_dir: Path, width: int, height: int) -> None: def main() -> int: - parser = argparse.ArgumentParser(description="Run mim through a PTY and write browser-viewable terminal E2E artifacts.") + parser = argparse.ArgumentParser( + description="Run mim through a PTY and write browser-viewable terminal E2E artifacts.", + epilog=( + "Golden policy: investigate before updating artifacts. Semantic snapshots " + "(manifest.tsv, transcript.txt, visible-controls.normalized.txt) are the " + "primary review surface; screenshot.svg/terminal.html are visual evidence. " + "It is safe to update artifacts only after explaining intentional UI/control-byte changes." + ), + ) parser.add_argument("mim", type=Path, help="path to compiled mim binary") - parser.add_argument("--out", type=Path, default=Path(".zig-cache/terminal-e2e/raw-key-chrome-crlf")) + parser.add_argument("--out", type=Path, default=Path(f".zig-cache/terminal-e2e/{SCENARIO_ID}")) parser.add_argument("--width", type=int, default=48) parser.add_argument("--height", type=int, default=16) args = parser.parse_args()