ouroboros/scripts/run_tests.py
Anton Razzhigaev 915f93604d Add scripts/run_tests.py, the one fast local test battery
The documented local command (`make test`, CONTRIBUTING §4, README) was
`pytest tests/ -q --tb=short`: the whole default suite (23k tests) in a
single process, behind a stale "smoke tests" comment. An Ouroboros task
followed it, hit its 1200 s timeout and withheld its PR. The correct
recipe — a parallel `not serial` xdist pass, then the serial pass — lived
only inside the hermetic commit gate.

scripts/run_tests.py is the single entry point for every actor. A bare
call always runs the FULL battery: the node lane (a missing or too-old
node is a red NOT_RUN, never a silent skip), then every default-lane
test in ONE xdist run under `--dist loadgroup --serial-shards=N`.
tests/conftest.py gains that option: each serial FILE is pinned to one of
N file-sharded groups (one per four workers, at most four; four cores
keep a single group), so serial tests overlap the parallel ones while a
serial file still never splits across workers or runs beside another
file of its shard; other tests keep the scope loadscope uses. The option
defaults to 0 and is inert for CI and the commit gate, which keep the
two-pass split and stay the authority. `--sequential` keeps the gate's
marker split and xdist flags in two passes; extra arguments forward a
focused run. Marker exclusions and xdist flags are imported from
ouroboros.preflight_runner, never restated.

Measured on an 18-core Mac shared with other agents: the two-pass
battery took 649 s (parallel 203 s + serial 446 s); the one-run battery
took 217–287 s with identical verdicts (22 950 passed).

Also: `make test` now calls the script; CONTRIBUTING, README, the Build
& CI chapter, the architecture map and one sentence of prompts/SYSTEM.md
point at it and say which opt-in lanes it leaves out and what to record
when a full run could not finish; tests/test_plan_author_presentation.py
resolves node through ouroboros.preflight_node like the rest of the
suite instead of a bare PATH `node`; tests/test_run_tests_script.py pins
the recipe parity, the bounded shard count, bare call = full battery,
node NOT_RUN = red, and the grouping semantics.

Not changed: the commit gate and .github/workflows/ci.yml; the three
gate self-tests that wait out real 30/20 s timeouts (their slack exists
for nested-xdist startup on slow runners); no version carrier.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-09-19 02:13:01 +03:00

143 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""Run the local test battery the fast, correct way.
python scripts/run_tests.py full battery (node lane + every default-lane test)
python scripts/run_tests.py --sequential full battery exactly as the commit gate and CI split it
python scripts/run_tests.py tests/test_x.py focused run; every argument is forwarded to pytest
A bare call always means the FULL battery — it never skips a test on the
strength of an earlier run. The default mode is one xdist run in which the
`serial` tests are pinned to a few file-sharded groups (`--serial-shards`,
tests/conftest.py) and therefore overlap the parallel tests instead of waiting
for them; `--sequential` keeps the marker split and xdist flags of
`ouroboros.preflight_runner` and `.github/workflows/ci.yml` in two passes.
The reviewed-commit gate and CI stay the authority: this script is feedback.
The marker exclusions and xdist flags are imported from the gate's SSOT
(`LANE_EXCLUSION_EXPR`, `PARALLEL_PASS_FLAGS`), never restated here. Data-root
isolation is `tests/conftest.py`'s job and is not repeated either.
"""
from __future__ import annotations
import argparse
import os
import pathlib
import shlex
import subprocess
import sys
import time
REPO = pathlib.Path(__file__).resolve().parents[1]
if str(REPO) not in sys.path:
sys.path.insert(0, str(REPO))
_MAX_SERIAL_SHARDS = 4
_PYTEST_EXIT_NO_TESTS = 5
def _workers() -> int:
"""What `-n auto` will resolve to: the operator override, else xdist's own count."""
raw = os.environ.get("OUROBOROS_PREFLIGHT_TEST_WORKERS", "").strip()
if raw.isdigit():
return max(2, int(raw))
try: # xdist prefers physical cores whenever psutil is importable
import psutil
physical = psutil.cpu_count(logical=False)
except Exception:
physical = None
return max(2, physical or os.cpu_count() or 2)
def serial_shards(workers: int) -> int:
"""How many serial groups may run at once: one per four workers, at most four.
Four cores or fewer keep ONE serial group, i.e. serial tests still never
overlap each other there — they only stop waiting for the parallel tests.
"""
return max(1, min(_MAX_SERIAL_SHARDS, workers // 4))
def _with(flags: list[str], option: str, value: str) -> list[str]:
out = list(flags)
out[out.index(option) + 1] = value
return out
def battery_commands(sequential: bool) -> list[tuple[str, list[str]]]:
from ouroboros.preflight_runner import LANE_EXCLUSION_EXPR, PARALLEL_PASS_FLAGS
base = [sys.executable, "-m", "pytest", "tests/"]
if sequential:
return [
("parallel", [*base, "-m", f"not serial and {LANE_EXCLUSION_EXPR}", *PARALLEL_PASS_FLAGS]),
("serial", [*base, "-m", f"serial and {LANE_EXCLUSION_EXPR}"]),
]
overlapped = _with(PARALLEL_PASS_FLAGS, "--dist", "loadgroup")
return [(
"overlapped",
[*base, "-m", LANE_EXCLUSION_EXPR, *overlapped, f"--serial-shards={serial_shards(_workers())}"],
)]
def _run(label: str, argv: list[str], cwd: pathlib.Path) -> int:
print(f"\n=== {label}: {shlex.join(argv[2:] if argv[0] == sys.executable else argv)}", flush=True)
started = time.monotonic()
env = dict(os.environ)
override = env.get("OUROBOROS_PREFLIGHT_TEST_WORKERS", "").strip()
if override.isdigit(): # the gate steers `-n auto` the same way (preflight_runner._preflight_env)
env["PYTEST_XDIST_AUTO_NUM_WORKERS"] = str(max(2, int(override)))
code = subprocess.call(argv, cwd=str(cwd), env=env)
print(f"=== {label}: exit {code} in {time.monotonic() - started:.0f}s", flush=True)
return code
def _node_lane() -> int:
from ouroboros.preflight_node import (
NODE_MIN_VERSION, WEB_DIR, _version_tuple, candidate_node_tests, probe_node_version, resolve_node,
)
files = candidate_node_tests(REPO)
if not files:
return 0
node = resolve_node()
version = probe_node_version(node) if node else ""
floor = ".".join(str(part) for part in NODE_MIN_VERSION)
if not version or _version_tuple(version) < NODE_MIN_VERSION:
found = f"{node} is v{version}" if version else (f"{node} did not answer --version" if node else "no node found")
print(f"\n=== node: NOT_RUN — {found}; the web lane needs node >= {floor}, so this is not the full "
"battery: install node or pass focused targets", flush=True)
return 1
return _run("node", [node, "--test", *files], REPO / WEB_DIR)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--sequential", action="store_true", help="two passes, exactly as the commit gate and CI")
args, pytest_args = parser.parse_known_args(argv)
if not (REPO / "tests" / "conftest.py").is_file():
print(f"{REPO} is not an Ouroboros checkout (no tests/conftest.py)", file=sys.stderr)
return 2
if pytest_args:
if args.sequential:
parser.error("--sequential selects the full two-pass battery; drop it or drop the pytest arguments")
return _run("focused", [sys.executable, "-m", "pytest", *pytest_args], REPO)
started = time.monotonic()
code = _node_lane()
ran = 0
for label, command in battery_commands(args.sequential) if code == 0 else []:
code = _run(label, command, REPO)
if code == _PYTEST_EXIT_NO_TESTS:
code = 0
continue
ran += 1
if code != 0:
break
if code == 0 and ran == 0:
print("no tests were collected in any lane", file=sys.stderr)
code = 1
print(f"\n=== battery: {'GREEN' if code == 0 else 'RED'} in {time.monotonic() - started:.0f}s", flush=True)
return code
if __name__ == "__main__":
sys.exit(main())