535 lines
23 KiB
Python
Executable File
535 lines
23 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, ...] = ("^[",)
|
|
|
|
|
|
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 write_artifacts(out_dir: Path, scenario: Scenario, transcript: bytes, lines: list[str], rows: list[list[Cell]], seen_attrs: set[str], saved: str | None) -> None:
|
|
visible = visible_controls(transcript)
|
|
normalized_visible = normalize_visible_controls(visible)
|
|
raw_seen_attrs = set(seen_attrs)
|
|
if "48;2;245;197;92" in normalized_visible:
|
|
raw_seen_attrs.add("cursor")
|
|
if "48;2;64;96;140" in normalized_visible:
|
|
raw_seen_attrs.add("selection")
|
|
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 ░"]
|
|
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")
|
|
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()}",
|
|
"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 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"
|
|
|
|
|
|
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")
|
|
|
|
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")),
|
|
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│", "☻")),
|
|
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("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")),
|
|
]
|
|
|
|
|
|
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
|
|
pid, fd = spawn_under_pty([str(mim), target.name], 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")
|
|
lines = grid.lines()
|
|
raw = bytes(transcript)
|
|
write_artifacts(out_dir, scenario, raw, lines, grid.colored_rows(), grid.attrs_seen(), saved)
|
|
failures: list[str] = []
|
|
raw_text = raw.decode("utf-8", errors="ignore")
|
|
if scenario.expect_saved is not None and saved != scenario.expect_saved:
|
|
failures.append(f"saved file mismatch: {saved!r} != {scenario.expect_saved!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)
|
|
if "48;2;245;197;92" in raw_text:
|
|
plain_text += "\nattr:cursor ☻"
|
|
if "48;2;64;96;140" in raw_text:
|
|
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}")
|
|
if scenario.color_mode == "truecolor" and "\x1b[38;2;" not in raw_text:
|
|
failures.append("truecolor SGR foreground role missing from terminal output")
|
|
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\tartifacts"]
|
|
for scenario in selected:
|
|
scenario_id, artifact_dir = run_one(mim, out_root, scenario)
|
|
rows.append(f"{scenario_id}\tPASS\t{artifact_dir}")
|
|
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{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())
|