Files
mim/tools/terminal_e2e.py
T
2026-06-21 19:34:17 +02:00

664 lines
30 KiB
Python
Executable File

#!/usr/bin/env python3
"""PTY-backed terminal E2E matrix for mim.
Runs high-signal scenarios through a real pseudo-terminal and writes
browser-viewable terminal artifacts. No third-party dependencies.
"""
from __future__ import annotations
import argparse
import dataclasses
import hashlib
import html
import os
import pty
import re
import select
import signal
import shutil
import sys
import tempfile
import time
from pathlib import Path
from typing import Callable
ESC = 0x1B
TMP_RE = re.compile(r"/tmp/mim-terminal-e2e-[^/]+")
CSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
@dataclasses.dataclass(frozen=True)
class Scenario:
id: str
width: int
height: int
file_state: str
input_profile: str
mode_surface: str
color_mode: str
target_kind: str
setup: Callable[[Path], tuple[Path, str | None]]
payloads: tuple[bytes, ...]
expect_saved: str | None
required_raw: tuple[str, ...]
forbidden_raw: tuple[str, ...] = ("^[",)
required_attrs: tuple[str, ...] = ("cursor", "status", "current-line")
max_key_events: int | None = None
expect_files: tuple[tuple[str, str], ...] = ()
prelude: str = ""
def visible_controls(data: bytes) -> str:
out: list[str] = []
text = data.decode("utf-8", errors="replace")
for ch in text:
code = ord(ch)
if ch == "\r":
out.append("␍")
elif ch == "\n":
out.append("␊\n")
elif ch == "\x1b":
out.append("␛")
elif ch == "\t":
out.append("⇥")
elif code < 0x20:
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 strip_csi(text: str) -> str:
return CSI_RE.sub("", text)
def classify_sgr(params: str) -> str:
if not params or params == "0":
return ""
parts = [p for p in params.split(";") if p]
joined = ";".join(parts)
if "48;2;245;197;92" in joined:
return "cursor"
if "48;2;64;96;140" in joined:
return "selection"
if "48;2;26;31;43" in joined:
return "current-line"
if "48;2;126;231;135" in joined:
return "status"
return ""
def render_debug_cell(cell: "Cell") -> str:
if cell.attr == "cursor":
return "☻"
if cell.attr == "selection":
return "░" if cell.ch != " " else "▒"
return cell.ch
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")
@dataclasses.dataclass
class Cell:
ch: str = " "
attr: str = ""
class TerminalGrid:
def __init__(self, width: int, height: int) -> None:
self.width = width
self.height = height
self.rows = [[Cell() for _ in range(width)] for _ in range(height)]
self.row = 0
self.col = 0
self.attr = ""
self.seen_attrs: set[str] = set()
def clear(self) -> None:
self.rows = [[Cell() for _ in range(self.width)] for _ in range(self.height)]
self.row = 0
self.col = 0
def newline(self) -> None:
self.row += 1
if self.row >= self.height:
self.rows.pop(0)
self.rows.append([Cell() for _ in range(self.width)])
self.row = self.height - 1
def put_char(self, ch: str) -> None:
if ch == "\r":
self.col = 0
return
if ch == "\n":
self.newline()
return
if self.col >= self.width:
self.col = 0
self.newline()
if 0 <= self.row < self.height and 0 <= self.col < self.width:
self.rows[self.row][self.col] = Cell(ch, self.attr)
self.col += 1
def feed(self, data: bytes) -> None:
i = 0
text = data.decode("utf-8", errors="replace")
while i < len(text):
ch = text[i]
if ch == "\x1b" and i + 1 < len(text) and text[i + 1] == "[":
j = i + 2
while j < len(text) and not ("@" <= text[j] <= "~"):
j += 1
if j >= len(text):
break
command = text[j]
params = text[i + 2 : j]
if command == "H":
self.row = 0
self.col = 0
elif command == "J" and params.endswith("2"):
self.clear()
elif command == "m":
self.attr = classify_sgr(params)
if self.attr:
self.seen_attrs.add(self.attr)
i = j + 1
continue
self.put_char(ch)
i += 1
def lines(self) -> list[str]:
lines = ["".join(render_debug_cell(cell) for cell in row).rstrip() for row in self.rows]
if "cursor" in self.seen_attrs and not any("☻" in line for line in lines):
lines.append("attr:cursor ☻")
if "selection" in self.seen_attrs and not any("░" in line for line in lines):
lines.append("attr:selection ░")
return lines
def colored_rows(self) -> list[list[Cell]]:
return self.rows
def attrs_seen(self) -> set[str]:
return set(self.seen_attrs)
def read_available(fd: int, timeout: float = 0.12) -> bytes:
chunks: list[bytes] = []
deadline = time.monotonic() + timeout
while True:
remaining = max(0.0, deadline - time.monotonic())
ready, _, _ = select.select([fd], [], [], remaining)
if not ready:
break
try:
chunk = os.read(fd, 65536)
except OSError:
break
if not chunk:
break
chunks.append(chunk)
deadline = time.monotonic() + 0.04
return b"".join(chunks)
def spawn_under_pty(argv: list[str], width: int, height: int, color_mode: str, cwd: Path) -> tuple[int, int]:
pid, fd = pty.fork()
if pid == 0:
os.chdir(cwd)
os.environ["TERM"] = {"truecolor": "xterm-256color", "mono": "vt100"}.get(color_mode, "xterm-256color")
os.environ["COLORTERM"] = "truecolor" if color_mode == "truecolor" else ""
try:
import fcntl
import struct
import termios
winsize = struct.pack("HHHH", height, width, 0, 0)
fcntl.ioctl(0, termios.TIOCSWINSZ, winsize)
except Exception:
pass
os.execv(argv[0], argv)
return pid, fd
def svg_for_cells(rows: list[list[Cell]], width: int, height: int, seen_attrs: set[str]) -> str:
cell_w = 9
cell_h = 18
pad = 12
svg_w = pad * 2 + width * cell_w
svg_h = pad * 2 + height * cell_h
body: list[str] = []
fills = {"cursor": "#f5c55c", "selection": "#40608c", "current-line": "#1a1f2b", "status": "#7ee787"}
text_fills = {"cursor": "#12161e", "status": "#12161e"}
for attr_idx, attr in enumerate(sorted(seen_attrs)):
fill = fills.get(attr)
if fill:
x = pad + attr_idx * 120
body.append(f'<rect x="{x}" y="2" width="16" height="8" fill="{fill}"/>')
body.append(f'<text x="{x + 20}" y="10" xml:space="preserve"><tspan fill="#d6deeb">{html.escape(attr)}</tspan></text>')
for row_idx, row in enumerate(rows[:height]):
y = pad + row_idx * cell_h
for col_idx, cell in enumerate(row[:width]):
fill = fills.get(cell.attr)
if fill:
body.append(f'<rect x="{pad + col_idx * cell_w}" y="{y}" width="{cell_w}" height="{cell_h}" fill="{fill}"/>')
line = "".join(cell.ch for cell in row[:width]).rstrip()
if line:
spans: list[str] = []
for cell in row[:len(line)]:
fill = text_fills.get(cell.attr, "#d6deeb")
spans.append(f'<tspan fill="{fill}">{html.escape(cell.ch)}</tspan>')
body.append(f'<text x="{pad}" y="{pad + (row_idx + 1) * cell_h - 4}" xml:space="preserve">{"".join(spans)}</text>')
return "\n".join([
f'<svg xmlns="http://www.w3.org/2000/svg" width="{svg_w}" height="{svg_h}" viewBox="0 0 {svg_w} {svg_h}">',
'<rect width="100%" height="100%" fill="#12161e"/>',
'<style>text{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px}</style>',
*body,
"</svg>",
])
def html_for_lines(lines: list[str], raw_svg_name: str, scenario: Scenario) -> str:
escaped = html.escape("\n".join(lines))
meta = html.escape(f"{scenario.id} {scenario.width}x{scenario.height} {scenario.file_state} {scenario.input_profile} {scenario.mode_surface} {scenario.color_mode}")
return f"""<!doctype html>
<meta charset="utf-8">
<title>mim terminal-e2e {html.escape(scenario.id)}</title>
<style>
body {{ margin: 0; background: #0f131a; color: #d6deeb; }}
.meta {{ color: #7ee787; font: 12px ui-monospace, monospace; padding: 8px 12px 0; }}
.terminal {{ white-space: pre; font: 14px/18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; padding: 12px; }}
a {{ color: #7ee787; }}
</style>
<div class="meta">{meta}</div>
<div class="terminal">{escaped}</div>
<p><a href="{html.escape(raw_svg_name)}">SVG screenshot artifact</a></p>
"""
def payload_label(payload: bytes) -> str:
if payload == b" ":
return "Space"
if payload == b"\x1b":
return "Esc"
if payload == b"\r":
return "Enter"
if payload == b"\t":
return "Tab"
names = {
b"\x1b[A": "ArrowUp",
b"\x1b[B": "ArrowDown",
b"\x1b[C": "ArrowRight",
b"\x1b[D": "ArrowLeft",
b"\x1b[F": "End",
b"\x1b[H": "Home",
b"\x1b[5~": "PageUp",
b"\x1b[6~": "PageDown",
}
if payload in names:
return names[payload]
try:
text = payload.decode("utf-8")
except UnicodeDecodeError:
return "bytes"
if text.isprintable():
return text
return "bytes"
def keystroke_rows(scenario: Scenario) -> tuple[list[str], int, int]:
rows = ["step\tkey_event\traw_bytes\tbytes_hex\tutf8"]
raw_bytes = 0
for idx, payload in enumerate(scenario.payloads, start=1):
raw_bytes += len(payload)
utf8 = payload.decode("utf-8", errors="replace")
visible = visible_controls(payload).replace("\n", "\\n")
rows.append(f"{idx}\t{payload_label(payload)}\t{len(payload)}\t{payload.hex()}\t{visible or utf8}")
return rows, len(scenario.payloads), raw_bytes
def write_artifacts(out_dir: Path, scenario: Scenario, transcript: bytes, lines: list[str], rows: list[list[Cell]], seen_attrs: set[str], saved: str | None, saved_files: dict[str, str] | None = None) -> None:
visible = visible_controls(transcript)
normalized_visible = normalize_visible_controls(visible)
raw_seen_attrs = set(seen_attrs)
for sgr, attr in (
("48;2;245;197;92", "cursor"),
("48;2;64;96;140", "selection"),
("48;2;26;31;43", "current-line"),
("48;2;126;231;135", "status"),
):
if sgr in normalized_visible:
raw_seen_attrs.add(attr)
if "cursor" in raw_seen_attrs and not any("☻" in line for line in lines):
lines = [*lines, "attr:cursor ☻"]
if "selection" in raw_seen_attrs and not any("░" in line for line in lines):
lines = [*lines, "attr:selection ░"]
key_rows, key_events, raw_input_bytes = keystroke_rows(scenario)
budget_status = "unbudgeted"
if scenario.max_key_events is not None:
budget_status = "pass" if key_events <= scenario.max_key_events else "fail"
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_cells(rows, scenario.width, scenario.height, raw_seen_attrs), encoding="utf-8")
(out_dir / "terminal.html").write_text(html_for_lines(lines, "screenshot.svg", scenario), encoding="utf-8")
width_report = ["line width text"]
for idx, line in enumerate(lines, start=1):
width_report.append(f"{idx} {len(line)} {line}")
(out_dir / "widths.tsv").write_text("\n".join(width_report) + "\n", encoding="utf-8")
(out_dir / "keystrokes.tsv").write_text("\n".join(key_rows) + "\n", encoding="utf-8")
saved_files = saved_files or {}
if saved_files:
saved_rows = ["path\tsha256\tbytes"]
for rel_path, contents in sorted(saved_files.items()):
saved_rows.append(f"{rel_path}\t{hashlib.sha256(contents.encode('utf-8')).hexdigest()}\t{len(contents.encode('utf-8'))}")
(out_dir / "saved-files.tsv").write_text("\n".join(saved_rows) + "\n", encoding="utf-8")
snapshot = [
f"scenario\t{scenario.id}",
f"viewport\t{scenario.width}x{scenario.height}",
f"file_state\t{scenario.file_state}",
f"input_profile\t{scenario.input_profile}",
f"mode_surface\t{scenario.mode_surface}",
f"color_mode\t{scenario.color_mode}",
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()}",
f"attrs_seen\t{','.join(sorted(raw_seen_attrs))}",
f"key_events\t{key_events}",
f"raw_input_bytes\t{raw_input_bytes}",
f"max_key_events\t{scenario.max_key_events if scenario.max_key_events is not None else ''}",
f"key_budget\t{budget_status}",
f"saved_files\t{','.join(sorted(saved_files.keys()))}",
"artifacts\traw.bin visible-controls.txt visible-controls.normalized.txt transcript.txt screenshot.svg terminal.html widths.tsv keystrokes.tsv saved-files.tsv manifest.tsv",
]
(out_dir / "manifest.tsv").write_text("key\tvalue\n" + "\n".join(snapshot) + "\n", encoding="utf-8")
def setup_empty(tmp: Path) -> tuple[Path, str | None]:
target = tmp / "empty.txt"
target.write_text("", encoding="utf-8")
return target, ""
def setup_existing(tmp: Path) -> tuple[Path, str | None]:
target = tmp / "existing.txt"
target.write_text("alpha\nbeta\n", encoding="utf-8")
return target, "alpha\nbeta\n"
def setup_new(tmp: Path) -> tuple[Path, str | None]:
return tmp / "new-file.txt", ""
def setup_directory(tmp: Path) -> tuple[Path, str | None]:
root = tmp / "repo"
root.mkdir()
(root / "one.zig").write_text("pub fn main() void {}\n", encoding="utf-8")
return root, None
def setup_short(tmp: Path) -> tuple[Path, str | None]:
target = tmp / "short.txt"
target.write_text("abcdef\n", encoding="utf-8")
return target, "abcdef\n"
def setup_brackets(tmp: Path) -> tuple[Path, str | None]:
target = tmp / "brackets.txt"
target.write_text("(ab)\n", encoding="utf-8")
return target, "(ab)\n"
def setup_indented(tmp: Path) -> tuple[Path, str | None]:
target = tmp / "indent.txt"
target.write_text(" item\n", encoding="utf-8")
return target, " item\n"
def setup_tabbed(tmp: Path) -> tuple[Path, str | None]:
target = tmp / "tabs.txt"
target.write_text("\titem\n", encoding="utf-8")
return target, "\titem\n"
def setup_long(tmp: Path) -> tuple[Path, str | None]:
target = tmp / "long.txt"
target.write_text("l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\n", encoding="utf-8")
return target, "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\n"
def setup_multifile(tmp: Path) -> tuple[Path, str | None]:
root = tmp / "repo"
root.mkdir()
(root / "alpha.zig").write_text("alpha\n", encoding="utf-8")
beta = root / "beta.zig"
beta.write_text("beta\n", encoding="utf-8")
return beta, "beta\n"
def setup_lsp_assist(tmp: Path) -> tuple[Path, str | None]:
target = tmp / "lsp.zig"
target.write_text("abc\n", encoding="utf-8")
return target, "abc\n"
MOBILE_SYMBOL_SAVE_QUIT = tuple(bytes([b]) for b in b" ps w q")
MOBILE_REPLACE_SAVE_QUIT = tuple(bytes([b]) for b in b"rx w q")
ATTACHED_SAVE_QUIT = (b"i", b"\x1b[F", b"!", b"\x1b", b" ", b"w", b" ", b"q")
PANEL_QUIT = (b" ", b"q")
LSP_ASSIST_PRELUDE = "\n".join((
"lsp_hover_fixture zls|add(lhs, rhs)|Very long documentation that should stay viewport safe.",
"lsp_signature_fixture zls|add(lhs: i32, rhs: i32) active=rhs",
"diagnostic_fixture zls|1|lsp.zig|0|3|warning|demo",
"language_default_format zls",
"language_edit zls|format|1|0|3|ZLS",
))
IOS_DEFAULT_KEYS = set(b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ ")
SCENARIOS = [
Scenario("raw-key-chrome-crlf", 48, 16, "empty", "ios-default-qwertz-space-path", "normal-symbol-save", "truecolor", "file", setup_empty, MOBILE_SYMBOL_SAVE_QUIT, "/", ("1│", "☻", "mode:normal"), max_key_events=7),
Scenario("ios-normal-replace-shift-path", 44, 12, "existing", "ios-default-qwertz-space-path", "normal-replace", "truecolor", "file", setup_existing, MOBILE_REPLACE_SAVE_QUIT, "xlpha\nbeta", ("1│", "☻", "mode:normal")),
Scenario("attached-existing-medium", 72, 18, "existing", "attached-keyboard", "insert-normal", "truecolor", "file", setup_existing, ATTACHED_SAVE_QUIT, "alpha!\nbeta", ("1│", "2│", "☻"), max_key_events=8),
Scenario("new-file-mono-narrow", 40, 12, "new", "ios-default-qwertz-space-path", "normal-symbol-save", "mono", "file", setup_new, MOBILE_SYMBOL_SAVE_QUIT, "/", ("1│", "☻", "mode:normal")),
Scenario("esc-attached-key-mode-switch", 56, 14, "empty", "attached-keyboard", "insert-normal", "truecolor", "file", setup_empty, (b"i", b"e", b"s", b"c", b"\x1b", b" ", b"w", b" ", b"q"), "esc", ("1│", "☻", "mode:normal")),
Scenario("attached-cursor-left-insert", 56, 14, "empty", "attached-keyboard", "insert-cursor", "truecolor", "file", setup_empty, (b"i", b"a", b"b", b"\x1b[D", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "aXb", ("1│", "☻", "aX", "mode:normal")),
Scenario("normal-A-append", 56, 14, "existing", "attached-keyboard", "append-eol", "truecolor", "file", setup_existing, (b"A", b"!", b"\x1b", b" ", b"w", b" ", b"q"), "alpha!\nbeta", ("1│", "☻", "alpha")),
Scenario("insert-enter-indent", 56, 14, "indented", "attached-keyboard", "newline-indent", "truecolor", "file", setup_indented, (b"A", b"\r", b"x", b"\x1b", b" ", b"w", b" ", b"q"), " item\n x", ("1│", "2│", "☻")),
Scenario("insert-tab-detects-tabs", 56, 14, "tabbed", "attached-keyboard", "tab-indent", "truecolor", "file", setup_tabbed, (b"A", b"\t", b"x", b"\x1b", b" ", b"w", b" ", b"q"), "\titem\tx", ("1│", "☻")),
Scenario("page-keys-move-cursor", 56, 10, "long", "attached-keyboard", "page-keys", "truecolor", "file", setup_long, (b"\x1b[6~", b"\x1b[5~", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "l1X\nl2\nl3\nl4\nl5\nl6\nl7\nl8", ("1│", "☻")),
Scenario("counted-move-inserts-at-count", 56, 12, "short", "attached-keyboard", "counts", "truecolor", "file", setup_short, (b"3", b"l", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "abcXdef", ("1│", "☻")),
Scenario("percent-match-jump", 56, 12, "brackets", "attached-keyboard", "match-jump", "truecolor", "file", setup_brackets, (b"%", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "(abX)", ("1│", "☻")),
Scenario("select-mode-colors", 56, 12, "short", "attached-keyboard", "select", "truecolor", "file", setup_short, (b"s", b"w", b"n", b" ", b"q"), "abcdef\n", ("attr:selection", "░", "mode:normal")),
Scenario("multi-file-coding-loop", 72, 16, "repo-multifile", "attached-keyboard", "multi-file", "truecolor", "file", setup_multifile, (b"A", b"?", b"\x1b", b" ", b"w", b" ", b"o", b"r", b"e", b"p", b"o", b"/", b"a", b"l", b"p", b"h", b"a", b".", b"z", b"i", b"g", b"\r", b"A", b"!", b"\x1b", b" ", b"w", b" ", b"q"), None, ("alpha.zig", "beta", "☻", "mode:normal"), max_key_events=30, expect_files=(("alpha.zig", "alpha!\n"), ("beta.zig", "beta?"))),
Scenario("lsp-assisted-coding", 60, 12, "lsp-fixture", "attached-keyboard", "lsp", "truecolor", "file", setup_lsp_assist, (b" ", b"l", b"h", b" ", b"l", b"s", b" ", b"d", b"q", b" ", b"d", b"n", b" ", b"l", b"f", b" ", b"w", b" ", b"q"), "ZLS", ("[zls] add(lhs, rhs)", "[zls] add(lhs: i32, rhs: i32) active=rhs", "diag:fresh:zls", "format:zls:applied", "ZLS", "mode:normal"), max_key_events=19, prelude=LSP_ASSIST_PRELUDE),
Scenario("dirty-discard-shift-q", 52, 12, "empty", "ios-default-qwertz-space-path", "dirty-discard", "truecolor", "file", setup_empty, tuple(bytes([b]) for b in b" ps Q"), "", ("1│", "☻")),
Scenario("directory-panel-narrow", 52, 12, "directory", "ios-default-qwertz-space-path", "panel", "mono", "directory", setup_directory, PANEL_QUIT, None, ("file", "one.zig"), required_attrs=()),
]
def assert_input_profile_is_ergonomic(scenario: Scenario) -> None:
if scenario.input_profile != "ios-default-qwertz-space-path":
return
for payload in scenario.payloads:
for byte in payload:
if byte not in IOS_DEFAULT_KEYS:
raise SystemExit(
f"{scenario.id}: iOS default keyboard scenario uses non-default key byte 0x{byte:02x}; "
"move it to an attached-keyboard scenario or redesign the mobile path"
)
def run_one(mim: Path, root_out: Path, scenario: Scenario) -> tuple[str, Path]:
assert_input_profile_is_ergonomic(scenario)
out_dir = root_out / scenario.id
with tempfile.TemporaryDirectory(prefix="mim-terminal-e2e-") as tmp_name:
tmp = Path(tmp_name)
target, expected_initial = scenario.setup(tmp)
_ = expected_initial
launch_arg = target.relative_to(tmp).as_posix()
argv = [str(mim), launch_arg]
if scenario.prelude:
prelude_path = tmp / "e2e-prelude.trace"
prelude_path.write_text(scenario.prelude + "\n", encoding="utf-8")
argv = [str(mim), "--e2e-prelude-file", prelude_path.name, launch_arg]
pid, fd = spawn_under_pty(argv, scenario.width, scenario.height, scenario.color_mode, tmp)
transcript = bytearray()
grid = TerminalGrid(scenario.width, scenario.height)
try:
for payload in (b"", *scenario.payloads):
if payload:
os.write(fd, payload)
chunk = read_available(fd)
transcript.extend(chunk)
grid.feed(chunk)
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
done_pid, status = os.waitpid(pid, os.WNOHANG)
chunk = read_available(fd, 0.03)
transcript.extend(chunk)
grid.feed(chunk)
if done_pid == pid:
if status != 0:
write_artifacts(out_dir, scenario, bytes(transcript), grid.lines(), grid.colored_rows(), grid.attrs_seen(), None)
raise SystemExit(f"{scenario.id}: mim exited non-zero: status={status}; artifacts={out_dir}")
break
else:
write_artifacts(out_dir, scenario, bytes(transcript), grid.lines(), grid.colored_rows(), grid.attrs_seen(), None)
os.kill(pid, signal.SIGTERM)
raise SystemExit(f"{scenario.id}: mim did not exit after script; artifacts={out_dir}")
finally:
try:
os.close(fd)
except OSError:
pass
saved: str | None = None
if scenario.target_kind == "file" and target.exists():
saved = target.read_text(encoding="utf-8")
saved_files: dict[str, str] = {}
for rel_path, _expected in scenario.expect_files:
file_path = (target / rel_path) if scenario.target_kind == "directory" else (target.parent / rel_path)
if file_path.exists():
saved_files[rel_path] = file_path.read_text(encoding="utf-8")
lines = grid.lines()
raw = bytes(transcript)
write_artifacts(out_dir, scenario, raw, lines, grid.colored_rows(), grid.attrs_seen(), saved, saved_files)
failures: list[str] = []
raw_text = raw.decode("utf-8", errors="ignore")
_, key_events, _ = keystroke_rows(scenario)
if scenario.max_key_events is not None and key_events > scenario.max_key_events:
failures.append(f"key budget exceeded: {key_events} > {scenario.max_key_events}")
if scenario.expect_saved is not None and saved != scenario.expect_saved:
failures.append(f"saved file mismatch: {saved!r} != {scenario.expect_saved!r}")
for rel_path, expected in scenario.expect_files:
actual = saved_files.get(rel_path)
if actual != expected:
failures.append(f"saved file mismatch for {rel_path}: {actual!r} != {expected!r}")
if has_bare_lf(raw):
failures.append("raw terminal output contains bare LF; expected CRLF in raw mode")
plain_text = strip_csi(raw_text)
raw_attrs: set[str] = set()
for sgr, attr in (
("48;2;245;197;92", "cursor"),
("48;2;64;96;140", "selection"),
("48;2;26;31;43", "current-line"),
("48;2;126;231;135", "status"),
):
if sgr in raw_text:
raw_attrs.add(attr)
if "cursor" in raw_attrs:
plain_text += "\nattr:cursor ☻"
if "selection" in raw_attrs:
plain_text += "\nattr:selection ░"
for required in scenario.required_raw:
if required not in plain_text and required not in raw_text:
failures.append(f"required terminal text missing: {required!r}")
for attr in scenario.required_attrs:
if attr not in raw_attrs:
failures.append(f"required ANSI role missing: {attr}")
if scenario.color_mode == "truecolor" and "\x1b[38;2;" not in raw_text:
failures.append("truecolor SGR foreground role missing from terminal output")
if scenario.color_mode == "mono" and scenario.mode_surface != "panel":
for text in ("mode:", " 1│"):
if text not in plain_text:
failures.append(f"mono/degraded transcript lost legible chrome text: {text!r}")
if "▌" in plain_text:
failures.append("terminal output contains layout-changing cursor glyph; cursor must be a cell attribute")
for forbidden in scenario.forbidden_raw:
if forbidden in raw_text:
failures.append(f"forbidden terminal text present: {forbidden!r}")
if scenario.mode_surface != "panel" and "mode:normal" not in raw_text:
failures.append("scenario never reached normal mode")
if any(len(line) > scenario.width for line in lines):
failures.append("terminal line exceeds viewport width")
if failures:
print(f"artifacts\t{out_dir}", file=sys.stderr)
for failure in failures:
print(f"FAIL\t{scenario.id}\t{failure}", file=sys.stderr)
raise SystemExit(1)
return scenario.id, out_dir
def run_matrix(mim: Path, out_root: Path, only: set[str] | None) -> None:
self_test()
selected = [scenario for scenario in SCENARIOS if only is None or scenario.id in only]
unknown = (only or set()) - {scenario.id for scenario in SCENARIOS}
if unknown:
raise SystemExit(f"unknown scenario id(s): {', '.join(sorted(unknown))}")
if out_root.exists():
shutil.rmtree(out_root)
out_root.mkdir(parents=True, exist_ok=True)
rows = ["scenario\tstatus\tartifacts"]
matrix_rows = ["scenario\tviewport\tfile_state\tinput_profile\tmode_surface\tcolor_mode\tkey_events\tmax_key_events\tartifacts"]
for scenario in selected:
scenario_id, artifact_dir = run_one(mim, out_root, scenario)
rows.append(f"{scenario_id}\tPASS\t{artifact_dir}")
_, key_events, _ = keystroke_rows(scenario)
matrix_rows.append(f"{scenario.id}\t{scenario.width}x{scenario.height}\t{scenario.file_state}\t{scenario.input_profile}\t{scenario.mode_surface}\t{scenario.color_mode}\t{key_events}\t{scenario.max_key_events if scenario.max_key_events is not None else ''}\t{artifact_dir}")
(out_root / "matrix.tsv").write_text("\n".join(matrix_rows) + "\n", encoding="utf-8")
print("\n".join(rows))
def main() -> int:
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 "
"(matrix.tsv, 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"))
parser.add_argument("--scenario", action="append", help="run only this scenario id; may be repeated")
args = parser.parse_args()
run_matrix(args.mim.resolve(), args.out, set(args.scenario) if args.scenario else None)
return 0
if __name__ == "__main__":
raise SystemExit(main())