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
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("<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:
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()