From 9bfe375c307bd2f59dacc22b16eb0a818b734c4c Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Mon, 2 Nov 2026 01:27:36 +0100 Subject: [PATCH] [ticket-021] feat(tui): add first-class testwins tui CLI subcommand wrapping PyteTerminalSurface --- project/ticket-021/README.md | 9 ++ project/ticket-021/intent.json | 16 ++++ pyproject.toml | 2 +- tests/test_tui_cli.py | 122 ++++++++++++++++++++++++++ testwins/cli.py | 5 ++ testwins/tui_cli.py | 151 +++++++++++++++++++++++++++++++++ 6 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 project/ticket-021/README.md create mode 100644 project/ticket-021/intent.json create mode 100644 tests/test_tui_cli.py create mode 100644 testwins/tui_cli.py diff --git a/project/ticket-021/README.md b/project/ticket-021/README.md new file mode 100644 index 0000000..604332e --- /dev/null +++ b/project/ticket-021/README.md @@ -0,0 +1,9 @@ +# ticket-021 — First-class `testwins tui` CLI subcommand wrapping `PyteTerminalSurface` + +## Goal +Expose `PyteTerminalSurface` and `TerminalSession` via a first-class `testwins tui` CLI subcommand (`PLF-2565`) so operators and CI pipelines can audit ANSI/VT100 terminal frames (`--input`) or live PTY commands (`--exec` / `--arg`) for 2D box geometry, truecolor palette drift (`--palette`), and required text assertions (`--expect-text`), and declare `pyte>=0.8.2,<1` in `[project.optional-dependencies].terminal`. + +## Acceptance Criteria +1. `testwins tui` accepts `--input ` or `--exec [--arg ...]`, `--cols`, `--rows`, `--expect-text`, `--palette`, and `--output`, emitting `testwins.tui-report/v1` JSON and returning exit code `0` on pass or `1` on parity/assertion failure. +2. `pyproject.toml` includes `pyte>=0.8.2,<1` under `[project.optional-dependencies].terminal`. +3. Deterministic unit and CLI tests in `tests/test_tui_cli.py` pass without `pytest.skip`. diff --git a/project/ticket-021/intent.json b/project/ticket-021/intent.json new file mode 100644 index 0000000..f25a9d3 --- /dev/null +++ b/project/ticket-021/intent.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": "1.0.0", + "ticketId": "ticket-021", + "title": "First-class testwins tui CLI subcommand wrapping PyteTerminalSurface", + "owner": "antigravity", + "workstream": "terminal-surface", + "status": "IN_PROGRESS", + "phase": "EDIT", + "allowedPaths": [ + "project/ticket-021/**", + "pyproject.toml", + "testwins/cli.py", + "testwins/tui_cli.py", + "tests/test_tui_cli.py" + ] +} diff --git a/pyproject.toml b/pyproject.toml index cb33c5d..6232001 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ subllm = ["subactor-subllm>=1.14,<2"] cv = ["opencv-python-headless>=4.10,<5", "numpy>=1.26,<3"] yolo = ["ultralytics>=8.3,<9"] desktop = ["mss>=9,<11", "pyautogui>=0.9.54,<1"] -terminal = ["pexpect>=4.9,<5; platform_system != 'Windows'"] +terminal = ["pexpect>=4.9,<5; platform_system != 'Windows'", "pyte>=0.8.2,<1"] dev = ["pytest>=8,<10", "build>=1.2,<2", "twine>=6,<7", "goal>=2.1.0; python_version >= '3.12'", "costs>=0.1.53; python_version >= '3.9'", diff --git a/tests/test_tui_cli.py b/tests/test_tui_cli.py new file mode 100644 index 0000000..c3a9aa1 --- /dev/null +++ b/tests/test_tui_cli.py @@ -0,0 +1,122 @@ +"""Skip-free unit and CLI tests for the `testwins tui` subcommand.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +from testwins.cli import main + + +def test_tui_cli_audits_ansi_frame_file_and_writes_report(tmp_path: Path) -> None: + frame_file = tmp_path / "heyasa_frame.ansi" + frame_file.write_text( + "\x1b[2J\x1b[H" + "\x1b[38;2;56;189;248m┌────────────────────────┐\x1b[0m\r\n" + "\x1b[38;2;56;189;248m│\x1b[0m\x1b[1;38;2;248;250;252m [1·A] Chat AUTO \x1b[0m\x1b[38;2;56;189;248m│\x1b[0m\r\n" + "\x1b[38;2;56;189;248m└────────────────────────┘\x1b[0m", + encoding="utf-8", + ) + out_file = tmp_path / "tui-report.json" + + code = main( + [ + "tui", + "--input", + str(frame_file), + "--cols", + "40", + "--rows", + "8", + "--expect-text", + "[1·A] Chat", + "--expect-text", + "AUTO", + "--palette", + "#38bdf8", + "--palette", + "#f8fafc", + "--output", + str(out_file), + ] + ) + assert code == 0 + report = json.loads(out_file.read_text(encoding="utf-8")) + assert report["schema"] == "testwins.tui-report/v1" + assert report["status"] == "passed" + assert report["exit_code"] == 0 + assert len(report["boxes"]) == 1 + assert report["boxes"][0]["border_fg"] == "#38bdf8" + assert report["findings"] == [] + assert report["missing_text"] == [] + + +def test_tui_cli_detects_missing_text_and_palette_drift(tmp_path: Path) -> None: + frame_file = tmp_path / "drift_frame.ansi" + frame_file.write_text( + "\x1b[2J\x1b[H" + "\x1b[38;2;255;0;0m┌──────────────┐\x1b[0m\r\n" + "\x1b[38;2;255;0;0m│ [A] Terminal │\x1b[0m\r\n" + "\x1b[38;2;255;0;0m└──────────────┘\x1b[0m", + encoding="utf-8", + ) + out_file = tmp_path / "drift-report.json" + + code = main( + [ + "tui", + "--input", + str(frame_file), + "--cols", + "30", + "--rows", + "6", + "--expect-text", + "[B] Logs", + "--palette", + "#38bdf8", + "--output", + str(out_file), + ] + ) + assert code == 1 + report = json.loads(out_file.read_text(encoding="utf-8")) + assert report["status"] == "failed" + assert report["missing_text"] == ["[B] Logs"] + rules = {item["rule"] for item in report["findings"]} + assert "TW-TUI-COLOR-DRIFT" in rules + assert "TW-TUI-TEXT-MISSING" in rules + + +def test_tui_cli_executes_pty_command_and_Rejects_invalid_args(tmp_path: Path) -> None: + out_file = tmp_path / "pty-report.json" + script = ( + "import sys; " + "sys.stdout.write('\\x1b[2J\\x1b[H┌────────────┐\\r\\n│ PTY READY │\\r\\n└────────────┘'); " + "sys.stdout.flush()" + ) + code = main( + [ + "tui", + "--cols", + "24", + "--rows", + "6", + "--expect-text", + "PTY READY", + "--output", + str(out_file), + "--", + sys.executable, + "-c", + script, + ] + ) + assert code == 0 + report = json.loads(out_file.read_text(encoding="utf-8")) + assert report["status"] == "passed" + assert len(report["boxes"]) == 1 + + # Invalid usage (neither --input nor --exec) returns exit code 2 + assert main(["tui"]) == 2 diff --git a/testwins/cli.py b/testwins/cli.py index 2d9de30..a9c46dc 100644 --- a/testwins/cli.py +++ b/testwins/cli.py @@ -54,7 +54,9 @@ def parser() -> argparse.ArgumentParser: d=sub.add_parser('draft');d.add_argument('image',type=Path);d.add_argument('--snapshot',type=Path,required=True) d.add_argument('--goal',required=True);d.add_argument('--url',required=True);d.add_argument('--output',type=Path,required=True);d.add_argument('--env',type=Path,default=Path('.env')) from .live.cli import register + from .tui_cli import register_tui register(sub) + register_tui(sub) return p @@ -69,6 +71,9 @@ def main(argv: list[str] | None=None) -> int: if a.command in {'watch','live-status','live-serve','live-export','capacity','diagnose','catalog'}: from .live.cli import dispatch return dispatch(a) + if a.command=='tui': + from .tui_cli import dispatch_tui + return dispatch_tui(a) if a.command=='strategies': from .ux import catalog print(json.dumps(catalog(), ensure_ascii=False, indent=2));return 0 diff --git a/testwins/tui_cli.py b/testwins/tui_cli.py new file mode 100644 index 0000000..f05d3d8 --- /dev/null +++ b/testwins/tui_cli.py @@ -0,0 +1,151 @@ +"""First-class CLI subcommand for 2D ANSI/VT100 terminal surface audits.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +from .integrations.terminal import PyteTerminalSurface, TerminalSession +from .util import atomic_json + +MAX_INPUT_BYTES = 2 * 1024 * 1024 + + +def register_tui(sub: Any) -> None: + p = sub.add_parser( + "tui", + help="Audit 2D ANSI/VT100 terminal surface geometry, truecolor palette, and text contracts", + ) + p.add_argument("--input", type=Path, help="Path to captured ANSI/VT100 frame file") + p.add_argument("--exec", dest="exec_bin", help="Executable to run inside an isolated PTY") + p.add_argument( + "cmd_argv", + nargs="*", + help="Optional command/arguments to execute inside an isolated PTY (pass after --)", + ) + p.add_argument("--cwd", type=Path, default=Path("."), help="Working directory for PTY command") + p.add_argument("--cols", type=int, default=120, help="Terminal width in columns (8..500)") + p.add_argument("--rows", type=int, default=36, help="Terminal height in rows (4..200)") + p.add_argument("--timeout", type=float, default=5.0, help="PTY read timeout in seconds (0.1..60)") + p.add_argument( + "--expect-text", + dest="expect_text", + action="append", + default=[], + help="Repeatable substring assertion that must appear on the rendered 2D surface", + ) + p.add_argument( + "--palette", + action="append", + default=[], + help="Repeatable hex color token (e.g. #38bdf8) allowed on the surface", + ) + p.add_argument("--output", type=Path, help="Optional JSON report output file") + + +def run_tui_audit( + *, + input_file: Path | None = None, + exec_bin: str | None = None, + exec_args: list[str] | None = None, + cwd: Path = Path("."), + cols: int = 120, + rows: int = 36, + timeout: float = 5.0, + expect_text: list[str] | None = None, + palette: list[str] | None = None, + output_file: Path | None = None, +) -> dict[str, Any]: + cmd: list[str] = [] + if exec_bin: + cmd.append(str(exec_bin)) + if exec_args: + cmd.extend(str(x) for x in exec_args) + if bool(input_file) == bool(cmd): + raise ValueError("Specify either --input or a PTY command (--exec / -- )") + if not (8 <= int(cols) <= 500 and 4 <= int(rows) <= 200): + raise ValueError("tui dimensions must be cols 8..500 and rows 4..200") + if not (0.1 <= float(timeout) <= 60.0): + raise ValueError("timeout must be 0.1..60.0 seconds") + + if input_file is not None: + if input_file.is_symlink() or not input_file.is_file(): + raise ValueError(f"Invalid --input file: {input_file}") + raw_bytes = input_file.read_bytes() + if len(raw_bytes) > MAX_INPUT_BYTES: + raise ValueError("--input exceeds 2 MiB budget") + surface = PyteTerminalSurface(cols=int(cols), rows=int(rows)) + surface.feed(raw_bytes.decode("utf-8", errors="replace")) + else: + if any(not part or "\x00" in part for part in cmd): + raise ValueError("Invalid PTY command arguments") + with TerminalSession( + cmd[0], + cmd[1:], + cwd=cwd.resolve(), + cols=int(cols), + rows=int(rows), + ) as session: + if expect_text: + for token in expect_text: + if token: + session.wait_for_text(str(token), timeout_s=float(timeout)) + else: + session.drain(timeout_s=min(float(timeout), 0.5)) + surface = session.surface + + palette_set = {str(c).strip().lower() for c in (palette or []) if str(c).strip()} or None + findings = surface.audit_tui_parity(expected_hex_tokens=palette_set) + missing_text: list[str] = [] + matched_text: dict[str, list[dict[str, Any]]] = {} + for token in expect_text or []: + needle = str(token) + if not needle: + continue + hits = surface.find_text(needle) + matched_text[needle] = hits + if not hits: + missing_text.append(needle) + findings.append( + { + "rule": "TW-TUI-TEXT-MISSING", + "severity": "error", + "message": f"Expected text {needle!r} not found on rendered terminal surface", + } + ) + + passed = len(findings) == 0 + report: dict[str, Any] = { + "schema": "testwins.tui-report/v1", + "status": "passed" if passed else "failed", + "exit_code": 0 if passed else 1, + "cols": surface.cols, + "rows": surface.rows, + "boxes": surface.detect_boxes(), + "findings": findings, + "missing_text": missing_text, + "matched_text": matched_text, + "lines": surface.lines(), + } + if output_file is not None: + atomic_json(output_file, report) + return report + + +def dispatch_tui(args: argparse.Namespace) -> int: + report = run_tui_audit( + input_file=args.input, + exec_bin=args.exec_bin, + exec_args=list(args.cmd_argv or []), + cwd=args.cwd, + cols=args.cols, + rows=args.rows, + timeout=args.timeout, + expect_text=list(args.expect_text or []), + palette=list(args.palette or []), + output_file=args.output, + ) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return int(report["exit_code"])