Open ACE Test Structure and CI Strategy
This document is the single source of truth for the project's test classification,
placement, and execution semantics. Before Issue #2429, tests/issues/<number>/
doubled as both the provenance record and the test taxonomy, which mixed tests with
different runtime requirements together and excluded them wholesale from the
default CI.
Design Conclusions
Tests are kept in exactly one copy: the directory answers "what environment it needs to run in", and the marker answers "why it exists and what its priority is". Therefore:
- No top-level
tests/regression/ortests/security/is created. Regression and security are test attributes, not runtime environments. - The same test is never copied into multiple directories such as
unit/orintegration/. Copies drift, execute redundantly, and end up in divergent fix states. - The
tests/issues/legacy quarantine was retired and deleted wholesale with the final #2429 batch and must not be recreated; regression tests go straight into the canonical directories with theregression/issuemarkers. - GitHub issue tracking uses
@pytest.mark.issue(number); defect regressions additionally use@pytest.mark.regression.
Canonical Directories
| Directory | Runtime contract | Default timing |
|---|---|---|
tests/unit/ | In-process, fast, no network/real database/subprocesses/servers | Every PR, required |
tests/integration/ | Crosses database, filesystem, subprocess, or component boundaries; progressively subdivided into sqlite/, postgres/, filesystem/, subprocess/ | Every PR; PostgreSQL on a dedicated lane |
tests/e2e/ | Requires a running Open ACE, a browser, or remote services | Critical subset by path/label; full set on a schedule |
tests/performance/ | Has time or resource thresholds; may be affected by runner noise | Separate non-blocking lane |
Route-boundary tests and concurrency tests have been placed into
tests/integration/routes/ and tests/integration/concurrency/ respectively.
The historical inventory at the tests root and in the autonomous domain has been
fully migrated (#3185); the grandfather inventory was retired with it, and the
layout policy pins the end state (only conftest/__init__ at the root). Do not
add new "per functional domain" top-level directories or test files in the tests
root.
Test database isolation (#2869): the _isolated_unit_db autouse fixture in
tests/unit/conftest.py points every unit test at its own throwaway sqlite
database (DATABASE_URL=sqlite:///<tmp>/unit-test.db), and this holds
unconditionally for any test not marked @pytest.mark.postgres. Tests that go
through create_app therefore no longer share the workspace-level app.db —
previously, a same-named table with an inconsistent column shape left behind by
an earlier test could make a later create_app's schema replay crash at random
(no such column); the fixture eliminates that shared state at the root and
makes the default test-database choice explicit (no silent inheritance of the
development machine's DATABASE_URL). tests/integration/conftest.py already
used a per-test tmp_path database. Tests that need Postgres carry
@pytest.mark.postgres and live under tests/integration/ (executed by the
dedicated postgres lane).
Writing Regression Tests
New regression tests are written directly into their canonical directory, with the origin recorded on the module or the test function:
import pytest
pytestmark = [pytest.mark.security, pytest.mark.regression, pytest.mark.issue(2429)]
Common commands:
# 默认 required suite(与 GitHub Actions 共用定义,并隔离 HOME/数据库环境)
python scripts/ci.py run default-collection python-core
# 按 issue 运行已经迁移到任意规范目录的测试
pytest --issue=2429
# 扩展测试
python scripts/run_extended_tests.py --category e2e --isolated-home
CI Guarantees
Directories alone constitute no guarantee; what the CI consumes is the guarantee:
- The default suite runs deterministic tests such as unit and integration on
the production Python 3.11; the
securitymarker is included in that same required suite. - The Python 3.11 required job runs pytest collection over
tests/and fails immediately on collection errors or an item count below.test-baseline.json. The issue directories that had historically entered the PR gate were long since fully migrated into the canonical layers (#2429 batches 1/2); the subsequentlegacy-prrequired suite,tests/issues/pr-gate-directories.txt, the issue-collection collection gate, and thetests/issues/quarantine tree were retired wholesale with the final #2429 batch; those regressions are now executed automatically, by directory, by thepython-core/python-minrequired lanes. - Critical E2E runs by changed path or label and is not a required check until stabilized; the full E2E runs nightly.
- PostgreSQL and performance use dedicated lanes, so environment requirements are not hidden as skips.
tests/unit/test_test_layout_policy.pyforbids adding numbered directories, top-level functional-domain directories, test files in the tests root, andtests/regression/ortests/security/.
Commands, timeouts, and toolchain versions for all suites come solely from
ci/suites.json; both local runs and GitHub Actions execute through
python scripts/ci.py. The PR matrix divides work by version (#2868):
- 3.11 (production runtime):
python-core— the fullpytest tests/(including integration) plus coverage, run on every non-documents change. This suite's budget was likewise raised from 600s to 1200s due to GitHub runner variance — healthy runs on the same branch have oscillated between 289s and 535s, and a slow runner's tail can blow straight through 600s (#3280). The pytest commands of bothpython-coreandpython-mincarry--timeout 300 --timeout-method thread --durations 20: when a single test hangs, all thread stacks are dumped at the 300s mark and it fails loudly, instead of burning through the whole suite budget and leaving only an undiagnosable "Command exceeded";--durationsmakes the shrinkage of budget headroom visible in the logs before the timeout. - false-positive-scan: the test-code false-positive scan (Issue #2189,
Scope #6), run on every non-documents change (
ci.py select_pr_suitesdefault set + consumed byPR Gate, #3186 Phase A), independent ofpython-coreto avoid timeouts. Known debt is expressed as a precise-identity ledger (ci/false-positive-ledger.json: pattern + file + class-qualified function name) — any new or replaced finding goes red; fixes may only shrink it (--prune-ledgeronly deletes, never adds), and its size is pinned by a contract test (only ever decreasing). The old count-based baseline has been retired. - 3.10 (minimum supported version):
python-min—compileall+ the fullpytest tests/unit/, run on every non-documents change. Version-specific regressions almost always surface on the oldest interpreter first (for example, before 3.11datetime.fromisoformatdid not accept theZsuffix); the old matrix ran only a 7-file smoke on 3.10, letting such unit-level regressions slip through both PRs and post-merge main pushes — one red main went unnoticed for 75 minutes. Running only unit keeps it fast (~2min) and deterministic (no integration flake, no coverage overhead; the suite's budget was likewise raised from 600s to 1200s due to GitHub runner variance — the same commit has oscillated between 183s and 652s (#3240) — but running the fulltests/directly on the slower 3.10 would still stretch wall-clock time and amplify flake). - 3.12, 3.14 (forward compatibility):
compatibility-smoke—compileall+ a small set of critical unit files, selected on dependency changes. Like thepostgreslane it carries--timeout 300 --timeout-method thread --durations 20(#3282): hang protection and slow-test visibility no longer belong to the unit lane alone; theperformancelane deliberately carries no per-test timeout — slow wall-clock benchmarks are the design intent. There is also a non-fatal budget-erosion warning (#3282): when any suite finishes successfully having consumed more than 75% of its budget,scripts/ci.pyprints::warning::...completed in ...s, ...% of its ...s budgetto the log (which also becomes a Checks UI annotation on GitHub Actions) and (when the nightly metrics stream is enabled) records asuite_budget_warningevent — this restores the gradual-slowdown signal after #3281 retired the 600s hard tripwire, letting the shrinkage of budget headroom be seen before it turns into intermittent timeouts.
Both python-min and python-core apply to every code change, so changes to
app/** always truly run the full unit suite on the minimum supported
version. Python 3.13 remains a declared supported version but is not in the
PR matrix. Scheduled workflows run the complete Python suite on 3.10, 3.11,
and 3.14, and carry the E2E and the checks most susceptible to runner noise.
tests/unit/test_ci_runner.py::test_min_supported_python_runs_the_full_unit_suite
locks "the minimum supported version must run the full tests/unit" into the
gate. Post-merge verification of main is carried by the existing
push: [main] (ci.yml and schema-sync.yml); this change makes it
genuinely effective for the minimum version — a red main is therefore an
honest, visible red check on the commit. (Note: the current ruleset does not
enable "require branches up to date", so stale baselines across PRs can
still redden main; that is a separate ruleset setting.)
Before submitting you can run python scripts/ci.py doctor --strict to
verify that the local Python/Node major versions match the PR, then use
python scripts/ci.py pr --base origin/main to select suites under the same
path rules. The PostgreSQL and E2E suites still require their declared
services/browsers to be provided locally. .python-version and .nvmrc are
pinned to 3.11 and 20 respectively, letting tools such as uv/pyenv/nvm
automatically select the same major versions as Actions. requirements-ci.lock
is universally resolved from the minimum supported Python 3.10, and every
local and GitHub test job installs that file. Production dependencies stay
in requirements.txt; the test, lint, build, and audit tooling needed for
development and CI stays in requirements-ci.in and must not be added to the
production-install requirements.txt; the corresponding dev extra must
stay consistent with that input — policy tests automatically check both plus
the production dependency boundary. After changing either input you must
regenerate and commit the lock using the commands in CONTRIBUTING.md.
Successful collection only proves that the tests "exist and import"; it does not prove the assertions are green. Only tests in required lanes count as merge gates.
CI Health Metrics Timing Validity
ci-health-metrics.yml collects on a GitHub schedule and also supports
manual dispatch on the default branch. The collector bounds the time window,
sample size, and API request budget per ci/ci-health-policy.json; when the
report has too few same-contract samples, the p95 must be flagged
insufficient_data and must not be used to promote Critical E2E from
advisory to required.
Report schema/timing-derivation version 2 (#3358) marks negative queue
durations of GitHub workflows/attempts individually as unavailable:
seconds=null, preserving the raw delta, both endpoint timestamps, and the
reason. Such values are neither clamped to zero seconds, nor do they cause
the whole run/attempt or its success/failure/cancelled verdict to be dropped.
The JSON workflow_queue_anomalies and the Markdown provide run/attempt
locating information; queue quantiles use only valid values, and the p95
minimum sample count is judged by the number of distinct runs with valid
queue data — repeated reruns or already-excluded samples cannot pad it.
Consumers reading the report must check schema_version first; queue
statistics from versions 1 and 2 must not be mixed directly.
This tolerance applies only to chronology anomalies in the workflow queue's source data. Missing or malformed timestamps still error out; job queue, job execution, attempt wall, and the like keep the existing fail-closed validation and the small clock-skew allowance of at most 2 seconds, and the inherited/skipped job rules are unchanged.
Legacy Issue Test Migration (Completed and Retired)
#2429 migrated tests/issues/ into the canonical directories in batches
(unit-like → integration → e2e/performance); the final batch (17) moved out
the remaining e2e and deleted the entire quarantine tree, the
legacy-directories.txt inventory, the issue-collection collection gate,
the issue-tests nightly shards, ci/legacy-issue-{quarantine,failures}.json,
and the scripts/legacy_issue_baseline.py comparator. Every promoted test
satisfied:
- it can run independently in a clean checkout;
- it failed before the fix and passed after it, with assertions verifying behavior rather than source strings;
- it writes nothing to the developer HOME, production databases, or fixed remote resources;
- it is discovered automatically by its CI lane, with no per-issue workflow YAML edits.
Baseline
.test-baseline.json records item counts and file counts separately. The
default CI checks the item count with a real pytest collection; the extended
runner's shards can only be allocated per file, so the file baseline is
checked proportionally by split_total.
The baseline is a lower bound against tests silently disappearing, not a coverage metric. Lowering the baseline requires the PR to explain why tests were migrated, deleted, or merged; after adding tests it should be tightened upward periodically.
Update procedure: first run python scripts/ci.py run default-collection to
record the actual item/file counts; then update actual_* in
.test-baseline.json. Lower min_* only when tests are deliberately
deleted, merged, or migrated, and explain why in the PR; when adding tests,
update only actual_* and periodically tighten min_* toward the actual
values. Finally rerun the collection suite. The baseline_runbook field in
ci/suites.json is pinned to this section, so the procedure is discoverable
from the suite inventory.
E2E Governance Baseline (Issue #2491)
The file-level disposition of tests/e2e/, the nodeid-level debt/promotion
state, and the mutually exclusive lane selection are governed by the
pure-stdlib tool family in scripts/e2e/ (carrying over the same "one
implementation for local and CI" pattern as the legacy_issue_baseline of
the retired #2457 failure baseline); governance data lives in
ci/e2e-*.json:
- inventory (
ci/e2e-inventory.json): every.pyunder the managed roots (disk enumeration is authoritative, including helper/demo scripts) must have a unique disposition (pytest-automated | standalone-automated | manual-demo) and a home lane; manual entries haveexecutor=noneand do not count toward automated coverage. Thecollectsflag declares whether the file currently produces pytest nodeids; a collection change is judged as manifest drift rather than silently absorbed. - expected nodeids (
ci/e2e-expected-nodeids.json): derived bypython scripts/e2e/manifest.py snapshotfrompytest --collect-only -q -o addopts=(no server/frontend build needed;-o addopts=cancels pytest.ini's-v, otherwise the output is an unparseable collection tree). Contract:test_e2e_inventory_manifest.py/test_e2e_governance.pyin the required unit lane re-collect live and compare — iftests/e2e's conftest pulls in heavy dependencies or regresses collection, it reddens the PR lane directly; that gate is intentional, not a false positive. - debt / promotion state (
ci/e2e-state.json/ci/e2e-promotion.json): stored keyed by normalized nodeid/entry, defaulting tounclassified + observingwhen missing (the observation lane's starting point); every change must go through the write subcommands ofpython scripts/e2e/governance.pyatomically (the only legitimate write path), with the command line attached to the PR description. - selector:
python scripts/e2e/selector.py --event {pr,nightly,weekly}outputs the four mutually exclusive and exhaustive sets normal/advisory/probe/invalid plus selection.json (--shadowis the record-only mode for P1–P3). - attempt evidence:
pytest -p pytest_attempts --e2e-attempts=<path>records every phase of every attempt (JUnit keeps only the final outcome; seedocs/dev-notes/2491-rerunfailures-junit-probe.md).
A new E2E file must be registered via governance.py set-disposition before
merging into the managed roots; otherwise the inventory completeness check
(both the local and the tests/unit paths) exits non-zero.
Appendix: Local shell prerequisites
ci/suites.json declares Bash minimum major version 4; python-core and
python-min include real subprocess tests of the fetch wrapper that need
Bash associative arrays. python scripts/ci.py doctor reports the Bash path
actually selected by PATH, its version, and its capabilities;
doctor --strict returns non-zero for missing, incompatible, or failed
probes. The strict doctor additionally checks the production Python 3.11 /
Node 20; it is not a launch condition for the Python 3.10 minimum-version
lane.
run / pr check the Bash prerequisites of the whole execution plan before
any selected suite starts, avoiding spending minutes on collection or tests
before an environment problem surfaces. Standalone scans, list, and
detect do not require Bash; running pytest directly bypasses this
entry-precondition check but does not change the tests' own requirements or
passing criteria.
macOS /bin/bash 3.2 does not satisfy the requirement. You can install a
standalone modern Bash and adjust PATH for the current terminal only; there
is no need to replace the system Bash, change the default login shell, or
touch wrapper shebangs:
brew install bash
# 先激活装有 requirements-ci.lock 的环境(重复激活会恢复旧 PATH)
source venv/bin/activate
# 保持 venv Python 第一、现代 Bash 随后,不丢弃其余 CLI 路径
export PATH="$VIRTUAL_ENV/bin:$(brew --prefix)/bin:$PATH"
command -v bash
bash --version
command -v python python3
python scripts/ci.py doctor --strict
python scripts/ci.py run default-collection python-core
Keep the PATH directories that existing dependencies such as the Docker
Compose CLI live in; do not clobber them with a reduced PATH just to select
Bash. The related script tests invoke python3 and docker compose version,
so launching the entry point with the absolute path of the venv Python alone
does not guarantee those subprocesses use the same dependency set.
Probes and suite subprocesses uniformly strip BASH_ENV / ENV, so
developer startup scripts cannot inject into and affect CI; the parent
process environment is unchanged. PATH keeps its original order, and
relative/empty directory entries are converted to absolute paths against the
working directory at invocation time, preventing a different Bash from being
selected after a suite switches working directories. Do not rely on personal
shell startup scripts to provide CI dependencies; prepare PATH and the
locked dependency environment explicitly.
A modern Bash is a necessary condition, not an equivalent guarantee of the
full Linux CI on macOS. The canonical execution environment remains the
Ubuntu 24.04 of ci/suites.json, the lane's Python, the locked
dependencies, and the required services. Cross-platform acceptance should
separately record the OS, the actual Python/Bash versions, the identical
suite commands, the test results, and the GitHub run link; a passing
precondition check or a passing targeted test must not be written up as a
full pass. This change adds no time-consuming macOS PR lane and skips no
wrapper regression.