- Plexer: always-active terminal multiplexing. NativePlexerController/ TmuxPlexerController share one interface, chosen by which env var is already set (TAI_PLEXER_SOCKET vs TMUX_PANE); NativePlexerBackend is the pty.fork()-based wrapper (raw mode, SIGWINCH resize forwarding, a Unix-socket control protocol, continuous session logging, a Ctrl+A prefix key distinct from tmux's own Ctrl+B). Auto-wraps new interactive shells via a .bashrc hook mirroring the existing command-not-found install pattern, gated on $TMUX/$TAI_PLEXER_ACTIVE/ a $HOME/.tai-no-plexer bypass. New plexer_send/read/type/status tools, env-gated on a detected controller. - tests/container.py + docker-compose.yml + tests/docker/Dockerfile: a disposable podman/docker image for the tests that must never touch a real machine (installing the plexer hook into a real ~/.bashrc, a live tmux session). Required suite (test_seal.py, test_tai.py, tests/plexer.py, tests/bashrc.py) gates the result; tests/rag.py runs as best-effort since its real-LLM dependency can't be satisfied inside a disposable container with no nested engine. - tests/bashrc.py: real end-to-end hook install + bash -i auto-wrap, anti-recursion, tmux-active skip, non-interactive skip, a live TmuxPlexerController test, and an install/uninstall round-trip. - tests/plexer.py: NativePlexerBackend under a real pty, plus a dedicated robustness pass (file permissions, >64KB payload framing, a malformed control request, a genuine self-deadlock fix in the write-to-child path). - tai_test.py: the shared async process-driving framework (pipe/pty transport, group-wide kill-and-reap, a StdinBridge for --interactive) both test files build on. - ChatClient last-resort backend: when there's no local opencode CLI but the sandbox box is already running, install opencode into it (official Node tarball, not apt -- apt's nodejs/npm pull in webpack/babel/jest as transitive deps) and route through it instead of giving up. Only reachable after the first retry round already failed everything else; never creates the box itself. TmuxPlexerController.status() fixed to use `list-panes` instead of `display-message -p`, which silently fell back to the default pane instead of erroring against a dead target. - Fix 4 pre-existing test_tai.py bugs surfaced by running it in a clean environment for the first time: two stale mock signatures (subprocess.run's stdout= kwarg, Chat.complete's deadline= kwarg), one $TMUX-dependent catalog assertion, one tty-dependent color assertion. - research/: a sourced research archive (Source Dossier/Citation Card/Conclusion tiers) backing the above design decisions, with its own audit.py consistency checker. - .gitignore: fix a blanket *.md rule that was about to silently drop the entire research/ archive from version control; add __pycache__/ and the one-off debug transcript this session started from. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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)
|