chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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"
|
||||
Reference in New Issue
Block a user