- 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>
137 lines
5.9 KiB
Python
137 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""tests/container.py — run the full test suite inside a throwaway container.
|
|
|
|
Two things can only be tested honestly inside a disposable environment, not
|
|
on the machine actually running Claude Code: installing the plexer hook into
|
|
a real ~/.bashrc and watching a real `bash -i` actually get wrapped by it
|
|
(tests/bashrc.py), and driving TmuxPlexerController against a real tmux
|
|
session. This is the harness for that -- not a replacement for test_seal.py,
|
|
test_tai.py, or tests/plexer.py, which also run inside the same container
|
|
first, so "did it work in a clean environment" is answered for everything,
|
|
not just the two new container-only tests. tests/rag.py runs too, but as a
|
|
best-effort check, not a required one -- see BEST_EFFORT_SUITE below for why.
|
|
|
|
Engine detection deliberately reuses tai.container_engine() (podman first,
|
|
then docker, same as the project's own sandbox feature) rather than
|
|
reimplementing "which one is installed" a second way. docker-compose.yml at
|
|
the repo root exists for a human who wants `docker compose run --rm
|
|
tai-test` / `podman compose run --rm tai-test`; this script drives plain
|
|
build/run directly so the automated path never depends on whether a compose
|
|
implementation happens to be installed on top of the engine itself.
|
|
|
|
Usage:
|
|
python3 tests/container.py # build the image, run everything inside it
|
|
python3 tests/container.py --inside # (used by the container itself) actually run the suite
|
|
"""
|
|
import argparse
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(ROOT))
|
|
import tai # noqa: E402
|
|
|
|
IMAGE_TAG = "tai-test:latest"
|
|
|
|
# Run in this order: cheapest/most-fundamental first, so a basic break fails
|
|
# fast instead of burning time on tmux/bashrc tests that assume the basics
|
|
# already work. These must all pass for the container run to count as green.
|
|
REQUIRED_SUITE = [
|
|
("test_seal.py", ["python3", "test_seal.py"]),
|
|
("test_tai.py", ["python3", "-m", "unittest", "test_tai", "-v"]),
|
|
("tests/plexer.py", ["python3", "tests/plexer.py"]),
|
|
("tests/bashrc.py", ["python3", "tests/bashrc.py"]),
|
|
]
|
|
|
|
# tests/rag.py's RagIndexAndSearchTest needs a real, working LLM backend.
|
|
# Confirmed by direct investigation (not assumed): inside this disposable
|
|
# container there is no opencode/claude/codex/gemini/grok CLI (the host's
|
|
# own successful run used a locally authenticated `opencode` CLI that has
|
|
# no business being baked into a throwaway test image), and the remaining
|
|
# free HTTP backends fail for reasons that are about the shared sandbox
|
|
# network, not this code -- openrouter's free tier returned "HTTP 401: No
|
|
# cookie auth credentials found" for every model (anonymous free-tier auth
|
|
# needs a browser cookie session this container doesn't have), and
|
|
# pollinations returned "HTTP 429: Queue full for IP: ...: 1 requests
|
|
# already queued" (its free tier's per-IP concurrency cap, already
|
|
# exhausted by other traffic sharing this sandbox's egress address). Both
|
|
# reproduced with a bare urllib POST outside of tai.py entirely, so this is
|
|
# not a tai.py bug to fix. It's run and reported like everything else, but
|
|
# never fails the overall container result -- tests/rag.py already runs
|
|
# (and passes) against the host directly, which is where a real LLM
|
|
# backend actually exists; see tests/README.md's "test with the real AI,
|
|
# accept the flakes" rule, which this is really just a container-flavored
|
|
# instance of.
|
|
BEST_EFFORT_SUITE = [
|
|
("tests/rag.py", ["python3", "tests/rag.py"]),
|
|
]
|
|
|
|
|
|
def run_outside():
|
|
engine = tai.container_engine()
|
|
if not engine:
|
|
print("no container engine found (checked podman, then docker) -- refusing to run the "
|
|
"container-only test suite. Install either one to run tests/bashrc.py and the "
|
|
"TmuxPlexerController live test; everything else already ran on the host directly.")
|
|
return 1
|
|
|
|
print("using container engine: %s" % engine, flush=True)
|
|
build = subprocess.run([engine, "build", "-t", IMAGE_TAG, "-f", "tests/docker/Dockerfile", "."], cwd=str(ROOT))
|
|
if build.returncode != 0:
|
|
print("image build failed", flush=True)
|
|
return build.returncode
|
|
|
|
run = subprocess.run([engine, "run", "--rm", IMAGE_TAG, "python3", "tests/container.py", "--inside"], cwd=str(ROOT))
|
|
return run.returncode
|
|
|
|
|
|
def _run_suite(suite):
|
|
results = []
|
|
for name, cmd in suite:
|
|
print("\n=== %s ===" % name)
|
|
done = subprocess.run(cmd, cwd=str(ROOT))
|
|
results.append((name, done.returncode == 0))
|
|
print("--- %s: %s ---" % (name, "PASS" if done.returncode == 0 else "FAIL"))
|
|
return results
|
|
|
|
|
|
def run_inside():
|
|
required = _run_suite(REQUIRED_SUITE)
|
|
best_effort = _run_suite(BEST_EFFORT_SUITE)
|
|
|
|
print("\n=== container test summary ===")
|
|
for name, passed in required:
|
|
print("%-6s %s" % ("PASS" if passed else "FAIL", name))
|
|
for name, passed in best_effort:
|
|
print("%-6s %s (best-effort: needs a real LLM backend, see comment in this file)" % ("PASS" if passed else "FAIL", name))
|
|
|
|
failed_required = [name for name, passed in required if not passed]
|
|
failed_best_effort = [name for name, passed in best_effort if not passed]
|
|
total = len(required) + len(best_effort)
|
|
passed_count = total - len(failed_required) - len(failed_best_effort)
|
|
print("%d/%d passed" % (passed_count, total), end="")
|
|
if failed_best_effort:
|
|
print(" (ignoring best-effort failures: %s)" % ", ".join(failed_best_effort))
|
|
else:
|
|
print("")
|
|
|
|
if failed_required:
|
|
print("REQUIRED tests failed: %s" % ", ".join(failed_required))
|
|
return 1
|
|
return 0
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--inside", action="store_true", help="internal: actually run the suite (used by the container itself)")
|
|
args = parser.parse_args()
|
|
if args.inside:
|
|
return run_inside()
|
|
return run_outside()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|