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