chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:12:33 +08:00
commit aacb60a4af
3387 changed files with 981307 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
"""Byte-level ANSI sequence assertions for terminal writer collision detection.
These helpers pin the invariant that the concurrent chat REPL never emits a
malformed pairing of cursor, line-erase, or cursor-up sequences across the
combined stream, slash-handler, approval-suspend, and resume window.
The helpers here operate on raw byte streams (not Rich renderables) so
they catch the actual escape sequences the terminal would see, including
any sequences emitted directly by the frontend renderer or by Rich's Console
under the hood.
Sequence reference:
``\\x1b[?25l`` hide cursor (DECRST 25)
``\\x1b[?25h`` show cursor (DECSET 25)
``\\x1b[2K`` erase entire line in current row
``\\x1b[<n>A`` move cursor up N rows
"""
from __future__ import annotations
import re
# Pre-compiled patterns kept module-private so callers reuse the same
# regex object across the test matrix rather than re-compiling per call.
_CURSOR_UP_RE = re.compile(rb"\x1b\[\d*A")
def count_sequence(byte_stream: bytes, seq: bytes) -> int:
"""Return the number of non-overlapping ``seq`` occurrences in ``byte_stream``.
Uses :func:`bytes.count` which performs non-overlapping byte-level
matching. ``seq`` is matched as a literal substring (no regex).
"""
return byte_stream.count(seq)
def find_orphan_pairs(
byte_stream: bytes, open_seq: bytes, close_seq: bytes
) -> list[int]:
"""Return offsets of orphaned ``open_seq`` / ``close_seq`` markers.
Walks the byte stream in order, tracking a single-slot ``open`` state
machine: every ``open_seq`` MUST be followed by a ``close_seq`` before
the next ``open_seq``, and every ``close_seq`` MUST be preceded by an
unmatched ``open_seq``. Any deviation is recorded as an orphan offset.
Returns a list of byte offsets where an orphan was detected. An empty
list means the open/close pairing is balanced.
"""
if not open_seq or not close_seq:
raise ValueError("open_seq and close_seq must be non-empty")
orphans: list[int] = []
pos = 0
open_outstanding = False
while pos < len(byte_stream):
next_open = byte_stream.find(open_seq, pos)
next_close = byte_stream.find(close_seq, pos)
# Neither token found; balance check below decides whether the
# last unmatched open is an orphan.
if next_open == -1 and next_close == -1:
break
# Pick the earlier of the two markers; bias toward open when they
# tie so an "open followed by close at the same offset" (which
# cannot happen for non-overlapping ANSI sequences anyway) still
# toggles state in the right order.
if next_open != -1 and (next_close == -1 or next_open <= next_close):
if open_outstanding:
# Two opens with no intervening close — the first is an
# orphan-hide. Record its offset (the earlier one).
orphans.append(_last_open_offset(byte_stream, open_seq, next_open))
open_outstanding = True
pos = next_open + len(open_seq)
else:
if not open_outstanding:
# Close with no matching open — record its offset.
orphans.append(next_close)
open_outstanding = False
pos = next_close + len(close_seq)
if open_outstanding:
# Unmatched open hanging at the end — record the last open offset.
last = byte_stream.rfind(open_seq)
if last != -1:
orphans.append(last)
return orphans
def _last_open_offset(
byte_stream: bytes, open_seq: bytes, before: int
) -> int:
"""Return the offset of the open marker preceding ``before``.
Helper for `find_orphan_pairs` so the recorded offset is the *first*
unmatched open, not the one that overrode it.
"""
last = byte_stream.rfind(open_seq, 0, before)
return last if last != -1 else before
def assert_no_orphans(
byte_stream: bytes, open_seq: bytes, close_seq: bytes
) -> None:
"""Assert balanced ``open_seq`` / ``close_seq`` pairing in ``byte_stream``.
Raises ``AssertionError`` with a descriptive message including the
offsets of any detected orphans plus the open/close counts so test
failures point at the bytes that violated the invariant.
"""
orphans = find_orphan_pairs(byte_stream, open_seq, close_seq)
if not orphans:
return
open_count = count_sequence(byte_stream, open_seq)
close_count = count_sequence(byte_stream, close_seq)
raise AssertionError(
f"orphan {open_seq!r}/{close_seq!r} pair(s) detected at byte "
f"offsets {orphans}; open={open_count} close={close_count}; "
f"stream length={len(byte_stream)}"
)
def count_cursor_up(byte_stream: bytes) -> int:
"""Return the number of ``\\x1b[<n>A`` cursor-up sequences in ``byte_stream``.
Matches ``\\x1b[A`` (default 1), ``\\x1b[1A`` ... ``\\x1b[999A``. Other
CSI parameter forms (``\\x1b[5;2A``) are intentionally NOT matched here
because the chat REPL never emits them; if a future change does, the
test should be tightened explicitly.
"""
return len(_CURSOR_UP_RE.findall(byte_stream))
def find_cursor_up_offsets(byte_stream: bytes) -> list[int]:
"""Return byte offsets of every ``\\x1b[<n>A`` cursor-up sequence."""
return [m.start() for m in _CURSOR_UP_RE.finditer(byte_stream)]
# Common literals exposed so tests do not have to spell out the escape
# bytes inline. Keeping them as module constants also lets ast-grep land
# on consistent values across the test matrix.
HIDE_CURSOR = b"\x1b[?25l"
SHOW_CURSOR = b"\x1b[?25h"
ERASE_LINE = b"\x1b[2K"
@@ -0,0 +1 @@
"""Real-terminal TUI integration harness for OpenSquilla."""
@@ -0,0 +1,224 @@
from __future__ import annotations
import re
from tui_real_terminal.driver import TerminalFrame
_ANSI_RE = re.compile(
r"\x1b(?:"
r"\[[0-?]*[ -/]*[@-~]"
r"|\][^\x07\x1b]*(?:\x07|\x1b\\)"
r"|P[^\x1b]*\x1b\\"
r"|[@-Z\\-_]"
r")"
)
_PROMPT_PLACEHOLDERS = ("send a message", "send a massage")
_COMPLETION_TITLES = ("commands", "files")
_COMPLETION_BACKGROUND_MARKERS = (
"OPEN_SQUILLA_TUI_READY",
"fake-response:",
"stream-token-",
"intermediate-before-tool",
"second-intermediate-line",
"complex-state-complete",
"terminal-change-response",
"architecture-analysis-complete",
"fake_tool",
"approval requested",
"Traceback (most recent call last)",
)
def assert_visible_text(frame: TerminalFrame, expected: str) -> None:
if expected not in frame.text:
raise AssertionError(
f"{frame.checkpoint}: expected visible text {expected!r}; screen was:\n"
f"{frame.text}"
)
def assert_prompt_ready(frame: TerminalFrame) -> None:
if "you" not in frame.text and not any(
placeholder in frame.text for placeholder in _PROMPT_PLACEHOLDERS
):
raise AssertionError(f"{frame.checkpoint}: prompt is not visibly ready:\n{frame.text}")
def assert_no_inline_prompt_chrome_collision(frame: TerminalFrame) -> None:
for line in frame.text.splitlines():
if re.match(r"^\s*│\s+s(?:[#|*⠋✓✗]|[\u4e00-\u9fff])", line):
raise AssertionError(
f"{frame.checkpoint}: inline prompt chrome overlapped output:\n{frame.text}"
)
resize_checkpoint = "narrow" in frame.checkpoint or "wide" in frame.checkpoint
if (
sum(line.count(placeholder) for placeholder in _PROMPT_PLACEHOLDERS) > 1
and not resize_checkpoint
):
raise AssertionError(
f"{frame.checkpoint}: inline prompt chrome was duplicated:\n{frame.text}"
)
def assert_no_completion_menu_overlap(frame: TerminalFrame) -> None:
"""Reject completion-menu rectangles whose border area is polluted.
The OpenTUI completion menu is a rounded rectangle with a ``commands`` or
``files`` title; clean top/bottom border spans contain only border fill and
that title, and the rectangle body must not contain known conversation text.
"""
text = _ANSI_RE.sub("", frame.text)
lines = _screen_lines(frame, text)
for top_index, left, right in _completion_top_spans(lines):
top_segment = _line_span(lines[top_index], left, right)
if not _is_completion_border_segment(top_segment, top=True):
_raise_completion_assertion(
frame,
"completion menu overlap: dirty top border",
)
bottom = _find_completion_bottom(lines, top_index, left, right)
if bottom is None:
_raise_completion_assertion(
frame,
"completion menu overlap: incomplete menu border",
)
bottom_index, body_right = bottom
bottom_segment = _line_span(lines[bottom_index], left, body_right)
if not _is_completion_border_segment(bottom_segment, top=False):
_raise_completion_assertion(
frame,
"completion menu overlap: dirty bottom border",
)
for body_index in range(top_index + 1, bottom_index):
line = lines[body_index]
segment = _line_span(line, left, body_right)
if any(marker in segment for marker in _COMPLETION_BACKGROUND_MARKERS):
_raise_completion_assertion(
frame,
"completion menu overlap: conversation text inside menu",
)
if (
not _has_completion_vertical_edges(line, left, body_right)
and segment.strip()
):
_raise_completion_assertion(
frame,
"completion menu overlap: broken vertical border",
)
def assert_no_stale_completion_menu(frame: TerminalFrame) -> None:
"""Reject completion-menu remnants after a close or clear operation.
A cleared frame should not contain a ``commands``/``files`` rounded title
border, nor a selected completion row left behind with rounded menu chrome.
"""
text = _ANSI_RE.sub("", frame.text)
lines = _screen_lines(frame, text)
if list(_completion_top_spans(lines)):
raise AssertionError(f"{frame.checkpoint}: stale completion menu:\n{frame.text}")
has_rounded_chrome = any(char in text for char in "╭╮╰╯")
for line in lines:
if has_rounded_chrome and " " in line and "" in line:
raise AssertionError(f"{frame.checkpoint}: stale completion menu:\n{frame.text}")
def assert_no_traceback(frame: TerminalFrame) -> None:
forbidden = (
"Traceback (most recent call last)",
"RuntimeError:",
"Unhandled exception",
)
for marker in forbidden:
if marker in frame.text:
raise AssertionError(f"{frame.checkpoint}: unexpected error marker {marker!r}")
def assert_no_raw_ansi_leakage(frame: TerminalFrame) -> None:
match = _ANSI_RE.search(frame.text)
if match:
raise AssertionError(
f"{frame.checkpoint}: raw terminal escape leaked at offset {match.start()}"
)
def _completion_top_spans(lines: list[str]) -> list[tuple[int, int, int]]:
spans: list[tuple[int, int, int]] = []
for index, line in enumerate(lines):
search_at = 0
while True:
left = line.find("", search_at)
if left < 0:
break
right = line.find("", left + 1)
if right < 0:
break
segment = line[left : right + 1]
if _has_completion_title(segment):
spans.append((index, left, right))
search_at = right + 1
return spans
def _screen_lines(frame: TerminalFrame, text: str) -> list[str]:
lines: list[str] = []
cols = frame.size.cols
for line in text.splitlines():
if cols > 0 and len(line) > cols:
lines.extend(
line[index : index + cols] for index in range(0, len(line), cols)
)
else:
lines.append(line)
return lines
def _find_completion_bottom(
lines: list[str],
top_index: int,
left: int,
right: int,
) -> tuple[int, int] | None:
for index in range(top_index + 1, len(lines)):
line = lines[index]
if len(line) <= right:
continue
bottom_right = line.find("", left + 1)
if line[left] == "" and bottom_right >= right:
return index, bottom_right
return None
def _line_span(line: str, left: int, right: int) -> str:
if len(line) <= left:
return ""
return line[left : min(len(line), right + 1)]
def _has_completion_title(segment: str) -> bool:
return any(f" {title} " in segment for title in _COMPLETION_TITLES)
def _is_completion_border_segment(segment: str, *, top: bool) -> bool:
if len(segment) < 2:
return False
expected_left, expected_right = ("", "") if top else ("", "")
if segment[0] != expected_left or segment[-1] != expected_right:
return False
inner = segment[1:-1]
for title in _COMPLETION_TITLES:
inner = inner.replace(title, "")
return all(char in {" ", ""} for char in inner)
def _has_completion_vertical_edges(line: str, left: int, right: int) -> bool:
return len(line) > right and line[left] == "" and line[right] == ""
def _raise_completion_assertion(frame: TerminalFrame, reason: str) -> None:
raise AssertionError(f"{frame.checkpoint}: {reason}:\n{frame.text}")
@@ -0,0 +1,142 @@
from __future__ import annotations
import os
import sys
from collections.abc import Callable
from pathlib import Path
from typing import cast
import pytest
HARNESS_PARENT = Path(__file__).resolve().parents[1]
if str(HARNESS_PARENT) not in sys.path:
sys.path.insert(0, str(HARNESS_PARENT))
from tui_real_terminal.driver import ( # noqa: E402
DriverSelection,
build_run_id,
open_real_terminal_session,
probe_terminal_capabilities,
)
from tui_real_terminal.evidence import EvidenceBundle, ScenarioResult # noqa: E402
from tui_real_terminal.scenarios import TuiScenario, run_scenario # noqa: E402
from tui_real_terminal.targets import TargetContext, build_tui_target # noqa: E402
def pytest_addoption(parser: pytest.Parser) -> None:
parser.addoption(
"--tui-backend",
action="store",
default="opentui",
choices=("opentui", "live-opentui"),
)
parser.addoption(
"--tui-driver",
action="store",
default="auto",
choices=("auto", "tmux", "pty"),
)
parser.addoption(
"--tui-artifact-root",
action="store",
default=".artifacts/tui-real-terminal/runs",
)
def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line(
"markers",
"tui_real_terminal: real terminal TUI integration tests driven through tmux or PTY",
)
@pytest.fixture
def artifact_root(pytestconfig: pytest.Config) -> Path:
return Path(str(pytestconfig.getoption("--tui-artifact-root")))
@pytest.fixture
def tui_backend(pytestconfig: pytest.Config) -> str:
return str(pytestconfig.getoption("--tui-backend"))
@pytest.fixture
def tui_driver(pytestconfig: pytest.Config) -> DriverSelection:
return cast(DriverSelection, str(pytestconfig.getoption("--tui-driver")))
@pytest.fixture
def run_real_terminal_scenario(
artifact_root: Path,
tui_backend: str,
tui_driver: DriverSelection,
) -> Callable[[TuiScenario], ScenarioResult]:
def _run(scenario: TuiScenario) -> ScenarioResult:
if tui_backend == "live-opentui":
if scenario.family != "live_prompt":
pytest.skip("live-opentui backend only runs live_prompt scenarios")
if os.environ.get("OPENSQUILLA_TUI_LIVE_REAL") != "1":
pytest.skip(
"set OPENSQUILLA_TUI_LIVE_REAL=1 to run the real CLI/OpenTUI smoke"
)
capabilities = probe_terminal_capabilities()
if capabilities.preferred_driver == "none":
pytest.skip(capabilities.skip_reason or "real-terminal capabilities unavailable")
evidence = EvidenceBundle.create(
artifact_root,
scenario_id=scenario.scenario_id,
backend_id=tui_backend,
)
target = build_tui_target(
tui_backend,
TargetContext(
project_root=Path.cwd(),
artifact_dir=evidence.run_dir,
scenario_id=scenario.scenario_id,
size=scenario.initial_size,
),
)
if not target.available:
pytest.skip(target.skip_reason or f"TUI backend {tui_backend!r} unavailable")
if (
target.backend_id == "live-opentui"
and os.environ.get("OPENSQUILLA_TUI_LIVE_REAL") != "1"
):
pytest.skip(
"set OPENSQUILLA_TUI_LIVE_REAL=1 to run the real CLI/OpenTUI smoke"
)
if (
scenario.required_backend_id is not None
and target.backend_id != scenario.required_backend_id
):
pytest.skip(
f"scenario {scenario.scenario_id!r} requires "
f"--tui-backend={scenario.required_backend_id}"
)
scenario_driver = tui_driver
if scenario.requires_tmux:
if not capabilities.tmux_available:
pytest.skip(f"scenario {scenario.scenario_id!r} requires tmux")
if tui_driver == "pty":
pytest.skip(f"scenario {scenario.scenario_id!r} requires tmux")
scenario_driver = "tmux"
session = open_real_terminal_session(
command=target.command,
cwd=Path.cwd(),
env=target.env,
run_id=build_run_id(scenario.scenario_id),
size=target.initial_size,
artifact_dir=evidence.run_dir,
driver=scenario_driver,
)
return run_scenario(
scenario=scenario,
session=session,
evidence=evidence,
backend_id=target.backend_id,
)
return _run
@@ -0,0 +1,460 @@
from __future__ import annotations
import errno
import os
import re
import shutil
import struct
import subprocess
import sys
import time
from dataclasses import dataclass, field
from itertools import count
from pathlib import Path
from typing import Literal, Protocol
try:
import pty
except ImportError: # pragma: no cover - exercised only on platforms without pty
pty = None # type: ignore[assignment]
DriverKind = Literal["tmux", "pty"]
DriverSelection = Literal["auto", "tmux", "pty"]
_RUN_ID_COUNTER = count()
@dataclass(frozen=True)
class TerminalSize:
cols: int = 100
rows: int = 30
def __post_init__(self) -> None:
if self.cols <= 0 or self.rows <= 0:
raise ValueError("terminal size must be positive")
@dataclass(frozen=True)
class TerminalFrame:
checkpoint: str
text: str
captured_at_ms: int
size: TerminalSize
@dataclass(frozen=True)
class TerminalCapabilities:
tmux_available: bool
pty_available: bool
screenshot_available: bool
resize_available: bool
preferred_driver: Literal["tmux", "pty", "none"]
skip_reason: str | None = None
class RealTerminalSession(Protocol):
run_id: str
kind: DriverKind
size: TerminalSize
def start(self) -> None: ...
def send_text(self, text: str) -> None: ...
def send_key(self, key: str) -> None: ...
def paste(self, text: str) -> None: ...
def resize(self, size: TerminalSize) -> None: ...
def capture_text(self, checkpoint: str) -> TerminalFrame: ...
def capture_scrollback_text(self, checkpoint: str) -> TerminalFrame: ...
def wait_for_text(
self,
needle: str,
*,
timeout_s: float,
checkpoint: str,
) -> TerminalFrame: ...
def is_alive(self) -> bool: ...
def terminate(self) -> None: ...
_SAFE_ID_RE = re.compile(r"[^A-Za-z0-9_-]+")
_ANSI_RE = re.compile(
r"\x1b(?:"
r"\[[0-?]*[ -/]*[@-~]"
r"|\][^\x07\x1b]*(?:\x07|\x1b\\)"
r"|P[^\x1b]*\x1b\\"
r"|[@-Z\\-_]"
r")"
)
def _now_ms() -> int:
return time.time_ns() // 1_000_000
def _run_id_suffix() -> str:
return f"{time.time_ns()}-{os.getpid()}-{next(_RUN_ID_COUNTER)}"
def build_run_id(scenario_id: str) -> str:
safe = _SAFE_ID_RE.sub("-", scenario_id.strip().lower()).strip("-") or "scenario"
return f"opensquilla-tui-{safe}-{_run_id_suffix()}"
def _strip_ansi(text: str) -> str:
return _ANSI_RE.sub("", text)
def probe_terminal_capabilities() -> TerminalCapabilities:
tmux_available = shutil.which("tmux") is not None
pty_available = sys.platform != "win32" and pty is not None and hasattr(pty, "openpty")
if tmux_available:
preferred_driver: Literal["tmux", "pty", "none"] = "tmux"
elif pty_available:
preferred_driver = "pty"
else:
preferred_driver = "none"
if preferred_driver != "none":
skip_reason: str | None = None
elif sys.platform == "win32":
skip_reason = (
"real-terminal harness needs tmux or a Unix PTY, which native Windows "
"lacks; run under WSL2 (see docs/tui-real-terminal-harness.md)"
)
else:
skip_reason = "tmux and PTY are unavailable"
return TerminalCapabilities(
tmux_available=tmux_available,
pty_available=pty_available,
screenshot_available=False,
resize_available=tmux_available or pty_available,
preferred_driver=preferred_driver,
skip_reason=skip_reason,
)
@dataclass
class _BaseTerminalSession:
command: list[str]
cwd: Path
env: dict[str, str]
run_id: str
size: TerminalSize
terminal_log: Path
kind: DriverKind = field(init=False)
def _append_log(self, text: str) -> None:
self.terminal_log.parent.mkdir(parents=True, exist_ok=True)
with self.terminal_log.open("a", encoding="utf-8") as fh:
fh.write(text)
if text and not text.endswith("\n"):
fh.write("\n")
@dataclass
class TmuxTerminalSession(_BaseTerminalSession):
kind: DriverKind = field(init=False, default="tmux")
def start(self) -> None:
env_prefix = ["env", *[f"{key}={value}" for key, value in sorted(self.env.items())]]
subprocess.run(
[
"tmux",
"new-session",
"-d",
"-s",
self.run_id,
"-x",
str(self.size.cols),
"-y",
str(self.size.rows),
"-c",
str(self.cwd),
*env_prefix,
*self.command,
],
check=True,
)
def send_text(self, text: str) -> None:
subprocess.run(["tmux", "send-keys", "-t", self.run_id, "-l", text], check=True)
subprocess.run(["tmux", "send-keys", "-t", self.run_id, "Enter"], check=True)
def send_key(self, key: str) -> None:
subprocess.run(["tmux", "send-keys", "-t", self.run_id, key], check=True)
def paste(self, text: str) -> None:
subprocess.run(
["tmux", "load-buffer", "-b", self.run_id, "-"],
check=True,
input=text,
text=True,
)
subprocess.run(
["tmux", "paste-buffer", "-p", "-t", self.run_id, "-b", self.run_id],
check=True,
)
def resize(self, size: TerminalSize) -> None:
self.size = size
subprocess.run(
[
"tmux",
"resize-window",
"-t",
self.run_id,
"-x",
str(size.cols),
"-y",
str(size.rows),
],
check=True,
)
def capture_text(self, checkpoint: str) -> TerminalFrame:
result = subprocess.run(
["tmux", "capture-pane", "-t", self.run_id, "-p", "-J"],
check=True,
text=True,
stdout=subprocess.PIPE,
)
frame = TerminalFrame(checkpoint, result.stdout, _now_ms(), self.size)
self._append_log(f"\n--- {checkpoint} ---\n{frame.text}")
return frame
def capture_scrollback_text(self, checkpoint: str) -> TerminalFrame:
result = subprocess.run(
["tmux", "capture-pane", "-t", self.run_id, "-S", "-", "-p", "-J"],
check=True,
text=True,
stdout=subprocess.PIPE,
)
frame = TerminalFrame(checkpoint, result.stdout, _now_ms(), self.size)
self._append_log(f"\n--- {checkpoint} scrollback ---\n{frame.text}")
return frame
def alternate_screen_active(self) -> bool:
# tmux tracks the pane's alternate-screen mode itself, so this probes
# real terminal state rather than inferring it from captured text: an
# app that exited without leaving the alternate screen still shows
# later shell output, just drawn over the stale alternate screen.
result = subprocess.run(
["tmux", "display-message", "-p", "-t", self.run_id, "#{alternate_on}"],
check=True,
text=True,
stdout=subprocess.PIPE,
)
return result.stdout.strip() == "1"
def wait_for_text(
self,
needle: str,
*,
timeout_s: float,
checkpoint: str,
) -> TerminalFrame:
deadline = time.monotonic() + timeout_s
last = self.capture_text(checkpoint)
if needle in last.text:
return last
while time.monotonic() < deadline:
time.sleep(0.05)
last = self.capture_text(checkpoint)
if needle in last.text:
return last
raise TimeoutError(f"timed out waiting for {needle!r}; last screen: {last.text}")
def is_alive(self) -> bool:
return (
subprocess.run(
["tmux", "has-session", "-t", self.run_id],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode
== 0
)
def terminate(self) -> None:
subprocess.run(
["tmux", "kill-session", "-t", self.run_id],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
@dataclass
class PtyTerminalSession(_BaseTerminalSession):
kind: DriverKind = field(init=False, default="pty")
_master_fd: int | None = field(init=False, default=None)
_process: subprocess.Popen[bytes] | None = field(init=False, default=None)
_buffer: bytearray = field(init=False, default_factory=bytearray)
def start(self) -> None:
if pty is None or not hasattr(pty, "openpty"):
raise RuntimeError("PTY terminal driver is unavailable")
master_fd, slave_fd = pty.openpty()
self._master_fd = master_fd
self._configure_master_fd()
self._set_pty_size()
env = self._process_env()
try:
self._process = subprocess.Popen(
self.command,
cwd=self.cwd,
env=env,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True,
)
finally:
os.close(slave_fd)
def send_text(self, text: str) -> None:
self._write(text.encode("utf-8"))
self.send_key("Enter")
def send_key(self, key: str) -> None:
sequences = {
"Enter": b"\r",
"C-c": b"\x03",
"Ctrl-C": b"\x03",
"C-d": b"\x04",
"EOF": b"\x04",
"Escape": b"\x1b",
"Tab": b"\t",
"Backspace": b"\x7f",
}
self._write(sequences.get(key, key.encode("utf-8")))
def paste(self, text: str) -> None:
payload = f"\x1b[200~{text}\x1b[201~"
self._write(payload.encode("utf-8"))
def resize(self, size: TerminalSize) -> None:
self.size = size
if self._master_fd is not None:
self._set_pty_size()
def capture_text(self, checkpoint: str) -> TerminalFrame:
self._drain_output()
text = _strip_ansi(self._buffer.decode("utf-8", errors="replace"))
frame = TerminalFrame(checkpoint, text, _now_ms(), self.size)
self._append_log(f"\n--- {checkpoint} ---\n{frame.text}")
return frame
def capture_scrollback_text(self, checkpoint: str) -> TerminalFrame:
return self.capture_text(checkpoint)
def wait_for_text(
self,
needle: str,
*,
timeout_s: float,
checkpoint: str,
) -> TerminalFrame:
deadline = time.monotonic() + timeout_s
last = self.capture_text(checkpoint)
if needle in last.text:
return last
while time.monotonic() < deadline:
time.sleep(0.05)
last = self.capture_text(checkpoint)
if needle in last.text:
return last
raise TimeoutError(f"timed out waiting for {needle!r}; last screen: {last.text}")
def is_alive(self) -> bool:
return self._process is not None and self._process.poll() is None
def terminate(self) -> None:
process = self._process
if process is not None and process.poll() is None:
process.terminate()
try:
process.wait(timeout=1)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=1)
if self._master_fd is not None:
try:
os.close(self._master_fd)
except OSError:
pass
self._master_fd = None
def _configure_master_fd(self) -> None:
if self._master_fd is None:
return
os.set_blocking(self._master_fd, False)
def _process_env(self) -> dict[str, str]:
env = os.environ.copy()
env.update(self.env)
env.setdefault("TERM", "xterm-256color")
env["COLUMNS"] = str(self.size.cols)
env["LINES"] = str(self.size.rows)
return env
def _set_pty_size(self) -> None:
if self._master_fd is None:
return
import fcntl
import termios
packed_size = struct.pack("HHHH", self.size.rows, self.size.cols, 0, 0)
fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, packed_size)
def _write(self, data: bytes) -> None:
if self._master_fd is None:
raise RuntimeError("PTY session has not started")
os.write(self._master_fd, data)
def _drain_output(self) -> None:
if self._master_fd is None:
return
while True:
try:
chunk = os.read(self._master_fd, 4096)
except BlockingIOError:
return
except OSError as exc:
if exc.errno in {errno.EIO, errno.EBADF}:
return
raise
if not chunk:
return
self._buffer.extend(chunk)
def open_real_terminal_session(
*,
command: list[str],
cwd: Path,
env: dict[str, str],
run_id: str,
size: TerminalSize,
artifact_dir: Path,
driver: DriverSelection = "auto",
) -> RealTerminalSession:
capabilities = probe_terminal_capabilities()
selected = capabilities.preferred_driver if driver == "auto" else driver
terminal_log = artifact_dir / "terminal.log"
if selected == "tmux" and capabilities.tmux_available:
return TmuxTerminalSession(command, cwd, env, run_id, size, terminal_log)
if selected == "pty" and capabilities.pty_available:
return PtyTerminalSession(command, cwd, env, run_id, size, terminal_log)
if driver != "auto":
raise RuntimeError(f"requested terminal driver {selected!r} is unavailable")
reason = capabilities.skip_reason or f"requested terminal driver {selected!r} is unavailable"
raise RuntimeError(reason)
@@ -0,0 +1,104 @@
from __future__ import annotations
import json
import re
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Literal
from tui_real_terminal.driver import TerminalFrame
ScenarioStatus = Literal["pass", "fail"]
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9_.-]+")
@dataclass(frozen=True)
class ScenarioFailure:
step_id: str
message: str
elapsed_s: float
last_screen: str
artifact_dir: str
@dataclass(frozen=True)
class ScenarioResult:
scenario_id: str
backend_id: str
status: ScenarioStatus
run_dir: Path
failure: ScenarioFailure | None = None
class EvidenceBundle:
def __init__(self, run_dir: Path, *, scenario_id: str, backend_id: str) -> None:
self.run_dir = run_dir
self.scenario_id = scenario_id
self.backend_id = backend_id
self.frames_dir = run_dir / "frames"
self.screenshots_dir = run_dir / "screenshots"
self.transcript_path = run_dir / "transcript.txt"
self.scrollback_path = run_dir / "scrollback.txt"
self.terminal_log_path = run_dir / "terminal.log"
self.app_log_path = run_dir / "app.log"
@classmethod
def create(cls, root: Path, *, scenario_id: str, backend_id: str) -> EvidenceBundle:
run_dir = root / f"{time.strftime('%Y%m%d-%H%M%S')}-{time.time_ns()}-{scenario_id}"
frames_dir = run_dir / "frames"
screenshots_dir = run_dir / "screenshots"
frames_dir.mkdir(parents=True, exist_ok=False)
screenshots_dir.mkdir(parents=True, exist_ok=True)
bundle = cls(run_dir, scenario_id=scenario_id, backend_id=backend_id)
for file_path in (
bundle.terminal_log_path,
bundle.app_log_path,
bundle.transcript_path,
bundle.scrollback_path,
):
file_path.touch()
return bundle
def write_scenario(self, payload: dict[str, Any]) -> None:
self._write_json("scenario.json", payload)
def record_frame(self, frame: TerminalFrame) -> Path:
index = len(tuple(self.frames_dir.glob("*.txt")))
filename = f"{index:03d}-{_safe_name(frame.checkpoint)}.txt"
frame_path = self.frames_dir / filename
frame_path.write_text(frame.text, encoding="utf-8")
with self.transcript_path.open("a", encoding="utf-8") as fh:
fh.write(f"\n--- {frame.checkpoint} ---\n{frame.text}\n")
return frame_path
def write_scrollback(self, frame: TerminalFrame) -> Path:
self.scrollback_path.write_text(frame.text, encoding="utf-8")
return self.scrollback_path
def write_visual_verdict(self, payload: dict[str, Any]) -> Path:
return self._write_json("visual-verdict.json", payload)
def write_result(self, result: ScenarioResult) -> Path:
payload: dict[str, Any] = {
"scenario_id": result.scenario_id,
"backend_id": result.backend_id,
"status": result.status,
"artifact_dir": str(result.run_dir),
}
if result.failure is not None:
payload["failure"] = asdict(result.failure)
return self._write_json("result.json", payload)
def _write_json(self, filename: str, payload: dict[str, Any]) -> Path:
path = self.run_dir / filename
path.write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
return path
def _safe_name(value: str) -> str:
return _SAFE_NAME_RE.sub("-", value.strip()).strip("-") or "frame"
@@ -0,0 +1,139 @@
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from tui_real_terminal.replay import replay_architecture_prompt
else:
from replay import replay_architecture_prompt
from opensquilla.cli.chat.turn import UsageSummary # type: ignore[import-untyped]
from opensquilla.cli.tui.opentui.renderer import (
OpenTuiStreamRenderer, # type: ignore[import-untyped]
)
from opensquilla.cli.tui.opentui.runtime import ( # type: ignore[import-untyped]
get_tui_output,
run_opentui_chat_runtime,
)
from opensquilla.engine.commands import Surface # type: ignore[import-untyped]
def _app_log_path() -> Path:
return Path(os.environ["OPENSQUILLA_TUI_FAKE_APP_LOG"])
def _write_log(event: str, payload: dict[str, Any] | None = None) -> None:
path = _app_log_path()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps({"event": event, "payload": payload or {}}, sort_keys=True) + "\n")
async def _render_response(
scope: dict[str, Any],
user_input: str,
scenario_id: str,
) -> bool:
if user_input.strip() in {"/exit", "exit"}:
_write_log("exit")
return False
output = get_tui_output(scope)
if output is None:
raise RuntimeError("opentui output handle was not exposed")
renderer = OpenTuiStreamRenderer(title="squilla", output_handle=output)
usage = UsageSummary(model="fake-terminal", input_tokens=1, output_tokens=2)
_write_log("dispatch", {"input": user_input, "scenario_id": scenario_id})
if scenario_id == "long_streaming":
for index in range(80):
await renderer.aappend_text(f"stream-token-{index:03d} ")
if index % 20 == 0:
await asyncio.sleep(0)
elif scenario_id == "complex_ui_state":
_set_toolbar(output, "router_hud", "route standard -> fake-terminal 99% save 42%")
_set_toolbar(output, "router_hud_style", "normal")
_invalidate(output)
await renderer.astatus("router route standard -> fake-terminal 99% save 42%")
# Mirror the real turn shape so the harness exercises all three block
# kinds:
# 1. reasoning — the model's extended-thinking PROCESS, collapsed to a
# transient "Thinking…" marker, its verbatim text never shown.
await renderer.aappend_reasoning(
"reasoning-process-should-stay-hidden "
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ" * 6
)
# 2. assistant text the model speaks before a tool call — streams into
# a purple intermediate narration block.
await renderer.aappend_text(
"intermediate-before-tool narration "
+ "0123456789" * 10
+ "\nsecond-intermediate-line tail",
presentation="intermediate",
)
await renderer.atool_start("fake_tool", {"path": "fixture.txt"}, "tool-1")
await renderer.atool_finished("tool-1", success=True, elapsed=0.01)
await renderer.astatus("approval requested: allow fake_tool fixture.txt")
# 3. final answer — the cyan answer card.
await renderer.aappend_text(
"complex-state-complete tool-card history projection",
presentation="answer",
)
elif scenario_id == "architecture_prompt":
usage = await replay_architecture_prompt(renderer, output)
elif scenario_id == "terminal_changes":
# Echo the submitted input back so the harness can prove a multi-line
# paste survived the composer round-trip. The line count and each line
# render as their own short markdown paragraphs, so narrow-terminal
# word wrap can never split an asserted needle across rows.
lines = user_input.split("\n")
await renderer.aappend_text(
f"terminal-change-response lines={len(lines)}\n\n"
"CJK混合ASCII multiline-paste ctrl-c-recovery wide-and-narrow-layout"
)
for index, line in enumerate(lines):
await renderer.aappend_text(f"\n\necho-line-{index}:{line}")
else:
await renderer.aappend_text(f"fake-response:{user_input}")
await renderer.afinalize(usage)
_write_log("turn_complete", {"input": user_input})
return True
def _set_toolbar(output: Any, key: str, value: object | None) -> None:
setter = getattr(output, "set_toolbar", None)
if callable(setter):
setter(key, value)
def _invalidate(output: Any) -> None:
invalidate = getattr(output, "invalidate", None)
if callable(invalidate):
invalidate()
async def _run() -> None:
scenario_id = os.environ.get("OPENSQUILLA_TUI_FAKE_SCENARIO", "launch_input_loop")
scope: dict[str, Any] = {
"model": "fake-terminal",
"session_key": f"fake:{scenario_id}",
}
_write_log("ready", {"scenario_id": scenario_id})
await run_opentui_chat_runtime(
surface=Surface.CLI_GATEWAY,
scope=scope,
dispatch=lambda user_input: _render_response(scope, user_input, scenario_id),
queue_max_size=4,
)
def main() -> None:
asyncio.run(_run())
if __name__ == "__main__":
main()
@@ -0,0 +1,113 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from opensquilla.cli.chat.turn import UsageSummary # type: ignore[import-untyped]
ARCHITECTURE_PROMPT_FIXTURE = (
Path(__file__).resolve().parents[3]
/ "fixtures"
/ "tui"
/ "architecture_prompt_replay.json"
)
async def replay_architecture_prompt(renderer: Any, output: Any) -> UsageSummary:
fixture = json.loads(ARCHITECTURE_PROMPT_FIXTURE.read_text(encoding="utf-8"))
usage = UsageSummary(model="fake-terminal", input_tokens=1, output_tokens=2)
events = fixture["events"]
tool_outputs_by_id = _tool_outputs_by_id(events)
for event in events:
kind = event["kind"]
payload = event.get("payload", {})
if kind == "toolbar":
_set_toolbar(output, "router_hud", payload.get("router_hud"))
_set_toolbar(output, "router_hud_style", payload.get("router_hud_style"))
_invalidate(output)
elif kind == "status":
await renderer.astatus(str(payload.get("message", "")))
for detail in _details_from_payload(payload):
await renderer.astatus(detail)
elif kind == "tool_start":
args = payload.get("args")
await renderer.atool_start(
str(payload.get("name", "tool")),
args if isinstance(args, dict) else None,
str(payload.get("tool_use_id", "")),
)
elif kind == "tool_finished":
elapsed = payload.get("elapsed")
tool_use_id = str(payload.get("tool_use_id", ""))
await renderer.atool_finished(
tool_use_id,
success=bool(payload.get("success", True)),
elapsed=elapsed if isinstance(elapsed, float | int) else None,
error=str(payload["error"]) if "error" in payload else None,
result=_tool_result_from_output(tool_outputs_by_id.get(tool_use_id)),
)
elif kind == "tool_output":
continue
elif kind == "text_delta":
await renderer.aappend_text(str(payload.get("text", "")))
elif kind == "done":
usage_payload = payload.get("usage", {})
if isinstance(usage_payload, dict):
usage = UsageSummary(
model=str(usage_payload.get("model", "fake-terminal")),
input_tokens=int(usage_payload.get("input_tokens", 0)),
output_tokens=int(usage_payload.get("output_tokens", 0)),
)
return usage
def _details_from_payload(payload: dict[str, Any]) -> list[str]:
details = payload.get("detail", [])
if isinstance(details, list):
return [str(detail) for detail in details]
return []
def _tool_outputs_by_id(events: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
outputs: dict[str, dict[str, Any]] = {}
for event in events:
if event.get("kind") != "tool_output":
continue
payload = event.get("payload", {})
if not isinstance(payload, dict):
continue
tool_use_id = payload.get("tool_use_id")
if tool_use_id is None:
continue
outputs[str(tool_use_id)] = payload
return outputs
def _tool_result_from_output(payload: dict[str, Any] | None) -> str | None:
if payload is None:
return None
parts: list[str] = []
summary = str(payload.get("summary", "")).strip()
if summary:
parts.append(summary)
stdout = payload.get("stdout", [])
if isinstance(stdout, list) and stdout:
parts.extend(str(line) for line in stdout)
return "\n".join(parts) if parts else None
def _set_toolbar(output: Any, key: str, value: object | None) -> None:
setter = getattr(output, "set_toolbar", None)
if callable(setter):
setter(key, value)
def _invalidate(output: Any) -> None:
invalidate = getattr(output, "invalidate", None)
if callable(invalidate):
invalidate()
@@ -0,0 +1,554 @@
from __future__ import annotations
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
from tui_real_terminal import assertions
from tui_real_terminal.driver import (
RealTerminalSession,
TerminalFrame,
TerminalSize,
)
from tui_real_terminal.evidence import (
EvidenceBundle,
ScenarioFailure,
ScenarioResult,
)
from tui_real_terminal.visual import blocking, build_visual_verdict
ScenarioFamily = Literal[
"launch_and_input_loop",
"long_streaming_output",
"complex_ui_state",
"architecture_prompt",
"live_prompt",
"terminal_changes",
"completion_menu",
]
ScenarioAction = Literal[
"wait_text",
"wait_any_text",
"send_text",
"paste",
"key",
"resize",
"capture",
]
@dataclass(frozen=True)
class ScenarioStep:
step_id: str
action: ScenarioAction
value: str = ""
checkpoint: str = ""
timeout_s: float = 5.0
@dataclass(frozen=True)
class TuiScenario:
scenario_id: str
family: ScenarioFamily
initial_size: TerminalSize
steps: tuple[ScenarioStep, ...]
expected_text: tuple[str, ...]
requires_tmux: bool = False
requires_prompt_ready: bool = True
required_backend_id: str | None = None
def to_json_dict(self) -> dict[str, Any]:
return {
"scenario_id": self.scenario_id,
"family": self.family,
"initial_size": {
"cols": self.initial_size.cols,
"rows": self.initial_size.rows,
},
"steps": [step.__dict__ for step in self.steps],
"expected_text": list(self.expected_text),
"requires_tmux": self.requires_tmux,
"requires_prompt_ready": self.requires_prompt_ready,
"required_backend_id": self.required_backend_id,
}
def all_scenarios() -> tuple[TuiScenario, ...]:
return (
TuiScenario(
scenario_id="launch_input_loop",
family="launch_and_input_loop",
initial_size=TerminalSize(cols=100, rows=30),
steps=(
ScenarioStep("wait-ready", "wait_text", "OPEN_SQUILLA_TUI_READY", "ready"),
ScenarioStep("send-message", "send_text", "hello harness", "after-input"),
ScenarioStep(
"wait-response",
"wait_text",
"fake-response:hello harness",
"after-response",
),
),
expected_text=("fake-response:hello harness",),
),
TuiScenario(
scenario_id="cjk_input_loop",
family="launch_and_input_loop",
initial_size=TerminalSize(cols=100, rows=30),
steps=(
ScenarioStep("wait-ready", "wait_text", "OPEN_SQUILLA_TUI_READY", "ready"),
ScenarioStep(
"send-message",
"send_text",
"中文输入 CJK混合ASCII",
"after-input",
),
ScenarioStep(
"wait-response",
"wait_text",
"fake-response:中文输入 CJK混合ASCII",
"after-response",
),
),
expected_text=("fake-response:中文输入 CJK混合ASCII", "CJK混合ASCII"),
),
TuiScenario(
scenario_id="long_streaming",
family="long_streaming_output",
initial_size=TerminalSize(cols=100, rows=30),
steps=(
ScenarioStep("wait-ready", "wait_text", "OPEN_SQUILLA_TUI_READY", "ready"),
ScenarioStep("send-message", "send_text", "stream please", "after-input"),
ScenarioStep(
"wait-stream",
"wait_text",
"stream-token-079",
"after-stream",
timeout_s=10.0,
),
ScenarioStep(
"capture-prompt-restored",
"capture",
"",
"after-prompt-restored",
timeout_s=0.2,
),
),
expected_text=("stream-token-079",),
),
TuiScenario(
scenario_id="complex_ui_state",
family="complex_ui_state",
initial_size=TerminalSize(cols=110, rows=34),
steps=(
ScenarioStep("wait-ready", "wait_text", "OPEN_SQUILLA_TUI_READY", "ready"),
ScenarioStep(
"send-message",
"send_text",
"complex state please",
"after-input",
),
ScenarioStep(
"wait-intermediate",
"wait_text",
"intermediate-before-tool",
"during-intermediate",
timeout_s=10.0,
),
ScenarioStep(
"wait-tool",
"wait_text",
"complex-state-complete",
"after-complex",
timeout_s=10.0,
),
),
expected_text=(
"route standard",
"fake_tool",
"complex-state-complete",
),
),
TuiScenario(
scenario_id="architecture_prompt",
family="architecture_prompt",
initial_size=TerminalSize(cols=112, rows=34),
steps=(
ScenarioStep("wait-ready", "wait_text", "OPEN_SQUILLA_TUI_READY", "ready"),
ScenarioStep(
"send-message",
"send_text",
"帮我分析这个代码长的架构 /workspace/opensquilla",
"after-input",
),
ScenarioStep(
"wait-architecture",
"wait_any_text",
"architecture-analysis-complete\n╰ in 1 / out 2",
"after-architecture",
timeout_s=10.0,
),
),
expected_text=(
"架构",
),
),
TuiScenario(
scenario_id="terminal_changes",
family="terminal_changes",
initial_size=TerminalSize(cols=100, rows=30),
steps=(
ScenarioStep("wait-ready", "wait_text", "OPEN_SQUILLA_TUI_READY", "ready"),
ScenarioStep("resize-narrow", "resize", "72x24", "after-narrow"),
ScenarioStep(
"paste-multiline",
"paste",
"first line\nsecond line CJK混合ASCII",
"after-paste",
),
ScenarioStep("submit-paste", "key", "Enter", "after-submit"),
ScenarioStep(
"wait-terminal-change",
"wait_text",
"terminal-change-response",
"after-response",
timeout_s=10.0,
),
# The fake app echoes the submitted input line by line; waiting
# for the second echoed line proves the pasted newline survived
# the composer (a collapsed paste would submit a single line).
ScenarioStep(
"wait-paste-echo",
"wait_text",
"echo-line-1:second line CJK混合ASCII",
"after-paste-echo",
timeout_s=10.0,
),
ScenarioStep("resize-wide", "resize", "120x34", "after-wide"),
ScenarioStep("ctrl-c", "key", "C-c", "after-ctrl-c"),
),
expected_text=(
"terminal-change-response lines=2",
"echo-line-0:first line",
"echo-line-1:second line CJK混合ASCII",
),
),
TuiScenario(
scenario_id="completion_slash_menu_filter",
family="completion_menu",
initial_size=TerminalSize(cols=100, rows=30),
steps=(
ScenarioStep("wait-ready", "wait_text", "OPEN_SQUILLA_TUI_READY", "ready"),
ScenarioStep("open-slash-menu", "key", "/", "slash-menu-open"),
ScenarioStep("filter-slash-c", "key", "c", "slash-menu-c"),
ScenarioStep("filter-slash-o", "key", "o", "slash-menu-co"),
ScenarioStep(
"capture-filtered-menu",
"capture",
"",
"slash-menu-filtered",
timeout_s=0.3,
),
),
expected_text=("/compact",),
requires_prompt_ready=False,
),
TuiScenario(
scenario_id="completion_menu_preserves_history",
family="completion_menu",
initial_size=TerminalSize(cols=100, rows=30),
steps=(
ScenarioStep("wait-ready", "wait_text", "OPEN_SQUILLA_TUI_READY", "ready"),
ScenarioStep(
"send-message",
"send_text",
"history before menu",
"after-input",
),
ScenarioStep(
"wait-response",
"wait_text",
"fake-response:history before menu",
"after-response",
),
ScenarioStep("open-slash-menu", "key", "/", "menu-open-over-history"),
ScenarioStep(
"capture-menu-over-history",
"capture",
"",
"menu-over-history",
timeout_s=0.3,
),
),
# The conversation text rendered before the menu must remain visible:
# the overlay layer must not paint a filled rectangle over history.
expected_text=("fake-response:history before menu", "/compact"),
requires_prompt_ready=False,
),
TuiScenario(
scenario_id="completion_menu_resize",
family="completion_menu",
initial_size=TerminalSize(cols=100, rows=30),
steps=(
ScenarioStep("wait-ready", "wait_text", "OPEN_SQUILLA_TUI_READY", "ready"),
ScenarioStep("open-slash-menu", "key", "/", "resize-menu-open"),
ScenarioStep(
"resize-narrow",
"resize",
"72x24",
"after-narrow-completion-menu",
),
ScenarioStep(
"resize-wide",
"resize",
"120x34",
"after-wide-completion-menu",
),
ScenarioStep(
"capture-resized-menu",
"capture",
"",
"after-resize-completion-menu",
timeout_s=0.3,
),
),
expected_text=("/compact",),
requires_prompt_ready=False,
),
TuiScenario(
scenario_id="completion_file_menu_escape",
family="completion_menu",
initial_size=TerminalSize(cols=100, rows=30),
steps=(
ScenarioStep("wait-ready", "wait_text", "OPEN_SQUILLA_TUI_READY", "ready"),
ScenarioStep("open-file-menu", "key", "@", "file-menu-key"),
ScenarioStep(
"capture-file-menu",
"capture",
"",
"file-menu-open",
timeout_s=0.4,
),
ScenarioStep("close-file-menu", "key", "Escape", "after-close-key"),
ScenarioStep(
"capture-closed-file-menu",
"capture",
"",
"after-close-file-menu",
timeout_s=0.3,
),
),
expected_text=(),
requires_prompt_ready=False,
),
TuiScenario(
scenario_id="live_opentui_architecture_prompt",
family="live_prompt",
initial_size=TerminalSize(cols=112, rows=34),
steps=(
ScenarioStep("wait-ready", "wait_text", "OPEN_SQUILLA_TUI_READY", "ready"),
ScenarioStep(
"send-message",
"send_text",
"帮我分析这个代码长的架构 /workspace/opensquilla",
"after-input",
),
ScenarioStep(
"wait-turn-complete",
"wait_any_text",
" · \nThe task timed out before it could finish.",
"after-turn-complete",
timeout_s=180.0,
),
ScenarioStep(
"capture-final",
"capture",
"",
"after-final",
timeout_s=0.2,
),
),
expected_text=(),
requires_tmux=True,
requires_prompt_ready=False,
required_backend_id="live-opentui",
),
)
def scenario_by_id(scenario_id: str) -> TuiScenario:
scenarios = {scenario.scenario_id: scenario for scenario in all_scenarios()}
try:
return scenarios[scenario_id]
except KeyError as exc:
raise ValueError(f"unknown real-terminal TUI scenario: {scenario_id}") from exc
def run_scenario(
*,
scenario: TuiScenario,
session: RealTerminalSession,
evidence: EvidenceBundle,
backend_id: str,
) -> ScenarioResult:
started_at = time.monotonic()
evidence.write_scenario(scenario.to_json_dict())
last_frame = TerminalFrame("not-started", "", 0, scenario.initial_size)
last_frame_path = evidence.frames_dir / "not-started.txt"
current_step = "start"
session.start()
try:
last_frame = session.capture_text("started")
last_frame_path = evidence.record_frame(last_frame)
for step in scenario.steps:
current_step = step.step_id
last_frame = _run_step(session, step)
last_frame_path = evidence.record_frame(last_frame)
assertions.assert_no_traceback(last_frame)
assertions.assert_no_raw_ansi_leakage(last_frame)
assertions.assert_no_inline_prompt_chrome_collision(last_frame)
if not session.is_alive() and step.action != "key":
raise AssertionError(f"{step.step_id}: terminal process exited unexpectedly")
for expected in scenario.expected_text:
assertions.assert_visible_text(last_frame, expected)
if scenario.requires_prompt_ready:
assertions.assert_prompt_ready(last_frame)
evidence.write_scrollback(session.capture_scrollback_text("scrollback"))
result = ScenarioResult(
scenario_id=scenario.scenario_id,
backend_id=backend_id,
status="pass",
run_dir=evidence.run_dir,
)
_write_visual_verdict(
scenario=scenario,
backend_id=backend_id,
evidence=evidence,
frame=last_frame,
frame_path=last_frame_path,
)
evidence.write_result(result)
return result
except Exception as exc:
failure = ScenarioFailure(
step_id=current_step,
message=str(exc),
elapsed_s=round(time.monotonic() - started_at, 3),
last_screen=last_frame.text,
artifact_dir=str(evidence.run_dir),
)
result = ScenarioResult(
scenario_id=scenario.scenario_id,
backend_id=backend_id,
status="fail",
run_dir=evidence.run_dir,
failure=failure,
)
_write_visual_verdict(
scenario=scenario,
backend_id=backend_id,
evidence=evidence,
frame=last_frame,
frame_path=last_frame_path,
)
evidence.write_result(result)
raise
finally:
session.terminate()
def _run_step(session: RealTerminalSession, step: ScenarioStep) -> TerminalFrame:
checkpoint = step.checkpoint or step.step_id
if step.action == "wait_text":
return session.wait_for_text(
step.value,
timeout_s=step.timeout_s,
checkpoint=checkpoint,
)
if step.action == "wait_any_text":
needles = tuple(item for item in step.value.splitlines() if item)
return _wait_for_any_text(
session,
needles,
timeout_s=step.timeout_s,
checkpoint=checkpoint,
)
if step.action == "send_text":
session.send_text(step.value)
return session.capture_text(checkpoint)
if step.action == "paste":
session.paste(step.value)
return session.capture_text(checkpoint)
if step.action == "key":
session.send_key(step.value)
return session.capture_text(checkpoint)
if step.action == "resize":
cols, rows = step.value.split("x", 1)
session.resize(TerminalSize(cols=int(cols), rows=int(rows)))
time.sleep(0.25)
return _capture_stable_resize_frame(session, checkpoint)
if step.action == "capture":
if step.timeout_s:
time.sleep(step.timeout_s)
return session.capture_text(checkpoint)
raise ValueError(f"unknown scenario step action: {step.action}")
def _wait_for_any_text(
session: RealTerminalSession,
needles: tuple[str, ...],
*,
timeout_s: float,
checkpoint: str,
) -> TerminalFrame:
deadline = time.monotonic() + timeout_s
last = session.capture_text(checkpoint)
if any(needle in last.text for needle in needles):
return last
while time.monotonic() < deadline:
time.sleep(0.05)
last = session.capture_text(checkpoint)
if any(needle in last.text for needle in needles):
return last
expected = " or ".join(repr(needle) for needle in needles)
raise TimeoutError(f"timed out waiting for {expected}; last screen: {last.text}")
def _capture_stable_resize_frame(
session: RealTerminalSession,
checkpoint: str,
) -> TerminalFrame:
deadline = time.monotonic() + 1.0
last = session.capture_text(checkpoint)
while _has_duplicate_inline_prompt(last) and time.monotonic() < deadline:
time.sleep(0.05)
last = session.capture_text(checkpoint)
return last
def _has_duplicate_inline_prompt(frame: TerminalFrame) -> bool:
return any(line.count("send a massage") > 1 for line in frame.text.splitlines())
def _write_visual_verdict(
*,
scenario: TuiScenario,
backend_id: str,
evidence: EvidenceBundle,
frame: TerminalFrame,
frame_path: Path,
) -> None:
verdict = build_visual_verdict(
scenario_id=scenario.scenario_id,
checkpoint=frame.checkpoint,
backend_id=backend_id,
terminal_size={"cols": frame.size.cols, "rows": frame.size.rows},
screenshot_path=None,
frame_path=str(frame_path),
expected_visible_regions=("prompt", "assistant stream", scenario.family),
)
verdict_path = evidence.write_visual_verdict(verdict)
if blocking(verdict):
raise AssertionError(f"blocking visual verdict: {verdict_path}")
@@ -0,0 +1,126 @@
from __future__ import annotations
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from tui_real_terminal.driver import TerminalSize
TuiBackendId = Literal["opentui", "live-opentui"]
@dataclass(frozen=True)
class TargetContext:
project_root: Path
artifact_dir: Path
scenario_id: str
size: TerminalSize
@dataclass(frozen=True)
class TuiTarget:
backend_id: TuiBackendId
command: list[str]
env: dict[str, str]
initial_size: TerminalSize
readiness_markers: tuple[str, ...]
log_paths: tuple[Path, ...]
capability_requirements: tuple[str, ...]
available: bool = True
skip_reason: str | None = None
def build_tui_target(backend_id: str, context: TargetContext) -> TuiTarget:
if backend_id == "opentui":
return _opentui_target(context)
if backend_id == "live-opentui":
return _live_opentui_target(context)
raise ValueError(f"only opentui is supported; got TUI backend target: {backend_id}")
def _base_env(context: TargetContext, *, isolate_state: bool = True) -> dict[str, str]:
env = os.environ.copy()
src_path = str(context.project_root / "src")
env["PYTHONPATH"] = src_path + os.pathsep + env.get("PYTHONPATH", "")
if isolate_state:
env["OPENSQUILLA_STATE_DIR"] = str(context.artifact_dir / "state")
env["OPENSQUILLA_LOG_DIR"] = str(context.artifact_dir / "logs")
env["OPENSQUILLA_TURN_CALL_LOG"] = "0"
env.setdefault("TERM", "xterm-256color")
return env
def _host_gateway_config_path(project_root: Path) -> str:
explicit = os.environ.get("OPENSQUILLA_GATEWAY_CONFIG_PATH", "").strip()
if explicit:
return explicit
cwd_config = project_root / "opensquilla.toml"
if cwd_config.is_file():
return str(cwd_config)
from opensquilla.paths import default_opensquilla_home # type: ignore[import-untyped]
user_config = default_opensquilla_home() / "config.toml"
return str(user_config) if user_config.is_file() else ""
def _opentui_target(context: TargetContext) -> TuiTarget:
app_path = Path(__file__).with_name("fake_opentui_app.py")
app_log = context.artifact_dir / "opentui-app.log"
env = _base_env(context)
env.update(
{
"OPENSQUILLA_TUI_FAKE_SCENARIO": context.scenario_id,
"OPENSQUILLA_TUI_FAKE_APP_LOG": str(app_log),
"OPENSQUILLA_TUI_READY_MARKER": "OPEN_SQUILLA_TUI_READY",
"OPENSQUILLA_TUI_BACKEND": "opentui",
}
)
return TuiTarget(
backend_id="opentui",
command=[sys.executable, "-u", str(app_path)],
env=env,
initial_size=context.size,
readiness_markers=("OPEN_SQUILLA_TUI_READY",),
log_paths=(app_log,),
capability_requirements=("real-terminal", "fake-provider", "opentui-footer"),
)
def _live_opentui_target(context: TargetContext) -> TuiTarget:
env = _base_env(context, isolate_state=False)
env.update(
{
"OPENSQUILLA_TUI_BACKEND": "opentui",
"OPENSQUILLA_TUI_READY_MARKER": "OPEN_SQUILLA_TUI_READY",
"OPENSQUILLA_MEMORY_DREAM_DISABLED": "1",
"OPENSQUILLA_OPENROUTER_LIVE_PRICING": "0",
}
)
config_path = _host_gateway_config_path(context.project_root)
if config_path:
env["OPENSQUILLA_GATEWAY_CONFIG_PATH"] = config_path
return TuiTarget(
backend_id="live-opentui",
command=[
sys.executable,
"-u",
"-m",
"opensquilla.cli.main",
"chat",
"--standalone",
"--workspace",
str(context.project_root),
"--workspace-strict",
"--timeout",
"120",
],
env=env,
initial_size=context.size,
readiness_markers=("OPEN_SQUILLA_TUI_READY",),
log_paths=(context.artifact_dir / "logs",),
capability_requirements=("real-terminal", "real-cli", "opentui-footer", "tmux"),
)
@@ -0,0 +1,174 @@
from __future__ import annotations
import asyncio
import json
from dataclasses import replace
from pathlib import Path
from typing import Any
import pytest
from tui_real_terminal.replay import replay_architecture_prompt
from tui_real_terminal.scenarios import TuiScenario, scenario_by_id
pytestmark = pytest.mark.tui_real_terminal
ARCHITECTURE_PROMPT = "帮我分析这个代码长的架构 /workspace/opensquilla"
ARCHITECTURE_REPLAY_FIXTURE = (
Path(__file__).resolve().parents[3]
/ "fixtures"
/ "tui"
/ "architecture_prompt_replay.json"
)
class _ReplayRecordingRenderer:
def __init__(self) -> None:
self.statuses: list[str] = []
self.finished_tools: list[dict[str, Any]] = []
self.text_chunks: list[str] = []
async def astatus(self, message: str, *, style: str = "dim") -> None:
self.statuses.append(message)
async def atool_start(
self,
name: str,
args: dict[str, Any] | None,
tool_use_id: str,
) -> None:
pass
async def atool_finished(
self,
tool_use_id: str,
*,
success: bool,
elapsed: float | None = None,
error: str | None = None,
result: str | None = None,
) -> None:
self.finished_tools.append(
{
"tool_use_id": tool_use_id,
"success": success,
"elapsed": elapsed,
"error": error,
"result": result,
}
)
async def aappend_text(self, text: str) -> None:
self.text_chunks.append(text)
class _ReplayRecordingOutput:
def set_toolbar(self, key: str, value: object | None) -> None:
pass
def invalidate(self) -> None:
pass
def _architecture_prompt_scenario_for_assertions() -> TuiScenario:
scenario = scenario_by_id("architecture_prompt")
steps = tuple(
replace(step, value="架构分析")
if step.step_id == "wait-architecture"
else step
for step in scenario.steps
)
return replace(scenario, steps=steps)
def test_architecture_prompt_replay_fixture_is_saved_for_render_only_runs() -> None:
fixture = json.loads(ARCHITECTURE_REPLAY_FIXTURE.read_text(encoding="utf-8"))
events = fixture["events"]
kinds = [event["kind"] for event in events]
assert fixture["source_prompt"] == ARCHITECTURE_PROMPT
assert "toolbar" in kinds
assert "tool_start" in kinds
assert "tool_finished" in kinds
assert "tool_output" in kinds
assert "text_delta" in kinds
assert events[-1]["kind"] == "done"
tool_outputs = [event["payload"] for event in events if event["kind"] == "tool_output"]
assert tool_outputs
assert all("stdout" in payload for payload in tool_outputs)
assert any(payload.get("truncated") for payload in tool_outputs)
assert any(
"architecture-analysis-complete" in event["payload"].get("text", "")
for event in events
if event["kind"] == "text_delta"
)
def test_architecture_prompt_replay_routes_tool_output_through_finished_result() -> None:
renderer = _ReplayRecordingRenderer()
usage = asyncio.run(replay_architecture_prompt(renderer, _ReplayRecordingOutput()))
finished_by_id = {
event["tool_use_id"]: event for event in renderer.finished_tools
}
list_result = finished_by_id["tool-list"]["result"]
read_result = finished_by_id["tool-read"]["result"]
assert usage.model == "fake-opentui"
assert isinstance(list_result, str)
assert "top-level repository layout" in list_result
assert "AGENTS.md" in list_result
assert isinstance(read_result, str)
assert "OpenTUI runtime owns the chat surface factory" in read_result
assert "async def run_opentui_chat_runtime(" in read_result
assert not any("tool_output" in message for message in renderer.statuses)
assert not any("stdout:" in message for message in renderer.statuses)
assert not any("truncated" in message for message in renderer.statuses)
def test_architecture_prompt_renders_tools_and_chinese_output(
run_real_terminal_scenario,
) -> None:
result = run_real_terminal_scenario(_architecture_prompt_scenario_for_assertions())
assert result.status == "pass"
transcript = (result.run_dir / "transcript.txt").read_text(encoding="utf-8")
scrollback = (result.run_dir / "scrollback.txt").read_text(encoding="utf-8")
rendered_output = f"{transcript}\n{scrollback}"
app_log_name = "opentui-app.log"
app_events = [
json.loads(line)
for line in (result.run_dir / app_log_name).read_text(encoding="utf-8").splitlines()
if line.strip()
]
submitted = [
event["payload"].get("input", "")
for event in app_events
if event["event"] == "dispatch"
]
assert any(ARCHITECTURE_PROMPT in item for item in submitted)
assert "架构" in rendered_output
# Fullscreen alt-screen viewport: the conversation lives in a ScrollBox, so
# assertions cover the markers visible in the final frame. The assistant
# turn renders as ONE card with width-independent chrome (a short
# "╭ squilla" label, continuous left gutter, and a "╰ <usage>" footer that
# carries the usage receipt); each tool shows a "✓ <name> <args> · <dur>"
# invocation row plus a single collapsed "└ line1 · line2 · line3" result
# corner (the read_file preview is clipped to width, so only its leading
# summary is pinned). The prompt echoes as bare "│ <text>" rows with no
# header of its own.
assert "╭ squilla" in rendered_output
assert "│ 帮我分析这个代码长的架构" in rendered_output
assert "╭─ prompt" not in rendered_output
assert "✓ list_dir /workspace/opensquilla · 0.0s" in rendered_output
assert "✓ read_file" in rendered_output
assert "└ top-level repository layout · AGENTS.md · pyproject.toml" in rendered_output
assert "└ OpenTUI runtime owns the chat surface factory" in rendered_output
assert "╰ in 1 / out 2 · fake-opentui" in rendered_output
assert "tool_output" not in rendered_output
assert "stdout:" not in rendered_output
assert "truncated" not in rendered_output
assert "Traceback" not in rendered_output
assert "\x1b[" not in rendered_output
@@ -0,0 +1,153 @@
from __future__ import annotations
from pathlib import Path
import pytest
from tui_real_terminal.assertions import (
assert_no_completion_menu_overlap,
assert_no_stale_completion_menu,
)
from tui_real_terminal.driver import TerminalFrame, TerminalSize
from tui_real_terminal.evidence import ScenarioResult
from tui_real_terminal.scenarios import scenario_by_id
pytestmark = pytest.mark.tui_real_terminal
def test_completion_menu_overlap_assertion_accepts_clean_menu() -> None:
frame = _frame(
"\n".join(
(
"conversation content above the composer",
" ╭ commands ──────────────────────────────╮",
" /compact Compact the conversation │",
" │ /resume Resume an existing session │",
" ╰────────────────────────────────────────╯",
" │ send a massage │",
)
)
)
assert_no_completion_menu_overlap(frame)
def test_completion_menu_overlap_assertion_rejects_interleaved_output() -> None:
frame = _frame(
"\n".join(
(
"conversation content above the composer",
" ╭ commands ───── fake-response:hello ───╮",
" /compact Compact the conversation │",
" ╰────────────────────────────────────────╯",
)
)
)
with pytest.raises(AssertionError, match="completion menu overlap"):
assert_no_completion_menu_overlap(frame)
def test_stale_completion_menu_assertion_accepts_cleared_frame() -> None:
frame = _frame(
"\n".join(
(
"conversation content remains visible",
" │ /co │",
" ╰──────────────────────────────────────╯",
)
),
checkpoint="after-close",
)
assert_no_stale_completion_menu(frame)
def test_stale_completion_menu_assertion_rejects_leftover_menu() -> None:
frame = _frame(
"\n".join(
(
"conversation content remains visible",
" ╭ commands ─────────────────────────────╮",
" /compact Compact the conversation │",
" ╰────────────────────────────────────────╯",
)
),
checkpoint="after-close",
)
with pytest.raises(AssertionError, match="stale completion menu"):
assert_no_stale_completion_menu(frame)
def test_slash_completion_menu_renders_without_overlap(run_real_terminal_scenario) -> None:
result = run_real_terminal_scenario(scenario_by_id("completion_slash_menu_filter"))
assert result.status == "pass"
frame = _read_frame(result, "slash-menu-filtered", TerminalSize(cols=100, rows=30))
assert " commands " in frame.text
assert "/compact" in frame.text
assert_no_completion_menu_overlap(frame)
def test_completion_menu_resize_does_not_leave_overlap(
run_real_terminal_scenario,
) -> None:
result = run_real_terminal_scenario(scenario_by_id("completion_menu_resize"))
assert result.status == "pass"
for checkpoint, size in (
("after-narrow-completion-menu", TerminalSize(cols=72, rows=24)),
("after-wide-completion-menu", TerminalSize(cols=120, rows=34)),
("after-resize-completion-menu", TerminalSize(cols=120, rows=34)),
):
frame = _read_frame(result, checkpoint, size)
assert " commands " in frame.text
assert_no_completion_menu_overlap(frame)
def test_file_completion_menu_closes_without_stale_overlay(
run_real_terminal_scenario,
) -> None:
result = run_real_terminal_scenario(scenario_by_id("completion_file_menu_escape"))
assert result.status == "pass"
file_menu = _read_frame(result, "file-menu-open", TerminalSize(cols=100, rows=30))
assert " files " in file_menu.text
assert_no_completion_menu_overlap(file_menu)
assert_no_stale_completion_menu(
_read_frame(result, "after-close-file-menu", TerminalSize(cols=100, rows=30))
)
def _frame(text: str, *, checkpoint: str = "menu") -> TerminalFrame:
rows = max(1, len(text.splitlines()))
return TerminalFrame(checkpoint, text, 1, TerminalSize(cols=80, rows=rows))
def _read_frame(
result: ScenarioResult,
checkpoint: str,
size: TerminalSize,
) -> TerminalFrame:
# ScenarioResult intentionally exposes the artifact directory; frame-level
# assertions read the checkpoint evidence without broadening run_scenario.
frame_path = _frame_path(result.run_dir, checkpoint)
return TerminalFrame(
checkpoint,
frame_path.read_text(encoding="utf-8"),
0,
size,
)
def _frame_path(run_dir: Path, checkpoint: str) -> Path:
matches = sorted((run_dir / "frames").glob(f"*-{checkpoint}.txt"))
if len(matches) == 1:
return matches[0]
available = ", ".join(
path.name for path in sorted((run_dir / "frames").glob("*.txt"))
)
raise AssertionError(
f"expected exactly one frame for checkpoint {checkpoint!r}; available: {available}"
)
@@ -0,0 +1,70 @@
from __future__ import annotations
from pathlib import Path
import pytest
from tui_real_terminal.assertions import assert_visible_text
from tui_real_terminal.driver import TerminalFrame, TerminalSize
from tui_real_terminal.evidence import ScenarioResult
from tui_real_terminal.scenarios import scenario_by_id
pytestmark = pytest.mark.tui_real_terminal
def test_complex_ui_state(run_real_terminal_scenario) -> None:
scenario = scenario_by_id("complex_ui_state")
result = run_real_terminal_scenario(scenario)
assert result.status == "pass"
assert (result.run_dir / "frames").is_dir()
assert (result.run_dir / "transcript.txt").exists()
intermediate_frame = _read_frame(
result,
"during-intermediate",
scenario.initial_size,
)
assert result.backend_id == "opentui"
# Intermediate narration is visible as purple thinking text, separate from
# reasoning and from the final answer card.
assert_visible_text(intermediate_frame, "intermediate-before-tool")
intermediate_lines = [
line
for line in intermediate_frame.text.splitlines()
if "intermediate-before-tool" in line
]
assert intermediate_lines
assert intermediate_lines[0].lstrip().startswith("")
assert "second-intermediate-line" in intermediate_frame.text
# The reasoning PROCESS text is never shown verbatim — only ever a transient
# "Thinking…" marker stood in for it.
assert "reasoning-process-should-stay-hidden" not in intermediate_frame.text
# And the marker is transient: by the time the model has moved on to speaking,
# reasoning has ended and its marker is removed from the timeline (it does not
# linger as a permanent node).
assert "Thinking" not in intermediate_frame.text
def _read_frame(
result: ScenarioResult,
checkpoint: str,
size: TerminalSize,
) -> TerminalFrame:
frame_path = _frame_path(result.run_dir, checkpoint)
return TerminalFrame(
checkpoint,
frame_path.read_text(encoding="utf-8"),
0,
size,
)
def _frame_path(run_dir: Path, checkpoint: str) -> Path:
matches = sorted((run_dir / "frames").glob(f"*-{checkpoint}.txt"))
if len(matches) == 1:
return matches[0]
available = ", ".join(path.name for path in sorted((run_dir / "frames").glob("*.txt")))
raise AssertionError(
f"expected exactly one frame for checkpoint {checkpoint!r}; available: {available}"
)
@@ -0,0 +1,349 @@
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
import pytest
HARNESS_PARENT = Path(__file__).resolve().parents[1]
if str(HARNESS_PARENT) not in sys.path:
sys.path.insert(0, str(HARNESS_PARENT))
from tui_real_terminal import driver # noqa: E402
from tui_real_terminal.driver import ( # noqa: E402
PtyTerminalSession,
TerminalFrame,
TerminalSize,
TmuxTerminalSession,
build_run_id,
open_real_terminal_session,
probe_terminal_capabilities,
)
class _FakePtyModule:
"""Stand-in for the Unix-only :mod:`pty` module.
Probe logic keys off ``driver.pty`` exposing ``openpty``; on Windows
``import pty`` yields ``None``. Patching this fake in lets the capability
tests exercise the probe's PTY branch on any platform instead of skipping.
"""
@staticmethod
def openpty() -> tuple[int, int]: # pragma: no cover - never invoked in probe
raise NotImplementedError
def _force_pty_module(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(driver, "pty", _FakePtyModule)
def test_terminal_size_validates_positive_dimensions() -> None:
size = TerminalSize(cols=100, rows=30)
assert size.cols == 100
assert size.rows == 30
with pytest.raises(ValueError, match="terminal size must be positive"):
TerminalSize(cols=0, rows=30)
with pytest.raises(ValueError, match="terminal size must be positive"):
TerminalSize(cols=100, rows=-1)
def test_terminal_frame_records_checkpoint_text_time_and_size() -> None:
size = TerminalSize(cols=80, rows=24)
frame = TerminalFrame(
checkpoint="ready",
text="OPEN_SQUILLA_TUI_READY",
captured_at_ms=123,
size=size,
)
assert frame.checkpoint == "ready"
assert frame.text == "OPEN_SQUILLA_TUI_READY"
assert frame.captured_at_ms == 123
assert frame.size is size
def test_build_run_id_is_tmux_safe(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(driver, "_run_id_suffix", lambda: "123456789-777-0")
run_id = build_run_id(" Launch input/loop!? ")
assert run_id == "opensquilla-tui-launch-input-loop-123456789-777-0"
assert all(ch.isalnum() or ch in "-_" for ch in run_id)
def test_build_run_id_uses_scenario_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(driver, "_run_id_suffix", lambda: "42-777-0")
assert build_run_id(" !!! ") == "opensquilla-tui-scenario-42-777-0"
def test_capability_probe_prefers_tmux_when_available(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def fake_which(name: str) -> str | None:
return "/usr/bin/tmux" if name == "tmux" else None
monkeypatch.setattr(driver.shutil, "which", fake_which)
monkeypatch.setattr(driver.sys, "platform", "linux")
_force_pty_module(monkeypatch)
capabilities = probe_terminal_capabilities()
assert capabilities.tmux_available is True
assert capabilities.pty_available is True
assert capabilities.screenshot_available is False
assert capabilities.resize_available is True
assert capabilities.preferred_driver == "tmux"
assert capabilities.skip_reason is None
def test_capability_probe_falls_back_to_pty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(driver.shutil, "which", lambda name: None)
monkeypatch.setattr(driver.sys, "platform", "linux")
_force_pty_module(monkeypatch)
capabilities = probe_terminal_capabilities()
assert capabilities.tmux_available is False
assert capabilities.pty_available is True
assert capabilities.preferred_driver == "pty"
assert capabilities.skip_reason is None
def test_capability_probe_reports_none_when_no_terminal_driver(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(driver.shutil, "which", lambda name: None)
monkeypatch.setattr(driver.sys, "platform", "win32")
capabilities = probe_terminal_capabilities()
assert capabilities.tmux_available is False
assert capabilities.pty_available is False
assert capabilities.preferred_driver == "none"
assert capabilities.resize_available is False
assert capabilities.skip_reason is not None
assert "WSL2" in capabilities.skip_reason
def test_factory_selects_available_driver_and_reports_unavailable(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(driver.shutil, "which", lambda name: None)
monkeypatch.setattr(driver.sys, "platform", "linux")
_force_pty_module(monkeypatch)
session = open_real_terminal_session(
command=[sys.executable, "-c", "print('ready')"],
cwd=tmp_path,
env={},
run_id="opensquilla-tui-test-1",
size=TerminalSize(),
artifact_dir=tmp_path,
driver="auto",
)
assert isinstance(session, PtyTerminalSession)
assert session.kind == "pty"
with pytest.raises(RuntimeError, match="requested terminal driver 'tmux' is unavailable"):
open_real_terminal_session(
command=[sys.executable, "-c", "print('ready')"],
cwd=tmp_path,
env={},
run_id="opensquilla-tui-test-2",
size=TerminalSize(),
artifact_dir=tmp_path,
driver="tmux",
)
def test_factory_reports_when_no_driver_is_available(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(driver.shutil, "which", lambda name: None)
monkeypatch.setattr(driver.sys, "platform", "win32")
with pytest.raises(RuntimeError, match="WSL2"):
open_real_terminal_session(
command=[sys.executable, "-c", "print('ready')"],
cwd=tmp_path,
env={},
run_id="opensquilla-tui-test-3",
size=TerminalSize(),
artifact_dir=tmp_path,
)
def test_tmux_session_uses_owned_session_commands(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[list[str], dict[str, Any]]] = []
def fake_run(
args: list[str],
**kwargs: Any,
) -> subprocess.CompletedProcess[str]:
calls.append((args, kwargs))
if args[:2] == ["tmux", "capture-pane"]:
return subprocess.CompletedProcess(args, 0, stdout="ready screen")
if args[:2] == ["tmux", "has-session"]:
return subprocess.CompletedProcess(args, 0)
return subprocess.CompletedProcess(args, 0)
monkeypatch.setattr(driver.subprocess, "run", fake_run)
session = TmuxTerminalSession(
command=["python", "-c", "print('ready')"],
cwd=tmp_path,
env={"TERM": "xterm-256color"},
run_id="opensquilla-tui-owned-1",
size=TerminalSize(cols=100, rows=30),
terminal_log=tmp_path / "terminal.log",
)
session.start()
session.send_text("hello")
session.send_key("C-c")
session.paste("line 1\nline 2")
session.resize(TerminalSize(cols=120, rows=40))
frame = session.wait_for_text("ready", timeout_s=0.1, checkpoint="ready")
alive = session.is_alive()
session.terminate()
assert session.kind == "tmux"
assert calls[0][0][:5] == ["tmux", "new-session", "-d", "-s", "opensquilla-tui-owned-1"]
assert ["tmux", "send-keys", "-t", "opensquilla-tui-owned-1", "-l", "hello"] in [
call for call, _ in calls
]
assert ["tmux", "send-keys", "-t", "opensquilla-tui-owned-1", "Enter"] in [
call for call, _ in calls
]
assert ["tmux", "send-keys", "-t", "opensquilla-tui-owned-1", "C-c"] in [
call for call, _ in calls
]
paste_call = next(call for call in calls if call[0][:3] == ["tmux", "load-buffer", "-b"])
assert paste_call[1]["input"] == "line 1\nline 2"
assert ["tmux", "resize-window", "-t", "opensquilla-tui-owned-1", "-x", "120", "-y", "40"] in [
call for call, _ in calls
]
assert frame.text == "ready screen"
assert frame.size == TerminalSize(cols=120, rows=40)
assert alive is True
assert calls[-1][0] == ["tmux", "kill-session", "-t", "opensquilla-tui-owned-1"]
@pytest.mark.skipif(sys.platform == "win32", reason="PTY fallback is only available on Unix")
def test_pty_session_drives_text_process_and_cleans_up(tmp_path: Path) -> None:
command = [
sys.executable,
"-u",
"-c",
(
"import sys\n"
"print('READY', flush=True)\n"
"for line in sys.stdin:\n"
" print('ECHO:' + line.strip(), flush=True)\n"
),
]
session = PtyTerminalSession(
command=command,
cwd=tmp_path,
env=os.environ.copy(),
run_id="opensquilla-tui-pty-1",
size=TerminalSize(cols=80, rows=24),
terminal_log=tmp_path / "terminal.log",
)
try:
session.start()
ready = session.wait_for_text("READY", timeout_s=2, checkpoint="ready")
session.send_text("hello")
echoed = session.wait_for_text("ECHO:hello", timeout_s=2, checkpoint="echo")
session.paste("draft")
session.send_key("Enter")
pasted = session.wait_for_text("ECHO:draft", timeout_s=2, checkpoint="paste")
session.resize(TerminalSize(cols=90, rows=20))
assert session.kind == "pty"
assert "READY" in ready.text
assert "ECHO:hello" in echoed.text
assert "ECHO:draft" in pasted.text
assert session.size == TerminalSize(cols=90, rows=20)
assert session.is_alive() is True
finally:
session.terminate()
assert session.is_alive() is False
def test_tmux_alternate_screen_probe_reads_pane_flag(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
responses = iter(["1\n", "0\n"])
def fake_run(
args: list[str],
**kwargs: Any,
) -> subprocess.CompletedProcess[str]:
assert args == [
"tmux",
"display-message",
"-p",
"-t",
"opensquilla-tui-owned-3",
"#{alternate_on}",
]
return subprocess.CompletedProcess(args, 0, stdout=next(responses))
monkeypatch.setattr(driver.subprocess, "run", fake_run)
session = TmuxTerminalSession(
command=["python", "-c", "print('ready')"],
cwd=tmp_path,
env={},
run_id="opensquilla-tui-owned-3",
size=TerminalSize(),
terminal_log=tmp_path / "terminal.log",
)
assert session.alternate_screen_active() is True
assert session.alternate_screen_active() is False
def test_wait_for_text_times_out_with_last_screen(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
def fake_run(
args: list[str],
**kwargs: Any,
) -> subprocess.CompletedProcess[str]:
if args[:2] == ["tmux", "capture-pane"]:
return subprocess.CompletedProcess(args, 0, stdout="not yet")
return subprocess.CompletedProcess(args, 0)
monkeypatch.setattr(driver.subprocess, "run", fake_run)
session = TmuxTerminalSession(
command=["python", "-c", "print('ready')"],
cwd=tmp_path,
env={},
run_id="opensquilla-tui-owned-2",
size=TerminalSize(),
terminal_log=tmp_path / "terminal.log",
)
with pytest.raises(TimeoutError, match="timed out waiting for 'missing'.*not yet"):
session.wait_for_text("missing", timeout_s=0, checkpoint="missing")
@@ -0,0 +1,111 @@
from __future__ import annotations
import json
import shlex
from pathlib import Path
import pytest
from tui_real_terminal import assertions
from tui_real_terminal.driver import (
TerminalSize,
TmuxTerminalSession,
build_run_id,
probe_terminal_capabilities,
)
from tui_real_terminal.evidence import EvidenceBundle
from tui_real_terminal.targets import TargetContext, build_tui_target
pytestmark = pytest.mark.tui_real_terminal
_EXIT_MARKER = "TUI_EXITED_TO_SHELL"
def test_exit_restores_primary_screen_and_shell(
artifact_root: Path,
tui_backend: str,
tui_driver: str,
) -> None:
"""/exit must hand a usable terminal back: primary screen, live shell, echo.
The fake app runs inside a shell so the tmux pane survives the app's exit —
the restoration path is only observable while something still owns the pane.
tmux's own alternate_on flag distinguishes a real alternate-screen exit from
shell output merely drawn over a stale alternate screen.
"""
if tui_backend != "opentui":
pytest.skip("exit-restoration drives the fake opentui app")
if tui_driver == "pty":
pytest.skip("exit-restoration requires tmux, not PTY")
if not probe_terminal_capabilities().tmux_available:
pytest.skip("exit-restoration requires tmux")
evidence = EvidenceBundle.create(
artifact_root,
scenario_id="exit_restoration",
backend_id=tui_backend,
)
target = build_tui_target(
"opentui",
TargetContext(
project_root=Path.cwd(),
artifact_dir=evidence.run_dir,
scenario_id="exit_restoration",
size=TerminalSize(cols=100, rows=30),
),
)
shell_script = (
f"{shlex.join(target.command)}; "
f"printf '\\n{_EXIT_MARKER} status=%s\\n' \"$?\"; "
"exec /bin/sh -i"
)
session = TmuxTerminalSession(
command=["/bin/sh", "-c", shell_script],
cwd=Path.cwd(),
env=target.env,
run_id=build_run_id("exit_restoration"),
size=TerminalSize(cols=100, rows=30),
terminal_log=evidence.run_dir / "terminal.log",
)
session.start()
try:
ready = session.wait_for_text(
"OPEN_SQUILLA_TUI_READY", timeout_s=15.0, checkpoint="ready"
)
evidence.record_frame(ready)
assert session.alternate_screen_active(), (
"the opentui host should run on the alternate screen"
)
session.send_text("/exit")
exited = session.wait_for_text(
f"{_EXIT_MARKER} status=0", timeout_s=15.0, checkpoint="after-exit"
)
evidence.record_frame(exited)
assertions.assert_no_traceback(exited)
assert not session.alternate_screen_active(), (
"exiting the TUI must leave the alternate screen"
)
# An interactive round-trip proves the shell prompt is back and typed
# characters echo again (raw mode off), not merely that output renders.
session.send_text("printf 'RESTORE-%s\\n' check")
echoed = session.wait_for_text(
"RESTORE-check", timeout_s=10.0, checkpoint="after-echo-probe"
)
evidence.record_frame(echoed)
assert "$" in echoed.text
evidence.write_scrollback(session.capture_scrollback_text("scrollback"))
finally:
session.terminate()
# The app-side log proves the exit went through the dispatch path (the
# runtime hands /exit to the app, which acknowledges before shutting down).
app_events = [
json.loads(line)
for line in (evidence.run_dir / "opentui-app.log")
.read_text(encoding="utf-8")
.splitlines()
if line.strip()
]
assert any(event["event"] == "exit" for event in app_events)
@@ -0,0 +1,51 @@
from __future__ import annotations
import importlib.util
from argparse import Namespace
from pathlib import Path
from types import ModuleType
import pytest
REMOVED_TEXT_BACKEND = "text" + "ual"
REMOVED_BACKEND_IDS = ("terminal", REMOVED_TEXT_BACKEND, f"live-{REMOVED_TEXT_BACKEND}")
def _load_lab_script() -> ModuleType:
script_path = Path(__file__).resolve().parents[4] / "scripts" / "tui_real_terminal_lab.py"
spec = importlib.util.spec_from_file_location("tui_real_terminal_lab", script_path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_live_opentui_lab_requires_explicit_live_env(monkeypatch: pytest.MonkeyPatch) -> None:
lab = _load_lab_script()
monkeypatch.delenv("OPENSQUILLA_TUI_LIVE_REAL", raising=False)
with pytest.raises(SystemExit, match="OPENSQUILLA_TUI_LIVE_REAL=1"):
lab._assert_live_backend_enabled("live-opentui")
monkeypatch.setenv("OPENSQUILLA_TUI_LIVE_REAL", "1")
lab._assert_live_backend_enabled("live-opentui")
lab._assert_live_backend_enabled("opentui")
def test_lab_script_accepts_opentui_backend_for_manual_render_runs() -> None:
module = _load_lab_script()
args: Namespace = module._parser().parse_args( # noqa: SLF001
["--scenario", "architecture_prompt", "--backend", "opentui"]
)
assert args.backend == "opentui"
@pytest.mark.parametrize("backend", REMOVED_BACKEND_IDS)
def test_lab_script_rejects_removed_backend_choices(backend: str) -> None:
module = _load_lab_script()
with pytest.raises(SystemExit):
module._parser().parse_args(["--scenario", "architecture_prompt", "--backend", backend])
@@ -0,0 +1,23 @@
from __future__ import annotations
import pytest
from tui_real_terminal.scenarios import scenario_by_id
pytestmark = pytest.mark.tui_real_terminal
def test_terminal_launch_and_input_loop(run_real_terminal_scenario) -> None:
result = run_real_terminal_scenario(scenario_by_id("launch_input_loop"))
assert result.status == "pass"
assert (result.run_dir / "scenario.json").exists()
assert (result.run_dir / "transcript.txt").exists()
assert (result.run_dir / "frames").is_dir()
def test_terminal_cjk_input_loop(run_real_terminal_scenario) -> None:
result = run_real_terminal_scenario(scenario_by_id("cjk_input_loop"))
assert result.status == "pass"
assert (result.run_dir / "transcript.txt").exists()
@@ -0,0 +1,46 @@
from __future__ import annotations
import os
import pytest
from tui_real_terminal.driver import probe_terminal_capabilities
from tui_real_terminal.scenarios import scenario_by_id
pytestmark = [
pytest.mark.tui_real_terminal,
pytest.mark.llm,
pytest.mark.llm_gateway,
]
def test_live_opentui_real_cli_runs_architecture_prompt_in_tmux(
run_real_terminal_scenario,
tui_backend: str,
tui_driver: str,
) -> None:
if os.environ.get("OPENSQUILLA_TUI_LIVE_REAL") != "1":
pytest.skip("set OPENSQUILLA_TUI_LIVE_REAL=1 to run the real CLI/OpenTUI tmux smoke")
if tui_backend != "live-opentui":
pytest.skip("run with --tui-backend=live-opentui")
if tui_driver == "pty":
pytest.skip("live OpenTUI real CLI mode requires tmux, not PTY")
if not probe_terminal_capabilities().tmux_available:
pytest.skip("tmux is unavailable")
result = run_real_terminal_scenario(scenario_by_id("live_opentui_architecture_prompt"))
assert result.status == "pass"
assert (result.run_dir / "terminal.log").exists()
scrollback_path = result.run_dir / "scrollback.txt"
assert scrollback_path.exists()
# The scenario's wait step matched either a completed turn (the usage
# separator) or the runtime's own timeout notice; the final scrollback must
# still show that state rather than an empty or crashed pane.
scrollback = scrollback_path.read_text(encoding="utf-8")
assert scrollback.strip()
assert "Traceback (most recent call last)" not in scrollback
assert (
" · " in scrollback
or "The task timed out before it could finish." in scrollback
)
@@ -0,0 +1,19 @@
from __future__ import annotations
import pytest
from tui_real_terminal.scenarios import scenario_by_id
pytestmark = pytest.mark.tui_real_terminal
def test_long_streaming_output(run_real_terminal_scenario) -> None:
result = run_real_terminal_scenario(scenario_by_id("long_streaming"))
assert result.status == "pass"
assert (result.run_dir / "frames").is_dir()
assert (result.run_dir / "transcript.txt").exists()
assert (result.run_dir / "scrollback.txt").exists()
scrollback = (result.run_dir / "scrollback.txt").read_text(encoding="utf-8")
assert "stream-token-000" in scrollback
assert "stream-token-079" in scrollback
@@ -0,0 +1,215 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
HARNESS_PARENT = Path(__file__).resolve().parents[1]
if str(HARNESS_PARENT) not in sys.path:
sys.path.insert(0, str(HARNESS_PARENT))
from tui_real_terminal.assertions import ( # noqa: E402
assert_no_completion_menu_overlap,
assert_no_inline_prompt_chrome_collision,
assert_no_raw_ansi_leakage,
assert_no_stale_completion_menu,
assert_prompt_ready,
assert_visible_text,
)
from tui_real_terminal.driver import ( # noqa: E402
TerminalFrame,
TerminalSize,
)
from tui_real_terminal.evidence import ( # noqa: E402
EvidenceBundle,
ScenarioResult,
)
from tui_real_terminal.scenarios import ( # noqa: E402
all_scenarios,
scenario_by_id,
)
from tui_real_terminal.visual import build_visual_verdict # noqa: E402
def test_all_abcd_scenarios_are_declared() -> None:
scenarios = {scenario.scenario_id: scenario for scenario in all_scenarios()}
assert set(scenarios) == {
"launch_input_loop",
"cjk_input_loop",
"long_streaming",
"complex_ui_state",
"architecture_prompt",
"completion_file_menu_escape",
"completion_menu_preserves_history",
"completion_menu_resize",
"completion_slash_menu_filter",
"live_opentui_architecture_prompt",
"terminal_changes",
}
assert scenarios["launch_input_loop"].family == "launch_and_input_loop"
assert scenarios["cjk_input_loop"].family == "launch_and_input_loop"
assert scenarios["long_streaming"].family == "long_streaming_output"
assert scenarios["complex_ui_state"].family == "complex_ui_state"
assert scenarios["architecture_prompt"].family == "architecture_prompt"
assert scenarios["completion_file_menu_escape"].family == "completion_menu"
assert scenarios["completion_menu_preserves_history"].family == "completion_menu"
assert scenarios["completion_menu_resize"].family == "completion_menu"
assert scenarios["completion_slash_menu_filter"].family == "completion_menu"
assert scenarios["live_opentui_architecture_prompt"].family == "live_prompt"
assert scenarios["terminal_changes"].family == "terminal_changes"
assert scenarios["live_opentui_architecture_prompt"].requires_tmux is True
assert scenarios["live_opentui_architecture_prompt"].requires_prompt_ready is False
assert (
scenarios["live_opentui_architecture_prompt"].required_backend_id
== "live-opentui"
)
def test_launch_scenario_serializes_to_json(tmp_path: Path) -> None:
scenario = scenario_by_id("launch_input_loop")
bundle = EvidenceBundle.create(
tmp_path,
scenario_id=scenario.scenario_id,
backend_id="terminal",
)
bundle.write_scenario(scenario.to_json_dict())
data = json.loads((bundle.run_dir / "scenario.json").read_text())
assert data["scenario_id"] == "launch_input_loop"
assert data["family"] == "launch_and_input_loop"
assert data["initial_size"] == {"cols": 100, "rows": 30}
def test_complex_ui_state_captures_intermediate_frame_before_final_state() -> None:
scenario = scenario_by_id("complex_ui_state")
intermediate_steps = [
step for step in scenario.steps if step.checkpoint == "during-intermediate"
]
assert len(intermediate_steps) == 1
assert intermediate_steps[0].action == "wait_text"
assert intermediate_steps[0].value == "intermediate-before-tool"
def test_visible_text_assertion_includes_checkpoint() -> None:
frame = TerminalFrame("after-input", "hello world", 1, TerminalSize())
with pytest.raises(AssertionError, match="after-input"):
assert_visible_text(frame, "missing")
def test_prompt_ready_accepts_visible_you_prompt() -> None:
frame = TerminalFrame("ready", "◢ you ", 1, TerminalSize())
assert_prompt_ready(frame)
def test_inline_prompt_chrome_collision_rejects_partial_prompt_redraw() -> None:
frame = TerminalFrame("after-turn", " │ s### heading\nbody", 1, TerminalSize())
with pytest.raises(AssertionError, match="inline prompt chrome overlapped"):
assert_no_inline_prompt_chrome_collision(frame)
def test_inline_prompt_chrome_collision_accepts_placeholder_row() -> None:
frame = TerminalFrame("ready", " │ send a massage │", 1, TerminalSize())
assert_no_inline_prompt_chrome_collision(frame)
def test_ansi_leakage_assertion_rejects_raw_escape() -> None:
frame = TerminalFrame("after-stream", "safe \x1b[2J unsafe", 1, TerminalSize())
with pytest.raises(AssertionError, match="raw terminal escape"):
assert_no_raw_ansi_leakage(frame)
def test_completion_menu_overlap_rejects_dirty_border() -> None:
frame = TerminalFrame(
"menu",
" ╭ commands ─ fake-response:hello ─╮\n"
" /compact Compact history │\n"
" ╰─────────────────────────────────╯",
1,
TerminalSize(),
)
with pytest.raises(AssertionError, match="completion menu overlap"):
assert_no_completion_menu_overlap(frame)
def test_stale_completion_menu_rejects_leftover_title_border() -> None:
frame = TerminalFrame(
"after-close",
" ╭ commands ───────────────────────╮\n"
" /compact Compact history │\n"
" ╰─────────────────────────────────╯",
1,
TerminalSize(),
)
with pytest.raises(AssertionError, match="stale completion menu"):
assert_no_stale_completion_menu(frame)
def test_evidence_bundle_writes_required_artifacts(tmp_path: Path) -> None:
bundle = EvidenceBundle.create(
tmp_path,
scenario_id="launch_input_loop",
backend_id="terminal",
)
frame = TerminalFrame("ready", "OPEN_SQUILLA_TUI_READY", 1, TerminalSize())
bundle.write_scenario({"scenario_id": "launch_input_loop"})
frame_path = bundle.record_frame(frame)
bundle.write_visual_verdict(
{
"status": "inspect",
"severity": "inspect-only",
"affected_region": "terminal",
"symptom": "screenshot unavailable",
"suspected_cause": "text-only run",
"recommended_next_action": "review transcript",
}
)
bundle.write_result(
ScenarioResult(
scenario_id="launch_input_loop",
backend_id="terminal",
status="pass",
run_dir=bundle.run_dir,
)
)
assert (bundle.run_dir / "scenario.json").exists()
assert (bundle.run_dir / "terminal.log").exists()
assert (bundle.run_dir / "app.log").exists()
assert (bundle.run_dir / "transcript.txt").exists()
assert (bundle.run_dir / "scrollback.txt").exists()
assert frame_path == bundle.run_dir / "frames" / "000-ready.txt"
assert frame_path.exists()
assert (bundle.run_dir / "screenshots").is_dir()
assert (bundle.run_dir / "visual-verdict.json").exists()
assert (bundle.run_dir / "result.json").exists()
def test_visual_verdict_contract_defaults_to_inspect_without_screenshot() -> None:
verdict = build_visual_verdict(
scenario_id="launch_input_loop",
checkpoint="after-response",
backend_id="terminal",
terminal_size={"cols": 100, "rows": 30},
screenshot_path=None,
frame_path="frames/003-after-response.txt",
expected_visible_regions=("prompt", "assistant stream"),
)
assert verdict["status"] == "inspect"
assert verdict["severity"] == "inspect-only"
assert verdict["affected_region"] == "terminal"
assert verdict["recommended_next_action"]
assert verdict["input"]["failure_modes"]
@@ -0,0 +1,98 @@
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from tui_real_terminal.driver import TerminalSize
from tui_real_terminal.targets import TargetContext, build_tui_target
REMOVED_TEXT_BACKEND = "text" + "ual"
REMOVED_BACKEND_IDS = ("terminal", REMOVED_TEXT_BACKEND, f"live-{REMOVED_TEXT_BACKEND}")
def test_removed_backend_targets_fail_clearly(tmp_path: Path) -> None:
context = TargetContext(
project_root=Path.cwd(),
artifact_dir=tmp_path,
scenario_id="launch_input_loop",
size=TerminalSize(cols=100, rows=30),
)
for backend_id in REMOVED_BACKEND_IDS:
with pytest.raises(ValueError, match="only opentui is supported"):
build_tui_target(backend_id, context)
def test_opentui_target_builds_fake_footer_app_command(tmp_path: Path) -> None:
context = TargetContext(
project_root=Path.cwd(),
artifact_dir=tmp_path,
scenario_id="launch_input_loop",
size=TerminalSize(cols=100, rows=30),
)
target = build_tui_target("opentui", context)
assert target.backend_id == "opentui"
assert target.available is True
assert target.skip_reason is None
assert target.command[:2] == [sys.executable, "-u"]
assert target.command[2].endswith("fake_opentui_app.py")
assert target.env["OPENSQUILLA_TUI_FAKE_SCENARIO"] == "launch_input_loop"
assert target.env["OPENSQUILLA_TUI_READY_MARKER"] == "OPEN_SQUILLA_TUI_READY"
assert target.env["OPENSQUILLA_TUI_BACKEND"] == "opentui"
assert target.readiness_markers == ("OPEN_SQUILLA_TUI_READY",)
assert target.log_paths == (tmp_path / "opentui-app.log",)
assert "opentui-footer" in target.capability_requirements
def test_live_opentui_target_builds_real_cli_command(tmp_path: Path) -> None:
context = TargetContext(
project_root=Path.cwd(),
artifact_dir=tmp_path,
scenario_id="live_architecture_prompt",
size=TerminalSize(cols=112, rows=34),
)
target = build_tui_target("live-opentui", context)
assert target.backend_id == "live-opentui"
assert target.command[:3] == [sys.executable, "-u", "-m"]
assert target.command[3:6] == ["opensquilla.cli.main", "chat", "--standalone"]
assert "--workspace" in target.command
assert str(Path.cwd()) in target.command
assert "--workspace-strict" in target.command
assert target.env["OPENSQUILLA_TUI_BACKEND"] == "opentui"
assert target.env["OPENSQUILLA_TUI_READY_MARKER"] == "OPEN_SQUILLA_TUI_READY"
assert "real-cli" in target.capability_requirements
assert "tmux" in target.capability_requirements
assert "fake-provider" not in target.capability_requirements
def test_live_opentui_target_preserves_user_config_path(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
home = tmp_path / "home"
user_config = home / ".opensquilla" / "config.toml"
user_config.parent.mkdir(parents=True)
user_config.write_text("[llm]\nprovider = 'openrouter'\n", encoding="utf-8")
project_root = tmp_path / "project"
project_root.mkdir()
artifact_dir = tmp_path / "artifacts"
monkeypatch.setenv("HOME", str(home))
monkeypatch.delenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", raising=False)
monkeypatch.delenv("OPENSQUILLA_STATE_DIR", raising=False)
context = TargetContext(
project_root=project_root,
artifact_dir=artifact_dir,
scenario_id="live_architecture_prompt",
size=TerminalSize(cols=112, rows=34),
)
target = build_tui_target("live-opentui", context)
assert "OPENSQUILLA_STATE_DIR" not in target.env
assert target.env["OPENSQUILLA_GATEWAY_CONFIG_PATH"] == str(user_config)
@@ -0,0 +1,38 @@
from __future__ import annotations
import json
import pytest
from tui_real_terminal.scenarios import scenario_by_id
pytestmark = pytest.mark.tui_real_terminal
PASTED_INPUT = "first line\nsecond line CJK混合ASCII"
def test_terminal_resize_paste_ctrl_c_and_eof(run_real_terminal_scenario) -> None:
result = run_real_terminal_scenario(scenario_by_id("terminal_changes"))
assert result.status == "pass"
assert (result.run_dir / "frames").is_dir()
assert (result.run_dir / "transcript.txt").exists()
scrollback = (result.run_dir / "scrollback.txt").read_text(encoding="utf-8")
assert "terminal-change-response lines=2" in scrollback
assert "echo-line-0:first line" in scrollback
assert "echo-line-1:second line CJK混合ASCII" in scrollback
# The dispatch log records the exact submitted text: the multi-line paste
# must reach the app with its newline intact, not collapsed to one line.
app_events = [
json.loads(line)
for line in (result.run_dir / "opentui-app.log")
.read_text(encoding="utf-8")
.splitlines()
if line.strip()
]
submitted = [
event["payload"].get("input", "")
for event in app_events
if event["event"] == "dispatch"
]
assert PASTED_INPUT in submitted
@@ -0,0 +1,49 @@
from __future__ import annotations
from typing import Any
def build_visual_verdict(
*,
scenario_id: str,
checkpoint: str,
backend_id: str,
terminal_size: dict[str, int],
screenshot_path: str | None,
frame_path: str,
expected_visible_regions: tuple[str, ...],
) -> dict[str, Any]:
status = "inspect" if screenshot_path is None else "pass"
severity = "inspect-only" if screenshot_path is None else "acceptable-variation"
symptom = "screenshot unavailable" if screenshot_path is None else "no blocking symptom"
return {
"status": status,
"severity": severity,
"affected_region": "terminal",
"symptom": symptom,
"suspected_cause": "text-only driver mode" if screenshot_path is None else "none",
"recommended_next_action": (
"review transcript and frames" if screenshot_path is None else "keep evidence"
),
"input": {
"scenario_id": scenario_id,
"checkpoint": checkpoint,
"backend_id": backend_id,
"terminal_size": terminal_size,
"screenshot_path": screenshot_path,
"frame_path": frame_path,
"expected_visible_regions": list(expected_visible_regions),
"failure_modes": [
"overlap between HUD, prompt, tool cards, and stream text",
"clipping at terminal edge, panel border, or prompt region",
"broken wrapping for long text, code fences, URLs, and CJK text",
"unreadable hierarchy or color contrast",
"stale loading, approval, or HUD state",
"bad recovery after resize, Ctrl-C, approval, or EOF",
],
},
}
def blocking(verdict: dict[str, Any]) -> bool:
return verdict.get("status") == "fail" and verdict.get("severity") == "blocking"