Add Phase 0a black-box integration test suite
81 tests across 11 files plus a 5-module harness (load, pg, streams, containers, payloads), covering all eleven Phase 0 goals and all 13 named chaos-flag pathologies. Talks to the system only over HTTP, Postgres, Redis, and the Docker CLI -- no imports of application code. RED-BASELINE.md records the suite failing red against the empty repo before any implementation existed; README.md documents the request-contract fields invented to fill gaps in the spec, measurement methodology, and known-flaky tests. This suite is a frozen contract from this point forward per CLAUDE.md.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""docker stats / docker compose logs / container lifecycle helpers.
|
||||
|
||||
Per phase-0a-test-suite-spec.md §7. All `docker compose` invocations run with
|
||||
`cwd=REPO_ROOT` so they work regardless of the pytest invocation's own cwd.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from .config import REPO_ROOT
|
||||
|
||||
|
||||
def _run(cmd, timeout=30):
|
||||
return subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
def _compose_ps(service):
|
||||
result = _run(["docker", "compose", "ps", service, "--format", "json"])
|
||||
lines = [l for l in result.stdout.strip().splitlines() if l.strip()]
|
||||
if not lines:
|
||||
return None
|
||||
return json.loads(lines[0])
|
||||
|
||||
|
||||
def stop(service):
|
||||
result = _run(["docker", "compose", "stop", service])
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"docker compose stop {service} failed: {result.stderr}")
|
||||
|
||||
|
||||
def start(service):
|
||||
result = _run(["docker", "compose", "start", service])
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"docker compose start {service} failed: {result.stderr}")
|
||||
|
||||
|
||||
def wait_ready(service, timeout=30.0):
|
||||
"""Poll `docker compose ps` until the container reports healthy (or, absent
|
||||
a healthcheck, running). Raises TimeoutError otherwise."""
|
||||
deadline = time.monotonic() + timeout
|
||||
last_state = None
|
||||
while time.monotonic() < deadline:
|
||||
info = _compose_ps(service)
|
||||
if info:
|
||||
health = info.get("Health", "")
|
||||
state = info.get("State", "")
|
||||
last_state = (state, health)
|
||||
if health == "healthy" or (health == "" and state == "running"):
|
||||
return
|
||||
time.sleep(1)
|
||||
raise TimeoutError(f"{service} not ready within {timeout}s (last state={last_state})")
|
||||
|
||||
|
||||
_MEM_UNITS = {
|
||||
"B": 1 / (1024 * 1024),
|
||||
"KIB": 1 / 1024,
|
||||
"KB": 1 / 1024,
|
||||
"MIB": 1,
|
||||
"MB": 1,
|
||||
"GIB": 1024,
|
||||
"GB": 1024,
|
||||
"TIB": 1024 * 1024,
|
||||
"TB": 1024 * 1024,
|
||||
}
|
||||
|
||||
|
||||
def _parse_mem_to_mb(s):
|
||||
m = re.match(r"([\d.]+)\s*([A-Za-z]+)", s.strip())
|
||||
if not m:
|
||||
raise ValueError(f"cannot parse memory string: {s!r}")
|
||||
value, unit = float(m.group(1)), m.group(2).upper()
|
||||
if unit not in _MEM_UNITS:
|
||||
raise ValueError(f"unknown memory unit {unit!r} in {s!r}")
|
||||
return value * _MEM_UNITS[unit]
|
||||
|
||||
|
||||
def rss_mb(service):
|
||||
"""Current RSS (approximated by `docker stats` MemUsage) of `service`'s container, in MB."""
|
||||
info = _compose_ps(service)
|
||||
if not info:
|
||||
raise RuntimeError(f"container for {service!r} not found -- is the stack up?")
|
||||
container_id = info.get("ID") or info.get("Id")
|
||||
result = _run(["docker", "stats", container_id, "--no-stream", "--format", "{{.MemUsage}}"])
|
||||
mem_str = result.stdout.strip().split("/")[0].strip()
|
||||
return _parse_mem_to_mb(mem_str)
|
||||
|
||||
|
||||
def count_log_lines(service, pattern, since):
|
||||
"""Count lines in `docker compose logs <service>` since `since` matching `pattern`.
|
||||
|
||||
`since` is a timezone-aware datetime or a docker-compatible string (RFC3339
|
||||
or a relative duration like "30s"). This is deliberately the only place
|
||||
that shells out to `docker compose logs` for counting -- see
|
||||
tests/README.md, "Log-counting migration", for why and what replaces it
|
||||
from Phase 3 onward.
|
||||
"""
|
||||
since_arg = since.isoformat() if isinstance(since, datetime) else since
|
||||
result = _run(
|
||||
["docker", "compose", "logs", service, "--since", since_arg, "--no-color", "--no-log-prefix"]
|
||||
)
|
||||
regex = re.compile(pattern)
|
||||
return sum(1 for line in result.stdout.splitlines() if regex.search(line))
|
||||
Reference in New Issue
Block a user