#!/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("", 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 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'') body.append(f'{html.escape(attr)}') 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'') 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'{html.escape(cell.ch)}') body.append(f'{"".join(spans)}') return "\n".join([ f'', '', '', *body, "", ]) 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""" mim terminal-e2e {html.escape(scenario.id)}
{meta}
{escaped}

SVG screenshot artifact

""" 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_tall(tmp: Path) -> tuple[Path, str | None]: target = tmp / "tall.txt" text = "".join(f"l{i}\n" for i in range(1, 17)) target.write_text(text, encoding="utf-8") return target, text def setup_long_line(tmp: Path) -> tuple[Path, str | None]: target = tmp / "wide.txt" target.write_text("abcdefghijklmnopqrstuvwxyz0123456789\n", encoding="utf-8") return target, "abcdefghijklmnopqrstuvwxyz0123456789\n" def setup_ragged(tmp: Path) -> tuple[Path, str | None]: target = tmp / "ragged.txt" target.write_text("abcdef\nxy\n123456\n", encoding="utf-8") return target, "abcdef\nxy\n123456\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" def setup_project_search(tmp: Path) -> tuple[Path, str | None]: root = tmp / "repo" root.mkdir() main = root / "main.zig" main.write_text("main\n", encoding="utf-8") (root / "target.zig").write_text("findme\n", encoding="utf-8") return main, "main\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-expands-spaces", 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 x", ("1│", "☻")), Scenario("dot-repeat-local-edit", 56, 12, "short", "attached-keyboard", "repeat", "truecolor", "file", setup_short, (b"i", b"X", b"\x1b", b"w", b".", b" ", b"w", b" ", b"q"), "XabcdefX", ("mode:normal", "☻")), Scenario("page-keys-move-cursor", 56, 10, "long", "attached-keyboard", "page-keys", "truecolor", "file", setup_long, (b"\x1b[6~", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "l1\nl2\nl3\nl4\nl5\nl6\nl7\nXl8", ("8│", "☻")), 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-motions", 56, 12, "short", "attached-keyboard", "select", "truecolor", "file", setup_short, (b"L", b"s", b"H", b"L", b"n", b" ", b"q"), "abcdef\n", ("attr:selection", "░", "mode:normal")), Scenario("horizontal-cursor-scroll", 24, 8, "wide-line", "attached-keyboard", "horizontal-scroll", "truecolor", "file", setup_long_line, (b"L", b" ", b"q"), "abcdefghijklmnopqrstuvwxyz0123456789\n", ("0123456789", "☻", "mode:normal")), Scenario("vertical-motion-preferred-column", 56, 12, "ragged", "attached-keyboard", "motion", "truecolor", "file", setup_ragged, (b"L", b"j", b"j", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "abcdef\nxy\n123456X", ("1│", "3│", "☻", "mode:normal")), Scenario("document-half-page-motions", 56, 10, "tall", "attached-keyboard", "motion", "truecolor", "file", setup_tall, (b"G", b"i", b"X", b"\x1b", b"g", b"g", b"\x04", b"i", b"Y", b"\x1b", b"G", b"g", b"u", b"i", b"Z", b"\x1b", b" ", b"w", b" ", b"q"), "l1\nl2\nl3\nl4\nl5\nZl6\nl7\nl8\nl9\nl10\nYl11\nl12\nl13\nl14\nl15\nXl16", ("11│", "16│", "☻", "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("project-search-panel", 60, 12, "repo-search", "attached-keyboard", "project-search", "truecolor", "file", setup_project_search, (b" ", b"s", b"p", b"f", b"i", b"n", b"d", b"m", b"e", b"\r", b"q", b" ", b"q"), "main\n", ("panel [search]", "target.zig", "findme", "mode:normal"), max_key_events=13), Scenario("job-cancel-output-panel", 60, 12, "job", "attached-keyboard", "job-output", "truecolor", "file", setup_empty, (b" ", b"t", b"x", b"q", b" ", b"q"), "", ("panel [job-output]", "job:profile:build", "job:status:cancelled:user", "mode:normal"), max_key_events=6), 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())