Add terminal E2E control snapshots

This commit is contained in:
slhx agent
2026-06-21 17:22:36 +02:00
parent 70e64180c1
commit 86af6d774e
2 changed files with 93 additions and 13 deletions
+28
View File
@@ -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/<scenario>/`.
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.
+65 -13
View File
@@ -7,9 +7,11 @@ browser-viewable terminal artifacts. No third-party dependencies.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import hashlib
import html import html
import os import os
import pty import pty
import re
import select import select
import signal import signal
import shutil import shutil
@@ -19,6 +21,8 @@ import time
from pathlib import Path from pathlib import Path
ESC = 0x1B ESC = 0x1B
SCENARIO_ID = "raw-key-chrome-crlf"
TMP_RE = re.compile(r"/tmp/mim-terminal-e2e-[^/]+")
def visible_controls(data: bytes) -> str: def visible_controls(data: bytes) -> str:
@@ -35,12 +39,34 @@ def visible_controls(data: bytes) -> str:
elif ch == "\t": elif ch == "\t":
out.append("") out.append("")
elif code < 0x20: elif code < 0x20:
out.append("") out.append(f"{code:02x}")
else: else:
out.append(ch) out.append(ch)
return "".join(out) return "".join(out)
def normalize_visible_controls(text: str) -> str:
text = TMP_RE.sub("<TMP>", text)
text = re.sub(r"/opt/repositories/mim/zig-out/bin/mim", "<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: class TerminalGrid:
def __init__(self, width: int, height: int) -> None: def __init__(self, width: int, height: int) -> None:
self.width = width 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: def run_scenario(mim: Path, out_dir: Path, width: int, height: int) -> None:
self_test()
if out_dir.exists(): if out_dir.exists():
shutil.rmtree(out_dir) shutil.rmtree(out_dir)
out_dir.mkdir(parents=True, exist_ok=True) 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}") raise SystemExit(f"mim exited non-zero: status={status}")
break break
else: else:
(out_dir).mkdir(parents=True, exist_ok=True) write_artifacts(out_dir, bytes(transcript), grid.lines(), width, height, None)
(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")
os.kill(pid, signal.SIGTERM) os.kill(pid, signal.SIGTERM)
raise SystemExit(f"mim did not exit after save+quit script; artifacts={out_dir}") raise SystemExit(f"mim did not exit after save+quit script; artifacts={out_dir}")
finally: 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") saved = target.read_text(encoding="utf-8")
lines = grid.lines() lines = grid.lines()
(out_dir / "raw.bin").write_bytes(bytes(transcript)) raw = bytes(transcript)
(out_dir / "visible-controls.txt").write_text(visible_controls(bytes(transcript)), encoding="utf-8") write_artifacts(out_dir, raw, lines, width, height, saved)
(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")
failures: list[str] = [] failures: list[str] = []
if saved != "what is up": if saved != "what is up":
failures.append(f"saved file mismatch: {saved!r}") failures.append(f"saved file mismatch: {saved!r}")
joined = "\n".join(lines) 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: if "1│" not in raw_text:
failures.append("line-number gutter missing from terminal output") failures.append("line-number gutter missing from terminal output")
if "" not in raw_text: 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: 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("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("--width", type=int, default=48)
parser.add_argument("--height", type=int, default=16) parser.add_argument("--height", type=int, default=16)
args = parser.parse_args() args = parser.parse_args()