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>
This commit is contained in:
Anton Razzhigaev 2026-09-19 02:13:01 +03:00
parent 472f8c5cc7
commit 915f93604d
10 changed files with 349 additions and 9 deletions

View file

@ -107,12 +107,26 @@ base-to-head range, never uncommitted edits.
## 4. Verify the Change
Use [`README.md` → Run from Source](README.md#run-from-source) for setup. Run
focused tests while developing, then the default local suite when practical:
focused tests while developing — you choose the narrowest targets that exercise
the change — then the full local battery when practical:
```bash
make test
python scripts/run_tests.py # or: make test
```
That is the one fast recipe (node lane, then every default-lane test in a single
xdist run); a bare `pytest tests/` runs the same tests in one process and takes
many times longer. Like CI's default lanes it leaves out the opt-in marker
lanes (`size_ratchet`, `browser`, `ui_browser`, `portable_detail`,
`skill_smoke`, `integration`); a change that touches repository size or a
reference-book chapter also runs `python -m pytest tests/ -m size_ratchet`. `python scripts/run_tests.py tests/test_x.py` forwards a
focused run. Inside Ouroboros a reviewed commit (`commit_reviewed` /
`vcs_commit_reviewed`) runs the complete battery in its hermetic gate unless
`skip_tests` is set, so running it by hand first is optional rather than a
second requirement; for a pull request, CI is that gate. If the full battery could
not finish on your machine or within your budget, say so — `NOT_RUN` with the
reason — and deliver the focused evidence; never report it as green.
Record exact commands, outcomes, and producer exit codes. If a check could not
run, record `NOT_RUN` and the reason instead of claiming it passed.

View file

@ -3,11 +3,12 @@
.PHONY: test lint health clean
# Run smoke tests (fast, no external deps needed at runtime)
# Full local battery: node lane + every default-lane test in one xdist run
# (modes and focused runs: scripts/run_tests.py)
test:
uv run --locked python -m pytest tests/ -q --tb=short
uv run --locked python scripts/run_tests.py
# Run smoke tests with verbose output
# Single-process verbose run (slow: the whole default suite in one process)
test-v:
uv run --locked python -m pytest tests/ -v --tb=long

View file

@ -321,7 +321,8 @@ The server binds to `127.0.0.1:8765` by default. Read [`docs/DEPLOYMENT.md`](doc
#### Run Tests
```bash
make test
python scripts/run_tests.py # full local battery (same as `make test`)
python scripts/run_tests.py tests/test_x.py # focused run
```
`pyproject.toml` is the direct-dependency authority and `uv.lock` is the

View file

@ -30,7 +30,7 @@ Tool-schema compatibility has two layers. Fork-safe PR tests build the complete
`claudexor-platform-gate.yml` proves the managed Claudexor runtime on three OSes: a fixture lane (fake harness, offline, $0) always, and a live lane only on explicit API keys — subscription auth stays deliberately out of CI, because an interactive machine-bound token must not enter CI secrets. `dependency-graph.yml` reads `ouroboros/claudexor_runtime_pin.json` and submits that direct runtime relationship to GitHub's dependency graph (runs only on pin/workflow changes on `main`/`ouroboros`, plus manual dispatch; `contents: write` only) without presenting Claudexor as a Python or Node package dependency. The Scorecard workflow (`.github/workflows/scorecard.yml`) runs on `main` pushes and weekly, pins every action by full commit SHA, defaults permissions to read-only, and adds only `security-events: write` + `id-token: write` for SARIF upload and OpenSSF publication. `CODE_OF_CONDUCT.md` owns community rules; `CITATION.cff` owns the software and preferred technical-report citations; `site/paper/index.html` owns the canonical paper landing page; `docs/benchmarks/evidence.json` holds the release-bound public benchmark projection; README remains the claim SSOT (guarded by `tests/test_trust_metadata.py` and `tests/test_public_site_metadata.py`).
Dev-facing release/review scripts: `scripts/run_external_review.py` (dual-lane external review; `--contributor` binding; `READY_FOR_INTEGRATION` is evidence, never merge authority), `scripts/contributor_review_evidence.py` (route-neutral contributor-packet binding), `scripts/run_plan_review.py` (the same engine as `plan_task`; review-exempt dev tool), `scripts/validate_scope_receipt.py`, `scripts/claudexor_platform_smoke.py`, `scripts/fetch_claudexor_runtime.py` (pin SSOT: `claudexor_runtime_pin.json`), `scripts/cleanup_test_pollution.py` (dry-run-first). `site/` is the Vite source of the public pages (`site/scripts/sync-assets.mjs` syncs assets); `skills/telegram/` and `skills/unix_computer_use/` are the bundled skills; `packaging/cli/` holds the CLI wrappers and installers; `packaging/appimage/` the AppRun dispatch + desktop metadata; `packaging/systemd/` the opt-in user unit (§1 Runtime topology owns its no-restart rationale).
Dev-facing release/review scripts: `scripts/run_tests.py` (test battery; DEVELOPMENT Build & CI), `scripts/run_external_review.py` (dual-lane external review; `--contributor` binding; `READY_FOR_INTEGRATION` is evidence, never merge authority), `scripts/contributor_review_evidence.py` (contributor-packet binding), `scripts/run_plan_review.py` (the `plan_task` engine; review-exempt), `scripts/validate_scope_receipt.py`, `scripts/claudexor_platform_smoke.py`, `scripts/fetch_claudexor_runtime.py` (pin SSOT: `claudexor_runtime_pin.json`), `scripts/cleanup_test_pollution.py` (dry-run-first). `site/` is the Vite source of the public pages (`site/scripts/sync-assets.mjs`); `skills/telegram/` and `skills/unix_computer_use/` are the bundled skills; `packaging/cli/` holds the CLI wrappers and installers; `packaging/appimage/` the AppRun dispatch + desktop metadata; `packaging/systemd/` the opt-in user unit (no-restart rationale: §1 Runtime topology).
### System E2E suite (`tests/system_e2e/`)

View file

@ -24,6 +24,10 @@ CI runs the default suite in parallel — `python -m pytest tests/` with `-m "no
- Mark real-process / real-port tests, and tests that mutate process-global state WITHOUT reliable fixture isolation, `@pytest.mark.serial` (or add the file to `_SERIAL_TEST_FILES` in `tests/conftest.py`): under `-n` such a test flakes on kill/reap or port-reclaim timing or crashes its worker, and with `--max-worker-restart=0` a dead worker fails its WHOLE co-located batch as spurious failures in unrelated files.
- Keep every other test parallel-safe so it stays in the fast pass: `tmp_path` (never a fixed path), `monkeypatch.setenv`/`delenv`/`setattr` for environment and attribute changes, no execution-order assumptions. The autouse `tests/conftest.py::_os_environ_isolation` snapshot restores `os.environ` at every test boundary, but monkeypatch stays the rule because it reverses exactly the named change inside the test. A module-global mutation reliably snapshot-and-restored by a fixture may stay in the parallel pass (pattern: `tests/conftest.py::_isolate_workspace_executor_globals`).
### The local battery entry point
`python scripts/run_tests.py` (also `make test`) is the documented local run. A bare call always means the FULL battery — the node lane (a missing node is a red `NOT_RUN`), then every default-lane test — and never skips a test on the strength of an earlier run; extra arguments are forwarded to pytest as a focused run. It imports `LANE_EXCLUSION_EXPR` and `PARALLEL_PASS_FLAGS` from the gate rather than restating them. Its default mode is ONE xdist run under `--dist loadgroup --serial-shards=N` (`tests/conftest.py::_pin_lane_groups`): every serial FILE is pinned to one of N file-sharded groups, so a serial file never splits across workers and never runs beside another file of its own shard, while every other test is grouped by file; N is one per four workers, at most four, so a four-core machine keeps a single serial group. Serial tests thus also get the parallel flags' 300 s per-test timeout, which the gate's serial pass lacks. `--sequential` keeps the gate's marker split and xdist flags in two passes. The option defaults to 0 and is inert for CI and the commit gate, which keep the two-pass split and stay the authority; a failure seen only in overlapped mode is re-checked with `--sequential` before it is believed. Enforcement: `tests/test_run_tests_script.py`.
### The commit gate mirrors the CI split
`ouroboros/preflight_runner.py::run_hermetic_pytest` mirrors CI in one disposable checkout and scrubbed temporary data root: the node test lane (`cd web && node --test tests/*.test.js`, content-keyed — a candidate without web tests never requires node, while an active web suite cannot silently disappear when node is missing), then the same two logical pytest passes (parallel `not serial`, then flag-free `serial`). The browser no-undef check has two layers: the dependency-free acorn walker in that suite (`web/tests/no_undef.test.js`) is the hermetic gate's, and both CI jobs additionally run ESLint's `no-undef` (`web/eslint.config.js`, exact-pinned, installed with `npm ci`) as an independent second opinion — CI-only, never part of the gate. `LANE_EXCLUSION_EXPR` and `PARALLEL_PASS_FLAGS` are executable SSOTs pinned against both CI jobs; the candidate is captured as one hardened worktree-vs-`HEAD` binary diff, and a capture or apply failure is the typed `PREFLIGHT_CANDIDATE_ASSEMBLY` hard block, never a test failure. The `pyproject.toml` `addopts` line is the single home of the per-test timing report (`--durations=25 --durations-min=1.0`), prepended to every argv, so the same slowest-test evidence appears locally, in both CI jobs and in both gate passes. Contributor rules:

View file

@ -211,7 +211,10 @@ need it.
- When a shared contract, format, prompt, route, setting, or lifecycle changes,
I read every reader and writer, update docs, prompts, and tests in the same
diff, keep `docs/ARCHITECTURE.md` rationale in sync for non-obvious
decisions, and run focused tests before review.
decisions, and run focused tests before review. `python scripts/run_tests.py`
is the fast full battery (arguments forward a focused run); a bare
`pytest tests/` is one slow process, and the reviewed commit runs the full
battery itself unless tests are explicitly skipped.
- I preserve my own work: never delete or overwrite a viable result, candidate,
or unique input without a recoverable copy; save a working deliverable as
soon as I have one, then improve copies.

143
scripts/run_tests.py Normal file
View file

@ -0,0 +1,143 @@
#!/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())

View file

@ -13,6 +13,7 @@ import sys
import tempfile
import threading
import time
import zlib
import pytest
pytest.register_assert_rewrite("tests.ui_media_delivery_smoke")
@ -305,6 +306,35 @@ def pytest_collection_modifyitems(config, items): # noqa: ARG001
for item in items:
if pathlib.Path(str(item.fspath)).name in _SERIAL_TEST_FILES:
item.add_marker(pytest.mark.serial)
_pin_lane_groups(items, config.getoption("--serial-shards"))
def pytest_addoption(parser):
parser.addoption(
"--serial-shards", type=int, default=0,
help="With `--dist loadgroup`: pin the serial tests to this many file-sharded xdist groups "
"so ONE run covers both lanes (scripts/run_tests.py). 0 leaves scheduling untouched.",
)
def _pin_lane_groups(items, shards: int) -> None:
"""One-run lane scheduling for `scripts/run_tests.py`; inert (shards == 0) for CI and the gate.
Under `--dist loadgroup` a serial FILE never splits across workers and never runs beside
another file of its own shard, so the shard count bounds how many serial files run at once.
Every other test keeps the scope `--dist loadscope` gives the two-pass recipe (file, or class
when there is one). Serial tests sort first so their long groups are handed out before the queue drains.
It runs inside the tryfirst hook because xdist reads `xdist_group` in the same hook.
"""
if shards <= 0:
return
for item in items:
path = item.nodeid.split("::", 1)[0]
serial = item.get_closest_marker("serial") is not None
# Serial: by FILE (the isolation unit). Others: the scope `--dist loadscope` uses (class when present).
group = f"serial{zlib.crc32(path.encode('utf-8')) % shards}" if serial else item.nodeid.rsplit("::", 1)[0]
item.add_marker(pytest.mark.xdist_group(group))
items.sort(key=lambda item: item.get_closest_marker("serial") is None)
def pytest_sessionstart(session): # noqa: ARG001

View file

@ -8,12 +8,15 @@ import pytest
from ouroboros.outcomes import public_task_result
from ouroboros.task_results import load_plan_review_state, load_task_result
from ouroboros.preflight_node import resolve_node
from tests.test_plan_review_engine import DECK_SPEC, _call, _finding, harness as _harness
from tests.test_preflight_node import requires_node
harness = _harness
pytestmark = pytest.mark.serial
@requires_node
@pytest.mark.parametrize("action", ["finish", "stop"])
@pytest.mark.parametrize("cap", ["1", "2"])
def test_current_plan_author_is_not_an_extra_reviewer(harness, monkeypatch, action, cap):
@ -70,6 +73,6 @@ def test_current_plan_author_is_not_an_extra_reviewer(harness, monkeypatch, acti
assert.equal(pending.state, 'running');
assert.equal(pending.authorDecisionText, group.authorDecisionText);
"""
completed = subprocess.run(["node", "--input-type=module", "-e", script],
completed = subprocess.run([resolve_node(), "--input-type=module", "-e", script],
input=json.dumps(detail), capture_output=True, text=True, timeout=30)
assert completed.returncode == 0, completed.stdout + completed.stderr

View file

@ -0,0 +1,141 @@
"""The local battery entry point keeps the gate's recipe and a bounded serial overlap."""
import importlib.util
import pathlib
import types
import pytest
from ouroboros.preflight_runner import LANE_EXCLUSION_EXPR, PARALLEL_PASS_FLAGS
from tests import conftest
REPO = pathlib.Path(__file__).resolve().parents[1]
@pytest.fixture(scope="module")
def run_tests():
spec = importlib.util.spec_from_file_location("run_tests_script", REPO / "scripts" / "run_tests.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _item(nodeid, *, serial):
markers = [types.SimpleNamespace(name="serial")] if serial else []
item = types.SimpleNamespace(nodeid=nodeid, added=[])
item.get_closest_marker = lambda name: next((m for m in markers if m.name == name), None)
item.add_marker = item.added.append
return item
def _group(item):
(mark,) = item.added
assert mark.name == "xdist_group"
return mark.args[0]
@pytest.mark.parametrize("workers, shards", [(2, 1), (4, 1), (7, 1), (8, 2), (12, 3), (16, 4), (64, 4)])
def test_serial_overlap_is_bounded_by_the_worker_count(run_tests, workers, shards):
assert run_tests.serial_shards(workers) == shards
def test_sequential_mode_keeps_the_gate_marker_split_and_xdist_flags(run_tests, monkeypatch):
monkeypatch.setenv("OUROBOROS_PREFLIGHT_TEST_WORKERS", "6")
(parallel_label, parallel), (serial_label, serial) = run_tests.battery_commands(sequential=True)
assert (parallel_label, serial_label) == ("parallel", "serial")
assert parallel[3:] == ["tests/", "-m", f"not serial and {LANE_EXCLUSION_EXPR}", *PARALLEL_PASS_FLAGS]
assert serial[3:] == ["tests/", "-m", f"serial and {LANE_EXCLUSION_EXPR}"]
def test_default_mode_is_one_run_over_both_lanes(run_tests, monkeypatch):
monkeypatch.setenv("OUROBOROS_PREFLIGHT_TEST_WORKERS", "8")
((label, command),) = run_tests.battery_commands(sequential=False)
assert label == "overlapped"
assert command[:3] == [run_tests.sys.executable, "-m", "pytest"]
command = command[3:]
assert command[command.index("-m") + 1] == LANE_EXCLUSION_EXPR
assert command[command.index("--dist") + 1] == "loadgroup"
assert command[command.index("-n") + 1] == "auto"
assert command[-1] == "--serial-shards=2"
untouched = [flag for flag in PARALLEL_PASS_FLAGS if flag.startswith("--") and flag != "--dist"]
assert all(flag in command for flag in untouched)
def test_the_worker_override_steers_xdist_like_the_gate(run_tests, monkeypatch):
seen = {}
monkeypatch.setenv("OUROBOROS_PREFLIGHT_TEST_WORKERS", "3")
monkeypatch.setattr(run_tests.subprocess, "call", lambda argv, cwd, env: seen.update(env=env) or 0)
assert run_tests._run("x", [run_tests.sys.executable, "-m", "pytest"], run_tests.REPO) == 0
assert seen["env"]["PYTEST_XDIST_AUTO_NUM_WORKERS"] == "3"
def test_sequential_with_pytest_arguments_is_refused(run_tests, monkeypatch):
monkeypatch.setattr(run_tests, "_run", lambda label, argv, cwd: pytest.fail("nothing must run"))
with pytest.raises(SystemExit) as exit_info:
run_tests.main(["--sequential", "tests/test_x.py"])
assert exit_info.value.code == 2
def test_a_bare_call_never_means_a_subset(run_tests, monkeypatch):
calls = []
monkeypatch.setattr(run_tests, "_node_lane", lambda: 0)
monkeypatch.setattr(run_tests, "_run", lambda label, argv, cwd: calls.append((label, argv)) or 0)
assert run_tests.main([]) == 0
assert [label for label, _ in calls] == ["overlapped"]
calls.clear()
assert run_tests.main(["tests/test_x.py", "-k", "one"]) == 0
assert calls == [("focused", [run_tests.sys.executable, "-m", "pytest", "tests/test_x.py", "-k", "one"])]
def test_a_missing_node_is_a_red_not_run_never_a_silent_skip(run_tests, monkeypatch):
monkeypatch.setattr(run_tests, "resolve_node", lambda: None, raising=False)
monkeypatch.setattr(run_tests, "_run", lambda label, argv, cwd: pytest.fail("pytest must not start"))
from ouroboros import preflight_node
monkeypatch.setattr(preflight_node, "resolve_node", lambda: None)
monkeypatch.setattr(preflight_node, "candidate_node_tests", lambda repo: ["tests/x.test.js"])
assert run_tests.main([]) == 1
def test_a_red_lane_stops_the_battery_and_an_empty_battery_is_red(run_tests, monkeypatch):
monkeypatch.setattr(run_tests, "_node_lane", lambda: 0)
codes = iter([1])
seen = []
monkeypatch.setattr(run_tests, "_run", lambda label, argv, cwd: seen.append(label) or next(codes))
assert run_tests.main(["--sequential"]) == 1
assert seen == ["parallel"]
monkeypatch.setattr(run_tests, "_run", lambda label, argv, cwd: 5)
assert run_tests.main(["--sequential"]) == 1
def test_lane_groups_pin_serial_files_and_keep_other_files_whole():
items = [
_item("tests/test_a.py::test_one", serial=False),
_item("tests/test_s.py::test_one", serial=True),
_item("tests/test_a.py::test_two", serial=False),
_item("tests/test_s.py::test_two[x]", serial=True),
_item("tests/test_t.py::test_one", serial=True),
_item("tests/test_c.py::TestBox::test_one", serial=False),
_item("tests/test_s.py::TestSer::test_three", serial=True),
]
conftest._pin_lane_groups(items, 2)
assert [item.nodeid.split("::")[0] for item in items[:4]] == ["tests/test_s.py", "tests/test_s.py", "tests/test_t.py", "tests/test_s.py"]
groups = {item.nodeid: _group(item) for item in items}
assert groups["tests/test_a.py::test_one"] == groups["tests/test_a.py::test_two"] == "tests/test_a.py"
assert groups["tests/test_c.py::TestBox::test_one"] == "tests/test_c.py::TestBox"
assert groups["tests/test_s.py::TestSer::test_three"] == groups["tests/test_s.py::test_one"]
assert groups["tests/test_s.py::test_one"] == groups["tests/test_s.py::test_two[x]"]
assert {groups["tests/test_s.py::test_one"], groups["tests/test_t.py::test_one"]} <= {"serial0", "serial1"}
def test_lane_groups_are_inert_without_the_option():
items = [_item("tests/test_s.py::test_one", serial=True), _item("tests/test_a.py::test_one", serial=False)]
conftest._pin_lane_groups(items, 0)
assert [item.added for item in items] == [[], []]
assert [item.nodeid for item in items] == ["tests/test_s.py::test_one", "tests/test_a.py::test_one"]
def test_the_documented_command_is_the_script():
makefile = (REPO / "Makefile").read_text(encoding="utf-8")
assert "test:\n\tuv run --locked python scripts/run_tests.py\n" in makefile
for doc in ("CONTRIBUTING.md", "README.md", "docs/development/14-build-and-ci.md", "prompts/SYSTEM.md"):
assert "scripts/run_tests.py" in (REPO / doc).read_text(encoding="utf-8"), doc