From 915f93604d45d699e06b2a1519ee2528a1f93e39 Mon Sep 17 00:00:00 2001 From: Anton Razzhigaev Date: Sat, 19 Sep 2026 02:13:01 +0300 Subject: [PATCH] Add scripts/run_tests.py, the one fast local test battery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- CONTRIBUTING.md | 18 ++- Makefile | 7 +- README.md | 3 +- .../08-git-branching-ci-and-build.md | 2 +- docs/development/14-build-and-ci.md | 4 + prompts/SYSTEM.md | 5 +- scripts/run_tests.py | 143 ++++++++++++++++++ tests/conftest.py | 30 ++++ tests/test_plan_author_presentation.py | 5 +- tests/test_run_tests_script.py | 141 +++++++++++++++++ 10 files changed, 349 insertions(+), 9 deletions(-) create mode 100644 scripts/run_tests.py create mode 100644 tests/test_run_tests_script.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5d9b0355c..8d913975d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/Makefile b/Makefile index 10da4302c..c22a84c07 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 5f961c23c..53ee7de5d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/architecture/08-git-branching-ci-and-build.md b/docs/architecture/08-git-branching-ci-and-build.md index 4d7cd1407..7ef19a99e 100644 --- a/docs/architecture/08-git-branching-ci-and-build.md +++ b/docs/architecture/08-git-branching-ci-and-build.md @@ -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/`) diff --git a/docs/development/14-build-and-ci.md b/docs/development/14-build-and-ci.md index 06d09a619..fca62a08c 100644 --- a/docs/development/14-build-and-ci.md +++ b/docs/development/14-build-and-ci.md @@ -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: diff --git a/prompts/SYSTEM.md b/prompts/SYSTEM.md index 855bf5223..70eaba6b6 100644 --- a/prompts/SYSTEM.md +++ b/prompts/SYSTEM.md @@ -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. diff --git a/scripts/run_tests.py b/scripts/run_tests.py new file mode 100644 index 000000000..718bcf93d --- /dev/null +++ b/scripts/run_tests.py @@ -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()) diff --git a/tests/conftest.py b/tests/conftest.py index c3205ba4c..dbd04a6f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_plan_author_presentation.py b/tests/test_plan_author_presentation.py index b3cbb44bd..c9d369dec 100644 --- a/tests/test_plan_author_presentation.py +++ b/tests/test_plan_author_presentation.py @@ -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 diff --git a/tests/test_run_tests_script.py b/tests/test_run_tests_script.py new file mode 100644 index 000000000..b35193806 --- /dev/null +++ b/tests/test_run_tests_script.py @@ -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