Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions project/ticket-021/README.md
Original file line number Diff line number Diff line change
@@ -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 <file>` or `--exec <binary> [--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`.
16 changes: 16 additions & 0 deletions project/ticket-021/intent.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
122 changes: 122 additions & 0 deletions tests/test_tui_cli.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions testwins/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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
Expand Down
151 changes: 151 additions & 0 deletions testwins/tui_cli.py
Original file line number Diff line number Diff line change
@@ -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 <file> or a PTY command (--exec / -- <cmd...>)")
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"])