528 lines
21 KiB
Python
528 lines
21 KiB
Python
#!/usr/bin/env python3
|
||||
|
|
"""tai_test.py — the integration-test framework for driving tai.py as a real subprocess.
|
|||
|
|
|
|||
|
|
This is a library, not a test itself (it has no "test_" prefix on purpose — nothing
|
|||
|
|
here is collected by unittest/pytest). Integration tests that use it live under
|
|||
|
|
tests/ and import it; see tests/README.md for the conventions and tests/rag.py for
|
|||
|
|
a worked example.
|
|||
|
|
|
|||
|
|
Design, and why, in one paragraph per decision (full research notes are archived
|
|||
|
|
under research/ — see research/README.md for how this project keeps that archive):
|
|||
|
|
|
|||
|
|
- Two transport modes, one interface. "pipe" mode uses asyncio's native
|
|||
|
|
create_subprocess_exec with PIPE streams — the simplest, most deterministic
|
|||
|
|
choice, and what one-shot `tai.py <prompt>` invocations should use, since they
|
|||
|
|
just run once and exit. "pty" mode opens a real pseudo-terminal (the stdlib
|
|||
|
|
`pty` module) and gives the child its slave end as stdin/stdout/stderr, because
|
|||
|
|
tai.py's own code branches on `sys.stdin.isatty()` (approval prompts, readline)
|
|||
|
|
— a plain pipe makes it behave as if piped, not as if a human were typing. Both
|
|||
|
|
modes end up exposing the same asyncio.StreamReader-based interface (the pty
|
|||
|
|
master fd is wrapped into one via loop.connect_read_pipe), so callers never
|
|||
|
|
need to know which transport a given test is using.
|
|||
|
|
- start_new_session=True, always, plus a kill-the-whole-group escalation
|
|||
|
|
(SIGTERM, wait, then SIGKILL, then always proc.wait() to reap). A plain
|
|||
|
|
proc.terminate() only signals the direct child; if tai.py ever forks a
|
|||
|
|
subagent or a shell command, a timeout needs to take the whole tree down, not
|
|||
|
|
just the leader, and failing to reap after killing leaves a zombie.
|
|||
|
|
- Non-blocking everything, no polling. The child's output is consumed through
|
|||
|
|
asyncio.StreamReader (native backpressure, no busy loop). A human's live
|
|||
|
|
stdin, when a test opts into --interactive, is bridged into the loop with
|
|||
|
|
loop.add_reader + raw os.read on the fd (never the buffered sys.stdin, which
|
|||
|
|
blocks) rather than a background thread — matching how the child's own output
|
|||
|
|
is handled, and confirmed against multiple independent sources as the correct
|
|||
|
|
pattern. Full sourcing for every design decision in this module — pty vs.
|
|||
|
|
pipes, the connect_read_pipe trick, the killpg escalation, the TestCase
|
|||
|
|
shape itself — is archived at research/async-pty-process-testing/ per this
|
|||
|
|
project's standing research rule (see AGENTS.md).
|
|||
|
|
- A known, accepted limitation: the pty mode does not explicitly call
|
|||
|
|
ioctl(TIOCSCTTY), so the child's session may not become the pty's controlling
|
|||
|
|
terminal in the job-control sense. isatty() on the fd is unaffected by this
|
|||
|
|
(it's a property of the fd, not of session leadership) so approval-prompt and
|
|||
|
|
readline behavior work correctly; what would NOT work is SIGWINCH-based resize
|
|||
|
|
forwarding or Ctrl-C-as-SIGINT generated by the tty line discipline. Tests
|
|||
|
|
that only script line-based input/output (the normal case) are unaffected.
|
|||
|
|
"""
|
|||
|
|
import argparse
|
|||
|
|
import asyncio
|
|||
|
|
import dataclasses
|
|||
|
|
import datetime
|
|||
|
|
import os
|
|||
|
|
import pty
|
|||
|
|
import re
|
|||
|
|
import signal
|
|||
|
|
import sys
|
|||
|
|
import time
|
|||
|
|
import traceback
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
DEFAULT_TAI_PATH = Path(__file__).resolve().parent / "tai.py"
|
|||
|
|
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
|||
|
|
REPL_PROMPT_RE = re.compile(r"tai\[[^\]]*\]\xe2\x80\xba|tai\[[^\]]*\]›")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def strip_ansi(text):
|
|||
|
|
return ANSI_RE.sub("", text)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------- #
|
|||
|
|
# Exceptions. Every failure a test can hit is one of these, and every one of
|
|||
|
|
# them carries the transcript collected so far, so a failing test is debuggable
|
|||
|
|
# from its log file alone, without reproducing it.
|
|||
|
|
# --------------------------------------------------------------------------- #
|
|||
|
|
|
|||
|
|
class TaiTestError(Exception):
|
|||
|
|
def __init__(self, message, transcript=None):
|
|||
|
|
super().__init__(message)
|
|||
|
|
self.transcript = list(transcript or [])
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestTimeout(TaiTestError):
|
|||
|
|
"""Raised when a step, or the test as a whole, exceeds its timeout."""
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestCrashed(TaiTestError):
|
|||
|
|
"""Raised when the child process exited (or died to a signal) unexpectedly."""
|
|||
|
|
|
|||
|
|
def __init__(self, message, returncode, transcript=None):
|
|||
|
|
super().__init__(message, transcript)
|
|||
|
|
self.returncode = returncode
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestAssertionFailed(TaiTestError):
|
|||
|
|
"""Raised by a test's verify() when the captured behavior doesn't match expectations."""
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------- #
|
|||
|
|
# Transcript + result data types.
|
|||
|
|
# --------------------------------------------------------------------------- #
|
|||
|
|
|
|||
|
|
@dataclasses.dataclass
|
|||
|
|
class OutputLine:
|
|||
|
|
stream: str # "stdout" | "stdin" (stderr is merged into stdout in pipe mode; pty merges all three)
|
|||
|
|
text: str
|
|||
|
|
ts: float
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclasses.dataclass
|
|||
|
|
class TestResult:
|
|||
|
|
name: str
|
|||
|
|
passed: bool
|
|||
|
|
duration: float
|
|||
|
|
error: str = None
|
|||
|
|
transcript: list = dataclasses.field(default_factory=list)
|
|||
|
|
log_path: str = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------- #
|
|||
|
|
# TeeLogger: every byte a test sees also goes to screen and to a log file,
|
|||
|
|
# synchronously (plain stdlib file I/O, flushed per write) — no extra
|
|||
|
|
# dependency, and fast enough that it never becomes the bottleneck next to a
|
|||
|
|
# network-bound LLM call or a human typing.
|
|||
|
|
# --------------------------------------------------------------------------- #
|
|||
|
|
|
|||
|
|
class TeeLogger:
|
|||
|
|
def __init__(self, log_path, echo=True):
|
|||
|
|
self.log_path = Path(log_path)
|
|||
|
|
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
self._fh = open(self.log_path, "a", encoding="utf-8")
|
|||
|
|
self.echo = echo
|
|||
|
|
|
|||
|
|
def write(self, text):
|
|||
|
|
self._fh.write(text)
|
|||
|
|
self._fh.flush()
|
|||
|
|
if self.echo:
|
|||
|
|
sys.stdout.write(text)
|
|||
|
|
sys.stdout.flush()
|
|||
|
|
|
|||
|
|
def close(self):
|
|||
|
|
try:
|
|||
|
|
self._fh.close()
|
|||
|
|
except OSError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------- #
|
|||
|
|
# StdinBridge: bridges the REAL terminal's stdin into an asyncio.Queue of
|
|||
|
|
# complete lines, without blocking the event loop and without a thread.
|
|||
|
|
# No-ops safely (returns False from start()) if real stdin isn't a tty, e.g.
|
|||
|
|
# under CI — a test must not hang waiting for input nobody can type.
|
|||
|
|
# --------------------------------------------------------------------------- #
|
|||
|
|
|
|||
|
|
class StdinBridge:
|
|||
|
|
def __init__(self):
|
|||
|
|
self.queue = asyncio.Queue()
|
|||
|
|
self._fd = None
|
|||
|
|
self._buf = b""
|
|||
|
|
self._active = False
|
|||
|
|
self._loop = None
|
|||
|
|
|
|||
|
|
def start(self):
|
|||
|
|
if not sys.stdin.isatty():
|
|||
|
|
return False
|
|||
|
|
self._loop = asyncio.get_running_loop()
|
|||
|
|
self._fd = sys.stdin.fileno()
|
|||
|
|
self._loop.add_reader(self._fd, self._on_readable)
|
|||
|
|
self._active = True
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
def _on_readable(self):
|
|||
|
|
try:
|
|||
|
|
chunk = os.read(self._fd, 4096)
|
|||
|
|
except OSError:
|
|||
|
|
chunk = b""
|
|||
|
|
if not chunk:
|
|||
|
|
self.stop()
|
|||
|
|
self.queue.put_nowait(None)
|
|||
|
|
return
|
|||
|
|
self._buf += chunk
|
|||
|
|
while b"\n" in self._buf:
|
|||
|
|
line, self._buf = self._buf.split(b"\n", 1)
|
|||
|
|
self.queue.put_nowait(line.decode(errors="replace"))
|
|||
|
|
|
|||
|
|
def stop(self):
|
|||
|
|
if self._active:
|
|||
|
|
try:
|
|||
|
|
self._loop.remove_reader(self._fd)
|
|||
|
|
except (ValueError, OSError):
|
|||
|
|
pass
|
|||
|
|
self._active = False
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------- #
|
|||
|
|
# ManagedProcess: spawns and safely controls one subprocess, in "pipe" or
|
|||
|
|
# "pty" mode, with a single interface either way.
|
|||
|
|
# --------------------------------------------------------------------------- #
|
|||
|
|
|
|||
|
|
class ManagedProcess:
|
|||
|
|
def __init__(self, command, mode="pipe", cwd=None, env=None, on_line=None):
|
|||
|
|
self.command = list(command)
|
|||
|
|
self.mode = mode
|
|||
|
|
self.cwd = cwd
|
|||
|
|
self.env = env
|
|||
|
|
self._on_line = on_line
|
|||
|
|
self.proc = None
|
|||
|
|
self._master_fd = None
|
|||
|
|
self._master_file = None
|
|||
|
|
self._reader = None
|
|||
|
|
self._lines = []
|
|||
|
|
|
|||
|
|
async def start(self):
|
|||
|
|
loop = asyncio.get_running_loop()
|
|||
|
|
if self.mode == "pty":
|
|||
|
|
master_fd, slave_fd = pty.openpty()
|
|||
|
|
self.proc = await asyncio.create_subprocess_exec(
|
|||
|
|
*self.command,
|
|||
|
|
stdin=slave_fd, stdout=slave_fd, stderr=slave_fd,
|
|||
|
|
cwd=self.cwd, env=self.env,
|
|||
|
|
start_new_session=True,
|
|||
|
|
)
|
|||
|
|
os.close(slave_fd)
|
|||
|
|
self._master_fd = master_fd
|
|||
|
|
self._master_file = os.fdopen(master_fd, "rb", buffering=0)
|
|||
|
|
reader = asyncio.StreamReader(loop=loop)
|
|||
|
|
protocol = asyncio.StreamReaderProtocol(reader, loop=loop)
|
|||
|
|
await loop.connect_read_pipe(lambda: protocol, self._master_file)
|
|||
|
|
self._reader = reader
|
|||
|
|
else:
|
|||
|
|
self.proc = await asyncio.create_subprocess_exec(
|
|||
|
|
*self.command,
|
|||
|
|
stdin=asyncio.subprocess.PIPE,
|
|||
|
|
stdout=asyncio.subprocess.PIPE,
|
|||
|
|
stderr=asyncio.subprocess.STDOUT,
|
|||
|
|
cwd=self.cwd, env=self.env,
|
|||
|
|
start_new_session=True,
|
|||
|
|
)
|
|||
|
|
self._reader = self.proc.stdout
|
|||
|
|
return self
|
|||
|
|
|
|||
|
|
async def send_line(self, text):
|
|||
|
|
data = (text + "\n").encode("utf-8", "replace")
|
|||
|
|
self._record("stdin", text + "\n")
|
|||
|
|
if self.mode == "pty":
|
|||
|
|
os.write(self._master_fd, data)
|
|||
|
|
else:
|
|||
|
|
self.proc.stdin.write(data)
|
|||
|
|
await self.proc.stdin.drain()
|
|||
|
|
|
|||
|
|
def _record(self, stream, text):
|
|||
|
|
line = OutputLine(stream, text, time.time())
|
|||
|
|
self._lines.append(line)
|
|||
|
|
if self._on_line:
|
|||
|
|
self._on_line(line)
|
|||
|
|
|
|||
|
|
async def _read_chunk(self, n=4096):
|
|||
|
|
try:
|
|||
|
|
chunk = await self._reader.read(n)
|
|||
|
|
except (OSError, ValueError, asyncio.IncompleteReadError):
|
|||
|
|
return b""
|
|||
|
|
return chunk
|
|||
|
|
|
|||
|
|
async def wait_for_pattern(self, pattern, timeout=30.0):
|
|||
|
|
"""Accumulates output until `pattern` (compiled regex) matches the
|
|||
|
|
ANSI-stripped buffer; returns everything read before the match."""
|
|||
|
|
buf = ""
|
|||
|
|
deadline = time.monotonic() + timeout
|
|||
|
|
while True:
|
|||
|
|
remaining = deadline - time.monotonic()
|
|||
|
|
if remaining <= 0:
|
|||
|
|
raise TestTimeout("timed out waiting for pattern %r" % pattern.pattern, self._lines)
|
|||
|
|
try:
|
|||
|
|
chunk = await asyncio.wait_for(self._read_chunk(), timeout=remaining)
|
|||
|
|
except asyncio.TimeoutError:
|
|||
|
|
raise TestTimeout("timed out waiting for pattern %r" % pattern.pattern, self._lines)
|
|||
|
|
if not chunk:
|
|||
|
|
raise TestCrashed(
|
|||
|
|
"process ended before pattern %r was seen" % pattern.pattern,
|
|||
|
|
self.proc.returncode, self._lines,
|
|||
|
|
)
|
|||
|
|
text = chunk.decode("utf-8", "replace")
|
|||
|
|
self._record("stdout", text)
|
|||
|
|
buf += strip_ansi(text)
|
|||
|
|
match = pattern.search(buf)
|
|||
|
|
if match:
|
|||
|
|
return buf[:match.start()]
|
|||
|
|
|
|||
|
|
async def drain_until_eof(self, timeout=120.0):
|
|||
|
|
"""Reads everything until the process closes its output — the one-shot case."""
|
|||
|
|
deadline = time.monotonic() + timeout
|
|||
|
|
out = []
|
|||
|
|
while True:
|
|||
|
|
remaining = deadline - time.monotonic()
|
|||
|
|
if remaining <= 0:
|
|||
|
|
raise TestTimeout("timed out waiting for process output to end", self._lines)
|
|||
|
|
try:
|
|||
|
|
chunk = await asyncio.wait_for(self._read_chunk(), timeout=remaining)
|
|||
|
|
except asyncio.TimeoutError:
|
|||
|
|
raise TestTimeout("timed out waiting for process output to end", self._lines)
|
|||
|
|
if not chunk:
|
|||
|
|
break
|
|||
|
|
text = chunk.decode("utf-8", "replace")
|
|||
|
|
self._record("stdout", text)
|
|||
|
|
out.append(text)
|
|||
|
|
return "".join(out)
|
|||
|
|
|
|||
|
|
async def wait(self, timeout=None):
|
|||
|
|
if timeout is None:
|
|||
|
|
return await self.proc.wait()
|
|||
|
|
try:
|
|||
|
|
return await asyncio.wait_for(self.proc.wait(), timeout)
|
|||
|
|
except asyncio.TimeoutError:
|
|||
|
|
raise TestTimeout("process did not exit within %.1fs" % timeout, self._lines)
|
|||
|
|
|
|||
|
|
async def terminate(self, grace=5.0):
|
|||
|
|
"""Escalating, group-wide, always-reaping shutdown. Safe to call on an
|
|||
|
|
already-exited process, and safe to call twice."""
|
|||
|
|
if self.proc is None or self.proc.returncode is not None:
|
|||
|
|
return self.proc.returncode if self.proc else None
|
|||
|
|
pgid = self.proc.pid
|
|||
|
|
for sig, wait_s in ((signal.SIGTERM, grace), (signal.SIGKILL, None)):
|
|||
|
|
try:
|
|||
|
|
os.killpg(pgid, sig)
|
|||
|
|
except ProcessLookupError:
|
|||
|
|
break
|
|||
|
|
except PermissionError:
|
|||
|
|
break
|
|||
|
|
if wait_s is None:
|
|||
|
|
break
|
|||
|
|
try:
|
|||
|
|
await asyncio.wait_for(self.proc.wait(), wait_s)
|
|||
|
|
break
|
|||
|
|
except asyncio.TimeoutError:
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
return await asyncio.wait_for(self.proc.wait(), 5.0)
|
|||
|
|
except asyncio.TimeoutError:
|
|||
|
|
return None # best-effort; we tried SIGKILL already
|
|||
|
|
finally:
|
|||
|
|
if self._master_file is not None:
|
|||
|
|
try:
|
|||
|
|
self._master_file.close()
|
|||
|
|
except OSError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def transcript(self):
|
|||
|
|
return list(self._lines)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------- #
|
|||
|
|
# TestCase: the thing you extend. setup()/run()/verify()/teardown() are the
|
|||
|
|
# override points; execute() is the orchestration and is not meant to be
|
|||
|
|
# overridden. run() can spawn() zero, one, or many ManagedProcess instances —
|
|||
|
|
# the framework doesn't assume a 1:1 test-to-process relationship, since a
|
|||
|
|
# one-shot-style test naturally spawns many short-lived processes while a
|
|||
|
|
# REPL-style test spawns exactly one long-lived one.
|
|||
|
|
# --------------------------------------------------------------------------- #
|
|||
|
|
|
|||
|
|
class TestCase:
|
|||
|
|
name = None
|
|||
|
|
timeout = 60.0
|
|||
|
|
mode = "pipe" # default transport for spawn() when not overridden per call
|
|||
|
|
extra_args = () # args always passed to tai.py, before whatever spawn() adds
|
|||
|
|
|
|||
|
|
def __init__(self, tai_path=None, interactive=False):
|
|||
|
|
self.name = self.name or type(self).__name__
|
|||
|
|
self.tai_path = Path(tai_path or DEFAULT_TAI_PATH)
|
|||
|
|
self.interactive = interactive
|
|||
|
|
self.logger = None
|
|||
|
|
self._processes = []
|
|||
|
|
self.env_overrides = {}
|
|||
|
|
|
|||
|
|
# ---- override points --------------------------------------------------
|
|||
|
|
|
|||
|
|
async def setup(self):
|
|||
|
|
"""Optional. Prepare fixtures/state (e.g. an isolated TAI_HOME) before any process starts."""
|
|||
|
|
|
|||
|
|
async def run(self):
|
|||
|
|
"""Required. Drive the process(es) via self.spawn(); return whatever verify() needs."""
|
|||
|
|
raise NotImplementedError
|
|||
|
|
|
|||
|
|
async def verify(self, result):
|
|||
|
|
"""Required. Raise TestAssertionFailed (with a clear message) if `result` doesn't hold up."""
|
|||
|
|
raise NotImplementedError
|
|||
|
|
|
|||
|
|
async def teardown(self):
|
|||
|
|
"""Optional. Runs after every spawned process has been terminated, pass or fail."""
|
|||
|
|
|
|||
|
|
# ---- helpers for run() --------------------------------------------------
|
|||
|
|
|
|||
|
|
def build_env(self):
|
|||
|
|
env = dict(os.environ)
|
|||
|
|
env.update(self.env_overrides)
|
|||
|
|
return env
|
|||
|
|
|
|||
|
|
def build_command(self, extra_args=()):
|
|||
|
|
return [sys.executable, str(self.tai_path)] + list(self.extra_args) + list(extra_args)
|
|||
|
|
|
|||
|
|
async def spawn(self, extra_args=(), mode=None, env_overrides=None):
|
|||
|
|
env = self.build_env()
|
|||
|
|
env.update(env_overrides or {})
|
|||
|
|
proc = ManagedProcess(self.build_command(extra_args), mode=mode or self.mode, env=env, on_line=self._on_line)
|
|||
|
|
await proc.start()
|
|||
|
|
self._processes.append(proc)
|
|||
|
|
return proc
|
|||
|
|
|
|||
|
|
def _on_line(self, line):
|
|||
|
|
if self.logger:
|
|||
|
|
self.logger.write(line.text)
|
|||
|
|
|
|||
|
|
def all_transcript(self):
|
|||
|
|
out = []
|
|||
|
|
for proc in self._processes:
|
|||
|
|
out.extend(proc.transcript)
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
# ---- orchestration (do not override) -----------------------------------
|
|||
|
|
|
|||
|
|
async def execute(self, log_dir):
|
|||
|
|
start = time.monotonic()
|
|||
|
|
stamp = datetime.datetime.now().strftime("%Y%m%dT%H%M%S")
|
|||
|
|
log_path = Path(log_dir) / ("%s-%s.log" % (self.name, stamp))
|
|||
|
|
self.logger = TeeLogger(log_path, echo=True)
|
|||
|
|
self.logger.write("=== %s starting %s ===\n" % (self.name, stamp))
|
|||
|
|
bridge = StdinBridge() if self.interactive else None
|
|||
|
|
bridge_task = None
|
|||
|
|
try:
|
|||
|
|
await self.setup()
|
|||
|
|
if bridge is not None and bridge.start():
|
|||
|
|
bridge_task = asyncio.ensure_future(self._pump_bridge(bridge))
|
|||
|
|
payload = await asyncio.wait_for(self.run(), timeout=self.timeout)
|
|||
|
|
await self.verify(payload)
|
|||
|
|
return TestResult(self.name, True, time.monotonic() - start, transcript=self.all_transcript(), log_path=str(log_path))
|
|||
|
|
except asyncio.TimeoutError:
|
|||
|
|
err = TestTimeout("test exceeded its %.1fs timeout" % self.timeout, self.all_transcript())
|
|||
|
|
return self._failure(err, start, log_path)
|
|||
|
|
except TaiTestError as exc:
|
|||
|
|
return self._failure(exc, start, log_path)
|
|||
|
|
except Exception:
|
|||
|
|
err = TaiTestError(traceback.format_exc(), self.all_transcript())
|
|||
|
|
return self._failure(err, start, log_path)
|
|||
|
|
finally:
|
|||
|
|
if bridge is not None:
|
|||
|
|
bridge.stop()
|
|||
|
|
if bridge_task is not None:
|
|||
|
|
bridge_task.cancel()
|
|||
|
|
for proc in self._processes:
|
|||
|
|
await proc.terminate()
|
|||
|
|
await self.teardown()
|
|||
|
|
self.logger.write("=== %s finished ===\n" % self.name)
|
|||
|
|
self.logger.close()
|
|||
|
|
|
|||
|
|
def _failure(self, exc, start, log_path):
|
|||
|
|
return TestResult(self.name, False, time.monotonic() - start, error=str(exc), transcript=exc.transcript, log_path=str(log_path))
|
|||
|
|
|
|||
|
|
async def _pump_bridge(self, bridge):
|
|||
|
|
"""Forwards live human typing (when --interactive) to whichever process
|
|||
|
|
was spawned most recently — the normal case of exactly one long-lived
|
|||
|
|
REPL-mode process."""
|
|||
|
|
while True:
|
|||
|
|
line = await bridge.queue.get()
|
|||
|
|
if line is None:
|
|||
|
|
break
|
|||
|
|
if self._processes:
|
|||
|
|
await self._processes[-1].send_line(line)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------- #
|
|||
|
|
# TestRunner + CLI: runs a list of TestCase instances sequentially (they
|
|||
|
|
# share a real terminal and, in --interactive mode, real human attention, so
|
|||
|
|
# parallel execution is deliberately not offered here), prints a summary.
|
|||
|
|
# --------------------------------------------------------------------------- #
|
|||
|
|
|
|||
|
|
class TestRunner:
|
|||
|
|
def __init__(self, cases, log_dir="tests/logs"):
|
|||
|
|
self.cases = cases
|
|||
|
|
self.log_dir = log_dir
|
|||
|
|
|
|||
|
|
async def run_all(self):
|
|||
|
|
results = []
|
|||
|
|
for case in self.cases:
|
|||
|
|
print("\n--- running %s ---" % case.name)
|
|||
|
|
result = await case.execute(self.log_dir)
|
|||
|
|
status = "PASS" if result.passed else "FAIL"
|
|||
|
|
print("--- %s: %s (%.1fs, log: %s) ---" % (case.name, status, result.duration, result.log_path))
|
|||
|
|
if not result.passed:
|
|||
|
|
print(" %s" % (result.error or "").splitlines()[-1] if result.error else " (no error message)")
|
|||
|
|
results.append(result)
|
|||
|
|
self.print_summary(results)
|
|||
|
|
return results
|
|||
|
|
|
|||
|
|
def print_summary(self, results):
|
|||
|
|
print("\n=== summary ===")
|
|||
|
|
for result in results:
|
|||
|
|
status = "PASS" if result.passed else "FAIL"
|
|||
|
|
print("%-6s %-40s %6.1fs %s" % (status, result.name, result.duration, result.log_path))
|
|||
|
|
passed = sum(1 for r in results if r.passed)
|
|||
|
|
print("%d/%d passed" % (passed, len(results)))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main_cli(cases, default_log_dir=None):
|
|||
|
|
"""Standard entrypoint for a tests/*.py module: sys.exit(main_cli(ALL_CASES)).
|
|||
|
|
|
|||
|
|
default_log_dir should normally be the calling module's own directory,
|
|||
|
|
e.g. str(Path(__file__).resolve().parent / "logs") — passed explicitly
|
|||
|
|
because __file__ inside this function would resolve to tai_test.py's own
|
|||
|
|
location, not the caller's."""
|
|||
|
|
parser = argparse.ArgumentParser(description="run tai integration tests")
|
|||
|
|
parser.add_argument("--interactive", action="store_true", help="let you type into the running process live, alongside the scripted input")
|
|||
|
|
parser.add_argument("--filter", default=None, help="only run tests whose name contains this substring")
|
|||
|
|
parser.add_argument("--timeout", type=float, default=None, help="override every selected test's timeout, in seconds")
|
|||
|
|
parser.add_argument("--log-dir", default=default_log_dir or str(Path(__file__).resolve().parent / "logs"))
|
|||
|
|
args = parser.parse_args()
|
|||
|
|
selected = [case for case in cases if not args.filter or args.filter in case.name]
|
|||
|
|
if not selected:
|
|||
|
|
print("no tests matched filter %r" % args.filter)
|
|||
|
|
return 1
|
|||
|
|
for case in selected:
|
|||
|
|
case.interactive = args.interactive
|
|||
|
|
if args.timeout:
|
|||
|
|
case.timeout = args.timeout
|
|||
|
|
runner = TestRunner(selected, log_dir=args.log_dir)
|
|||
|
|
results = asyncio.run(runner.run_all())
|
|||
|
|
return 0 if all(r.passed for r in results) else 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
print(__doc__)
|
|||
|
|
sys.exit(0)
|