From 70e64180c1ed61f97f51f84214394f1b8bb56d09 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 17:17:37 +0200 Subject: [PATCH] Add PTY terminal E2E harness --- build.zig | 5 + tools/terminal_e2e.py | 266 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100755 tools/terminal_e2e.py diff --git a/build.zig b/build.zig index 8bc6245..4118fe3 100644 --- a/build.zig +++ b/build.zig @@ -96,6 +96,11 @@ pub fn build(b: *std.Build) void { const smoke_cmd = b.addRunArtifact(smoke_tests); const smoke_step = b.step("v1-smoke", "Run cheap v1 end-to-end fixture suite"); smoke_step.dependOn(&smoke_cmd.step); + + const terminal_e2e_cmd = b.addSystemCommand(&.{ "python3", "tools/terminal_e2e.py" }); + terminal_e2e_cmd.addArtifactArg(exe); + const terminal_e2e_step = b.step("terminal-e2e", "Run PTY-backed browser-terminal E2E checks"); + terminal_e2e_step.dependOn(&terminal_e2e_cmd.step); } fn addRunProfileStep(b: *std.Build, profile: Profile, exe: *std.Build.Step.Compile) void { diff --git a/tools/terminal_e2e.py b/tools/terminal_e2e.py new file mode 100755 index 0000000..092a82c --- /dev/null +++ b/tools/terminal_e2e.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""PTY-backed terminal E2E harness for mim. + +Runs a tiny high-signal scenario through a real pseudo-terminal and writes +browser-viewable terminal artifacts. No third-party dependencies. +""" +from __future__ import annotations + +import argparse +import html +import os +import pty +import select +import signal +import shutil +import sys +import tempfile +import time +from pathlib import Path + +ESC = 0x1B + + +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("␀") + else: + out.append(ch) + return "".join(out) + + +class TerminalGrid: + def __init__(self, width: int, height: int) -> None: + self.width = width + self.height = height + self.rows = [[" " for _ in range(width)] for _ in range(height)] + self.row = 0 + self.col = 0 + + def clear(self) -> None: + self.rows = [[" " 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([" " 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] = ch + 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() + # SGR and unknown CSI are intentionally ignored for semantic grid. + i = j + 1 + continue + self.put_char(ch) + i += 1 + + def lines(self) -> list[str]: + return ["".join(row).rstrip() for row in self.rows] + + +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) -> tuple[int, int]: + pid, fd = pty.fork() + if pid == 0: + os.environ["TERM"] = "xterm-256color" + 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_lines(lines: list[str], width: int, height: int) -> str: + cell_w = 9 + cell_h = 18 + pad = 12 + svg_w = pad * 2 + width * cell_w + svg_h = pad * 2 + height * cell_h + body = [] + for idx, line in enumerate(lines[:height]): + body.append( + f'{html.escape(line)}' + ) + return "\n".join( + [ + f'', + '', + '', + *body, + "", + ] + ) + + +def html_for_lines(lines: list[str], raw_svg_name: str) -> str: + escaped = html.escape("\n".join(lines)) + return f""" + +mim terminal-e2e + +
{escaped}
+

SVG screenshot artifact

+""" + + +def run_scenario(mim: Path, out_dir: Path, width: int, height: int) -> None: + if out_dir.exists(): + shutil.rmtree(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="mim-terminal-e2e-") as tmp: + target = Path(tmp) / "note.txt" + target.write_text("", encoding="utf-8") + pid, fd = spawn_under_pty([str(mim), str(target)], width, height) + transcript = bytearray() + grid = TerminalGrid(width, height) + try: + for payload in [b"", b"w", b"h", b"a", b"t", b" ", b"i", b"s", b" ", b"u", b"p", b" ", b"n", b" ", b"w", b" ", b"q"]: + 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: + 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") + os.kill(pid, signal.SIGTERM) + raise SystemExit(f"mim did not exit after save+quit script; artifacts={out_dir}") + finally: + try: + os.close(fd) + except OSError: + pass + + 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") + + 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") + if "1│" not in raw_text: + failures.append("line-number gutter missing from terminal output") + if "▌" not in raw_text: + failures.append("cursor marker missing from terminal output") + if "mode:normal" not in raw_text and "saved" not in raw_text: + failures.append("expected editor status not observed") + if "^[" in raw_text: + failures.append("escape appears echoed as ^[") + if "mode:normal" not in raw_text: + failures.append("Space-n did not leave insert mode before save/quit") + if any(len(line) > 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{failure}", file=sys.stderr) + raise SystemExit(1) + + print("scenario\tstatus\tartifacts") + print(f"raw-key-chrome-crlf\tPASS\t{out_dir}") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run mim through a PTY and write browser-viewable terminal E2E artifacts.") + 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("--width", type=int, default=48) + parser.add_argument("--height", type=int, default=16) + args = parser.parse_args() + run_scenario(args.mim.resolve(), args.out, args.width, args.height) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())