Add terminal E2E control snapshots
This commit is contained in:
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user