Files
tai/tests/container.py
T
retoorandClaude Sonnet 5 9dbd1fe667 Add plexer subsystem, container test infra, and last-resort opencode box backend
- 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>
2026-10-10 04:36:05 +02:00

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())