Testing
The platform's tests share one harness library, one discovery runner, and one web dashboard. Every test declares a small metadata header that says what it touches and where it may run; the runner reads that header to decide what to execute, and both the CLI and the dashboard consume the same result contract.
The two axes: tier and env
Every test declares a tier (blast radius — what it touches) and an env (where it is allowed to run). They are separate on purpose.
tier — drives which batch a test runs in:
| tier | meaning |
|---|---|
safe | Pure, mocked, or rolled back. No persistent side effects. |
db | Writes the dev database and self-cleans. |
test-db | Runs against the copied test database (DbConnector::set_test_mode()). |
live | Real external effects — sends mail, hits a storage bucket, uses Stripe test keys, drives a remote host. |
deploy | Runs on a deployed node after an upgrade swap, with a rollback hanging on the result. Reads only, and assumes no repository. |
| env | meaning |
|---|---|
any | Read-only or pure; safe even on production. |
prod-verify | Mutative but deliberately prod-runnable and self-cleaning — its job is to verify the production environment itself (real deliverability sends, a bucket round-trip, the inbound loopback). Never part of a batch run. |
dev-only | Writes fixtures, trips rate limits, uses the test database, or uses Stripe test keys. Hard-blocked when the debug setting is off. |
dev-only test when debug is off, and the harness re-checks at runtime, so
invoking a test directly by path offers no bypass. A missing or unparseable
env fails closed to dev-only.The metadata header
Every runnable test (.php or a .sh gate) carries a header comment. It is
read without executing the file, so the runner can enforce tiers before
running anything:
/** @joinery-test
* name: cloud_offload_engine
* tier: safe # safe | db | test-db | live
* env: dev-only # any | prod-verify | dev-only
* needs: [] # e.g. [stripe-test-keys, macmini, mailgun, b2, rust, curl, chrome, test-cloud, agent-source]
* covers: [] # optional repo-relative globs the suite reaches WITHOUT loading
* timeout: 180 # optional wall-clock cap in seconds (default 180, max 1800; 5400 for a live suite)
* parallel: true # optional, safe tier only: runs beside other suites
*/parallel: true puts a safe-tier suite in a pool that runs three at a time
ahead of the serial batch. Declare it only for a suite that shares nothing
written: no database writes (not even rolled-back ones), no site settings, no
mail, no fixed file paths (temp names carry getmypid() or random bytes), no
servers or ports, and no assertion on a machine-wide quantity such as free disk
space. Two parts of that are enforced. The harness makes a parallel suite's
database session read-only, so a write fails on the spot, and skips the shared
mail and fixture cleanup passes. The runner fails any suite outside the safe
tier that declares it. --serial and --only run parallel suites serially.
Set timeout: only when a suite genuinely needs longer than the 180-second
default — a multi-minute build gate or a live third-party flow. The runner kills
a test that exceeds its cap and marks it failed. Tests that explicitly declare a
timeout beyond the web request window run from the CLI only (see the dashboard
section).
A file that looks like a test (*_test.php, test_*.php, or a *_gate.sh) but
has no header is listed as undeclared in the runner's summary — never
silently skipped.
Running tests
From the command line:
php tests/run.php # the safe tier
php tests/run.php db # safe + db + test-db — the pre-deploy gate
php tests/run.php test-db # only the test-database suites
php tests/run.php live # only live tests (never implied)
php tests/run.php deploy # does the deployed code run here — what upgrade.php runs
php tests/run.php --changed # only the suites the edited files can reach
php tests/run.php db --changed # the same narrowing, over the db gate
php tests/run.php db --changed=origin/main # edited + everything different from a ref
php tests/run.php db --filter=api # narrow by name or path substring
php tests/run.php db --serial # disable the test-db lane overlap (fully serial)
php tests/run.php --only=tests/unit/dns_resolver_test.php # one exact test by repo-relative path
php tests/run.php db --timeout=30 # override every test's declared wall-clock cap (seconds)
php tests/run.php --list # list discovered tests, run nothing
php tests/run.php --json # emit the aggregate JSON contract (for CI)
php tests/run.php db --fresh # run every selected suite; borrow no earlier result
php tests/run.php db --no-wait # exit 3 at once if another run holds the gate
php tests/run.php --help # the usage text; runs nothingAn option the runner does not declare is refused with the usage text and exit
2, never ignored: a misspelled --onyl= must not quietly run the whole safe
tier in place of the one test that was meant. --only takes one path; a comma
list matches nothing and the run says so.
safe, db and test-db are cumulative, so php tests/run.php db is the
pre-deploy gate and covers the model CRUD suite. live runs only when named and
never pulls in the others — it has real external effects, so it is always an
explicit choice. Each test runs in its own subprocess, so a fatal in one file
cannot take down the run. The runner exits non-zero if any test failed — it is
the CI entry point.
When a run selects both test-db suites and anything else, the test-db
suites run as one serial lane alongside the main batch, hiding their wall clock
inside it. The overlap is safe because those suites write only to the copied
test database — every write goes through DbConnector::set_test_mode(), and
harness_finish() fails any test-db-tier suite whose process never entered
test mode, so the isolation is enforced rather than assumed. The suites stay
serial within the lane (they share the copy), lane results carry a
[test-db] tag in the output, and a lane failure — including a crash of the
lane worker — fails the gate. --serial forces the fully serial order, for
debugging or as a fallback.
Every PHP suite shares one compile cache ({site root}/cache/tests/opcache),
checked against each file's modification time on every include. Temp
directories and the site's cache/ are excluded, because suites write PHP
there and rewrite it within the same second. The deploy tier does not use it.
--changed: run what the change can reach
--changed narrows a run to the suites that can actually be affected by what
is edited (git status: staged, unstaged, and untracked; --changed=<ref>
adds everything different from the ref). It never changes what the full gate
means — php tests/run.php db with no flags remains the complete pre-publish
gate.
How a suite's reach is known. A PHP suite reaches code by loading it, so
its result contract carries the repo files its process included (files in
the JSON contract). Every run folds those into the coverage map at
{site root}/cache/test_coverage_map.json, so the map is never older than
the last time each suite ran, and a full run refreshes all of it. Files a
suite reaches without loading them — a manifest it reads, a script it runs,
the code a shell gate builds — are declared in its header as covers: globs
(** crosses directories, * stays within one segment); a shell gate's
covers: is its whole declaration.
Finer reach: the functions a suite ran. Where the PHP extension xhprof is
loaded (on dev: sudo apt install php8.3-xhprof; nothing installs it
elsewhere, and a box without it is simply file level), the runner starts each
PHP suite with tests/lib/function_trace.php prepended
(-d auto_prepend_file), which turns on xhprof's function trace before the
suite's first line. The contract then also carries functions: every
repository function and method the process ran. The runner stores each
distinct list once, under {site root}/cache/test_fnsets/<id>.json; a result
and a coverage map entry carry only its id (functions_set). The trace costs
a small suite about 5-10% and a long one nothing measurable.
Selection rules. A suite in the requested batch runs when any of these holds, and the summary names which:
- the map has no entry for it — unknown means run, never skip;
- its own file changed, or a fixture in its area (
tests/X/fixtures/selects the suites undertests/X/;tests/fixtures/selects everything); - a changed file is in its recorded
filesor matches itscovers; - it drives the web server (
needs: [dev-web]) and a core directory (includes/,data/,logic/,views/,adm/,api/,ajax/,theme/,serve.php) or its own plugin changed — Apache-side reach is not in the process's include list.
HEAD, and
against the ref too under --changed=<ref>) selects nothing through the code
that loads or runs it: a suite's recorded files, the web-server rule, and
the harness rule below all pass over it, and it is not listed as uncovered.
It still selects where its text is the subject — the suite's own file (the
@joinery-test header is a comment), a fixture, a covers: glob. Only PHP
counts; a comment in any other file may be what something reads. The summary
names such files on a line under Changed:, and the JSON selection lists
them as comment_only.An edit inside function bodies reaches only the suites that ran them.
When a changed PHP file differs only inside the bodies of its functions and
methods, a suite that loaded it is selected only if its function set holds
one of the changed functions, and the reason says which (`runs
sessioncontrol::check_permission (public_html/includes/SessionControl.php)`).
Every other kind of change selects every suite that loaded the file, as
above: anything outside a body (top-level code, class headers, constants,
properties, attributes, closures at file scope), a global function added,
removed or renamed, a method added or removed (code probes for optional
methods by name — method_exists($profile, 'backupRow') — so a method
appearing or disappearing can change a suite that never ran one of that
name), and a method's signature changed (an override that no longer matches
breaks its class at load time). A suite with no function set — untraced, or
its set pruned — is file level. Names compare in lower case without their
namespace; a trait's methods, and those of an anonymous class at file scope,
compare by method name alone, since the trace reports them under the class
that uses them. A closure belongs to the function whose body holds it.
A change to tests/run.php, tests/lib/harness.php, tests/lib/coverage.php,
tests/lib/discovery.php, tests/lib/gate.php or tests/lib/function_trace.php
runs the whole batch: the harness changing invalidates every recorded reach,
and no suite loads the runner, the selection, the discovery or the gate, so
reach cannot see them. Every other file under
tests/lib/ — a fixture library, the HTTP or logic helpers — is loaded by the
suites that use it and recorded in their reach, so it selects only those. A
stale or missing map can therefore only widen a run, never wrongly narrow it.
What the output says. The summary states how many changed files selected
how many suites and why each ran; the aggregate JSON carries the same as
selection. Changed code files that no suite in the estate reaches are
listed as uncovered — a coverage gap named at the moment someone is looking
(markdown, specs/ and docs/ are never listed). A --changed run that
selects nothing exits 0: a docs-only change is legitimately green.
--changed refuses the live and deploy tiers (real effects and deployed
nodes are never silently narrowed), refuses --only (which already names its
test), and refuses to run without git and a repository.
One run at a time: the gate
Several agents share one box, and each runs the gate before checking in. A gate is a three-wide pool plus the test-database lane plus whatever a suite compiles, so two of them at once is more than four cores carry: the box stops answering, and the overlap is also where most flaky reds come from (two runs sharing one database and one tree). So the box runs one at a time.
A run selecting more than ten suites takes an exclusive lock on
{site root}/cache/tests/gate.lock before its first suite and holds it until
its last result is in. A second such run prints who holds the gate and how
far along it is, then a progress line every thirty seconds, and starts the
moment the lock frees:
Gate held by public-html-41 (pid 3408152): db --changed, 212/711 suites done, 0 failed, held 4m10s. Waiting (340 suites to run, more than the cutoff of 10)...
still waiting (0m30s): db --changed, 260/711 suites done, 0 failed, held 4m40s
Gate acquired after 6m02s.The kernel owns the lock, so a killed runner frees it at once and nothing
needs cleaning up. gate.holder.json beside the lock names the holder — pid,
tier, arguments, and the Claude session it runs under, resolved to the name
the work launcher gave it — and is believed only while the lock is held.
--no-wait exits with code 3 instead of waiting, for a caller that would
rather come back later.
What does not queue:
- A run of ten suites or fewer — a
--only, a narrow--filter, a--changedthat reaches little. It runs at once, beside whatever holds the gate, so the working loop never waits behind a full run. The cutoff isGATE_QUEUE_CUTOFFintests/lib/gate.php. - The
deploytier, which runs once on a node after an upgrade swap with a rollback hanging on the result. - Dashboard runs, which are one suite per request and so always below the cutoff. The dashboard shows the holder line while a gate runs.
A test of the runner spawns runners while the real gate is held:
JOINERY_GATE_DIR relocates the lock and the run records, and
JOINERY_GATE_CUTOFF lowers the cutoff so a one-suite run exercises the
wait. Both are read only when the debug setting is on.
Run records and reuse
Every run leaves a record under {site root}/cache/tests/runs/: {run_id}.json
written at the start (tier, arguments, holder, the tree id) and
{run_id}.jsonl, one result per line as it lands (which is what a waiting
runner reads for the holder's progress). The tree id is the git tree of the
working tree as it stood when the run started — every tracked and untracked,
non-ignored file under the repository root — made with git add -A into a
temporary copy of the index and git write-tree, so the shared index and
every prepared-commit index stay untouched. Records are kept for 48 hours.
Each result also says whether its reach is complete: the files the
suite's process loaded (the same list the coverage map records) plus its
covers globs are a complete statement of what the result depends on. That
is true of a PHP suite that reported its files and of a shell gate, whose
covers is its whole declaration. It is false for a crash or timeout, for a
suite with needs: [dev-web] (Apache-side reach is not in its include list),
and for live, prod-verify and deploy suites, which are never reused.
A run then borrows a suite's result from a record instead of running it when all of these hold:
- the recorded reach is complete and the result is a pass. A fail is never borrowed: a red suite is re-proven on the current tree, which is also what lets a rerun alone tell a flaky suite from a broken one. A skip, crash or timeout is never borrowed either;
- nothing in that reach differs between the record's tree and this run's —
one
git diff-treeper record answers it for every suite at once, and the decision is the--changedselection applied to that diff, so the two can never disagree about what a change reaches (comment-only and function-level PHP differences included); - the record's debug environment matches.
reused_from: run id, start time, holder), and the summary says how many
came from where: PASS booking_flow 61/61 8104ms plugins/bookings/tests/booking_flow_test.php [reused: 2026-10-14T18:40Z by public-html-41 (pid 3408152)]
...
Reused: 379 of 711 results from 6 earlier runs (20261014T185421Z-3408152, 20261014T192432Z-3718766, ...); 332 run now
Not borrowed: 332: loads public_html/plugins/server_manager/data/managed_nodes_class.php
Run record: 20261014T192632Z-3720986 (held the gate, waited 1m26s)The Not borrowed line says what blocked reuse, most suites first, in the
words the --changed selection uses. The example is a real run: one data
class edited by another session between the two runs, and every suite that
boots the model layer loads every data class, so a data class change
invalidates most of the estate — for reuse exactly as for --changed.
A run that waited for the gate decides reuse again once it holds it, so everything the previous holder completed is on offer. A borrowed result does not touch the coverage map — the map already holds that reach from the run that produced it — and a record holds only what its own run ran, so a borrow always points at the run that produced the result.
A result's function set travels with it: a borrowed result is spared by an
edit inside function bodies exactly as --changed would spare the suite.
Sets the coverage map does not name are pruned with the records, after 48
hours; a record whose set is gone is file level.
What reuse does not know: it knows what a suite loaded and ran, not what the
database held, what a setting was, or what another process wrote while the
suite ran. That is the assumption --changed already makes when it skips a
suite, and a result that was wrong for one of those reasons stays wrong when
borrowed. --fresh looks at no record, and *the pre-publish gate is
php tests/run.php db --fresh: a release proves the tree it ships, on the
box, now. Reuse is for the working loop and the pre-checkin gate, where the
question is "did my change break something".
deploy is not a development tier
safe, db and test-db all run in a checkout, and their tests are entitled
to assert things about one: that every first-party plugin is present, that the
components manifest matches the repository, that maintenance_scripts has the
layout the installer expects. That is what env: any has always meant in
practice — safe on any development environment.
A deployed site is not a checkout. It carries the plugins it uses and no others,
keeps its own themes, and has no repository around it. Eleven safe suites fail
on a production node for reasons that say nothing about the release, so safe
must never be pointed at one — upgrade.php did that once and rolled back a
perfectly good deploy on getjoinery.
deploy is the tier that may run there. It pulls in nothing and nothing pulls it
in. Three rules for anything added to it:
- Assume no repository — not the plugin set, not the theme set, not a git checkout, not a sibling directory.
- Reads only. It runs on production, after a swap, with a rollback on the result.
- An unreachable dependency is a SKIP, not a failure. Reverting a working release because a socket would not open is the worse error.
deploy may run as root. The development tiers prove things about
installers and sysadmin tools by running them inside sandboxes — a stubbed
systemctl on PATH, a temp directory standing in for /etc, an installer
expected to stop at its own root check. Those sandboxes hold only for an
unprivileged user; root walks through every one of them and the test performs
the real operation on the machine running the suite. tests/run.php refuses any
tier other than deploy when its effective user is root, and says so. Run the
development tiers as the site's user.See Deploy and Upgrade for what it currently checks.
What each tier costs
The working loop is a scoped run: php tests/run.php --changed executes only
the safe suites the edited files can reach — typically seconds. Pre-checkin,
php tests/run.php db --changed does the same over the full gate's batch.
The complete gate, php tests/run.php db --fresh, is the pre-publish proof:
about 810 suites in roughly 35 to 40 minutes on the dev box, half of it in the
90 shell gates (the test-db lane hides inside the db batch's wall clock).
Without --fresh a run borrows whatever earlier results still hold, so a full
db run takes anywhere from seconds to that, depending on how widely loaded
the edited files are.
Most suites are cheap — over 200 finish in under a second each. The expensive ones are expensive for a legible reason — they drive a real subsystem end to end rather than a unit of one:
| test | tier | ~time | what it drives |
|---|---|---|---|
sync_sim | safe | 25s | a real cargo test of the sync simulator and soak rig (covers: [sync/**], so a scoped run pays it only when sync/ changed) |
models_crud | test-db | 19s | CRUD against every data class in the platform |
restore_roundtrip | db | 13s | five real PostgreSQL dump/restore round trips |
plugin_sync | db | 11s | a full PluginManager::sync() over every active plugin, twice (the idempotence check) |
multi_models_crud | test-db | 10s | every Multi collection's filter surface |
drive_encryption | db | 10s | the real chunked encrypted upload pipeline |
booking_flow | db | 8s | the whole booking subsystem, availability through checkout |
tier: it has earned.Those are the costs of running a suite. A pre-checkin gate pays them only for the suites whose reach differs from a recorded run (see Run records and reuse); two sessions whose trees differ in files most suites never load share most of one run between them.
Within each lane the runner executes tests one at a time. Most of the db
gate's wall clock is spent waiting on the database rather than on CPU, so its
duration is closer to the sum of its parts than to the work it does.
The test-db suites declare needs: [test-db], and the runner probes the
connection itself rather than trusting the setting. An install without the
database copy skips them with a named reason instead of failing the gate; see
The test database.
A run that matches zero declared tests exits non-zero (code 2) with an
explicit message, rather than reporting a hollow green — a --filter/--only
typo or an empty tier is almost always a mistake, and the gate must not pass on
nothing.
An unrecognized or mistyped tier: in a header resolves to live, not safe:
safe runs on every php tests/run.php, so defaulting an unknown tier there
would be fail-open (a tier: Live typo could fire a real-effect suite in the
pre-deploy gate). live never runs unless named, so the typo fails safe — the
test simply does not run until its header is corrected. env fails closed the
same way, defaulting to dev-only.
A test's needs are enforced: before running, the runner probes each declared
dependency (macmini, node, rust, curl, chrome, stripe-test-keys, mailgun, b2, …). An unmet
need makes the test a reported SKIP with its reason — never a silent pass and
never a hard failure — so a box without the dependency stays green honestly. An
unrecognized need name is treated as met (it never blindly skips).
Live gates that need a rig, and why they fail rather than skip
A live gate never runs unless it is named, so it is entitled to assume that
somebody who named it meant to run it. Several therefore need configuration —
credentials, a host, a rig — and the rule for all of them is the same: a
missing rig is a failure that names what to set, not a skip. A gate that passed
because it was never configured is the silent success these gates exist to catch.
needs: is different, and is enforced by the runner rather than the gate: an
absent toolchain or an unreachable machine is a reported SKIP with its reason.
The distinction is between "this box cannot run it" (skip) and "this box could
run it and nobody said against what" (fail).
tests/functional/sync/sync_soak_gate.sh is the current example. It wants
JD_SOAK_SERVER, JD_SOAK_ACCOUNT and JD_SOAK_PASSWORD, refuses a server that
looks like dev, builds the workspace, links two devices, runs a real
storm-and-settle cycle against two real daemons, and checks that the actors
actually worked and that a daemon was actually killed before it believes the six
assertions that follow. See
Drive Sync § The soak environment.
Any single test also runs on its own and prints a human summary, or emits the
contract with --json:
php tests/unit/dns_resolver_test.php
php tests/unit/dns_resolver_test.php --jsonThe dashboard
/tests/ (superadmin, permission 10) lists every discovered test grouped by
tier with its env and needs, plus per-tier "Run all" controls, and names the
run holding the gate while one is running. Runs execute
through the tests_run API action, which spawns the runner with --json;
page JS calls /api/v1 with the browser-session credential. On production
(debug off) dev-only tests render locked; live and prod-verify tests
require a confirm naming the side effect before they run.
"Run all" runs a tier's tests one request at a time, client-side, rendering each
result as it lands — so no single request spans a whole tier and results are
never lost to a proxy timeout. It runs exactly the tests the section shows (the
CLI keeps cumulative safe+db semantics; the dashboard does not). A test that
declares a timeout: beyond the web request window renders a disabled CLI
button — run it via php tests/run.php.
Writing a test
Require the shared harness, declare the header, build sections of assertions, and finish. The harness bootstraps PathHelper, Globalvars, SessionControl, DbConnector, and LibraryFunctions for you.
<?php
/** @joinery-test
* name: my_feature
* tier: db
* env: dev-only
* needs: []
*/
require_once(__DIR__ . '/../lib/harness.php'); // path is relative to your test
harness_boot();
section('Creation');
$user = make_user('MyFeature'); // auto-cleaned at finish
check($user->key > 0, 'user was created');
ok('email is set', $user->get('usr_email') !== ''); // label-first alias
section('Behaviour');
harness_set_setting_mem('some_flag', '1'); // in-memory only, auto-restored
check(do_the_thing() === 'expected', 'thing does the thing');
harness_finish(); // MUST be last — prints/emits + exitsThe assertion surface:
check($condition, $label, $detail = '')— condition-first.ok($label, $condition, $detail = '')— label-first alias (same recorder).section($title)— group the checks that follow.harness_skip($label, $detail = '')— a skipped check (e.g. an unmetneeds). The runner's line for the test shows it,125/125 (1 skipped), so a proof that did not run is visible without reading the totals.
make_user($suffix, $permission = 0)— a test user, registered for cleanup. The generated email carries a per-run random token, so leftovers from a killed run can never collide with the next run's same-suffix fixture. Every call is a new user: a suffix used before in the run gets a numbered address (harnesstest_<suffix>_2_<token>@…), since no two accounts may share one.harness_fixture_email($label)— the address a fixture should use when it needs a real, deliverable one. Use it instead of writing an address by hand: the shared shape is what stops a leftover from colliding with the next run, and what lets teardown recognise the mail that address received.harness_unique_png()— a valid 1x1 PNG whose bytes no other file on the box shares. Stored files are deduplicated by content, so a fixed picture lands on whatever blob already holds it, which on a database restored from another machine may sit in a bucket this box cannot reach. Use it for any picture a test stores and reads back.make_machine_key($user_id, $name, $permission = 4)— an API key.harness_register_row($table, $pkey_col, $id)/harness_register_user($user)/harness_register_key_id($id).harness_defer(callable)— any custom teardown.
foreign_key in the field spec, see
Deletion System) cascade to the children. Never assume a
cascade that is not declared. The referential_integrity test (tier safe)
fails the gate whenever a run leaves orphan rows, stray harnesstest_% users,
or a serial sequence behind its table's MAX(pkey) — a leak surfaces in the
very next run with the table named, not later as flakiness in another suite.A run killed outright (SIGKILL — a timed-out suite past its grace period, an
interrupted gate) never reaches teardown, so its fixtures strand. The harness
collects those itself: harness_cleanup_stale_fixtures() runs at every
db-tier suite's boot and reclaims fixture rows older than two hours — no live
run can own them, since no suite outlives its timeout cap (30 minutes, 90 for a
live suite) — deleting
through the models so cascades hold. Debug-gated, so it never runs on
production. The referential_integrity gate itself stays read-only; a red
there points at the run that just happened, and a failed fixture teardown
also fails the suite that leaked, right where the leaker is named.
Its sibling tables_without_model (tier safe) fails the gate when a live
table is declared by no model and is not on its short list of tables that
stay outside on purpose (reference data, one recovery artifact), each with
its reason. A table no model declares lives outside update_database, the
deletion engine and the validator — a new one is a decision: give it a model
(docs/example_class.php), or list it there with why.
It also names surviving rows in the fixture families that label themselves.
Name any standalone fixture HarnessTest <something> in the table's name column
and a leak reports the table and the offending row instead of waiting to be
noticed as a phantom entry in an admin screen. Where the value cannot hold a
space, the label is a prefix instead: mailbox domains start harnesstest-
(ied_domain), mailbox aliases and IMAP account usernames start harnesstest_
(iea_alias, iia_username). The families are one list,
harness_named_fixture_families() in tests/lib/harness.php, which both the
gate and the boot-time reclaim read. A table goes on it the first time a suite
names a fixture there: the gate scans the suites for 'HarnessTest values and
fails on a name column the list does not cover, since a killed run's rows in
such a table would never be reclaimed. The list is in reclaim order, a row
before whatever it points at (a node before its host and backup target, backup
history before its target), and the mailbox families come before the users,
because a fixture user who is the sole member of a protected mailbox cannot be
deleted while that mailbox stands — the alias goes first and cascades its
grants and feeds. A table with no creation column (pro_products,
eml_emails) cannot be aged, so the reclaim takes its rows only while no other
run is alive. A
fixture reachable from a registered parent needs no name.
A HarnessTest node is also invisible to the scheduled passes that run on the
same database while a suite does: the incident pass
(IncidentReconciler::full_pass_nodes()) and the uptime and status-refresh
pass (RunNodeUptimeChecks::watched_nodes()) leave it alone, so no superadmin
is told about a suite's node and no job is queued for one that the suite then
deletes. A suite that wants either pass over its nodes calls it with them.
A stand-in models what a real machine runs
A gate that stubs a system tool (pgrep, systemctl, apt-get, docker) answers as a stock Ubuntu machine would, never as an empty one. A stubbed process table holds what every machine runs: systemd, cron, the agent, and on Ubuntu unattended-upgrade-shutdown --wait-for-signal, whose process name is cut to unattended-upgr, the same as unattended-upgrade's. A stub of an engine refuses what the real one refuses: Docker takes one capability per --cap-add, where Podman also takes a comma list. A stub that answers "nothing is running" or takes every argument proves only that the script agrees with the stub.
That is not enough for a script the agent runs as root. An agent word that is new or changed runs once on a real test server before the release ships it (php maintenance_scripts/dev_tools/test_server.php create, Ubuntu as the fleet runs it). The live-tier test plugins/server_manager/tests/agent_live_proofs_live_test.php does the run: it builds the agent being prepared with a key made for the run, makes a test server, installs and joins the agent, runs each changed word a bare machine can prove (or those LIVE_PROOF_WORDS names), and records each that passes in plugins/server_manager/agent_live_proofs.json, noting the agent version and the script's hash it ran with; then it removes the server, its node and the key. plugins/server_manager/tests/agent_live_proofs_site_live_test.php does the same for the words a site runs (apply_update, upgrade_preflight, site_log): it installs the newest published release on a test server, upgrades it through the agent onto a candidate built from this tree, and reads the upgraded site's access log. A word that needs the publishing box is recorded by hand after its run with php maintenance_scripts/dev_tools/record_live_proof.php WORD --where=... --what=.... record_live_proof.php list says which changed words lack one. The publish page lists those words and publishes them only when the box saying so is ticked (AgentLiveProofs).
Site copy has one live test of its own, plugins/server_manager/tests/site_copy_live_test.php, run by name after copy code changes and before the release that carries them (php tests/run.php live --only=plugins/server_manager/tests/site_copy_live_test.php, about 35 minutes, under its 90-minute cap). It runs on this management node's own code: a source installed on a test server at the oldest release this node serves that a copy accepts, named in the Cloudflare test zone with its records proxied, its recovery key a key made for the run (opened only in the test's own process), its first backup taken; then a copy onto a Linode made here in the test account, the source's export approved once it names the copy's own key; the way back at ready; and the switch-over by the proxy, its seeds, probes and security checked against the source's, kept. The zone's token is read from the runner's ~/.config/joinery-test/cloudflare_test_token; without it the test makes nothing and skips. Its nodes are marked test builds, so the test cloud's cleanup takes them with their servers if a run is killed before its teardown, and the next run removes a killed run's zone records. SITE_COPY_LIVE_RELEASE installs the source at another release this node serves; SITE_COPY_LIVE_KEEP_ON_FAILURE=1 keeps a failed run's servers to look at.
A test never changes the real repository
A suite that exercises git makes its own throwaway repositories under the
temp directory (sys_get_temp_dir(), never cache/tests/, which sits inside
the checkout) and commits, pushes and rewrites remotes there. The real
checkout is guarded two ways (tests/lib/repo_guard.php):
- Refused.
harness_boot()andtests/run.phpmark every test process withJOINERY_TEST_RUNand the suite's name inJOINERY_TEST_SUITE, and install two git hooks in the real repository.reference-transactionrefuses a marked process moving a branch, a tag orHEAD(a fetch's remote-tracking refs pass);pre-pushrefuses its push. Each refusal is logged with the suite's name in.git/joinery-test-refusals.log. Nothing else is affected: an unmarked process, and every throwaway repository, has no such hook to meet. - Compared. What a hook cannot see is snapshotted when a suite boots and
compared in
harness_finish():.git/configandconfig.worktree, the shared index (it stays empty; commits are prepared in private indexes), and the refusal log. The runner does the same around its whole pass, which covers the shell gates. A change fails the suite (the check the real repository is as this test found it) or the run (an entry namedrepo_guard), saying what changed and what to put back. A branch that moved while the hooks stood is someone's commit and is only noted (Repository:in the summary); with no hook standing it fails.
Mail a run sends is cleaned up too
harness_boot() points sending at the local relay and redirects every recipient
to a test store alias, so a run never mails a person. That relay is a real mail
server, though, so those messages are really delivered and stored — addressed to
an address no alias claims, which means they sit in no mailbox and nobody ever
trashes them.
Cleanup therefore runs at both ends of a run, and needs to:
- At teardown — NULL-alias mail received since the run booted, addressed to
the test store alias or to a
harness_fixture_email()address carrying this run's token. It runs last, because delivery is asynchronous and every other teardown step is time the relay gets to hand over what the run sent. - At boot — the same shapes, matching any fixture address, but only mail older than a 600-second floor. This is the pass that actually empties the box: teardown provably cannot finish the job, because a message the relay hands over after teardown is already too late, and one was measured doing exactly that. What a previous run sent has certainly landed by the time the next run starts.
Both passes only run when debug is on, in the db tier or above, and both
patterns are anchored to the fixture domain. That is deliberate and load-bearing
rather than belt-and-braces: deploy-tier tests declare env: any and run on
customer production nodes, safe promises no persistent side effects, and a
customer's own mail to an address that merely starts harnesstest_ at their own
domain is not ours to delete.
Anything still missed is caught by mailbox_unmatched_retention_days.
The fixture-address half exists for the dev-web suites: those make their
requests inside Apache, which reads the site's real email settings and sends for
real, so the redirect never applies to them. The recipient is still a fixture
address, and that is enough to recognise it.
The cleanup list lives only in the test's own memory, so it survives a failure,
an uncaught exception and a fatal error — all of which still run shutdown
functions — plus SIGTERM (the runner's timeout) and SIGINT (Ctrl-C),
both converted to exit(1) so teardown runs on the way out. Only SIGKILL and
the OOM killer can strand fixtures.
Settings and databases:
get_setting_raw($name)/set_setting_raw($name, $value)— persisted DB settings.harness_set_setting_mem($key, $value)— override a setting in the Globalvars in-memory cache only, restored at teardown. Never persisted. A blank value does not override.*Globalvars::get_setting()treats an empty in-memory value as a cache miss and reads the database instead, so a setting cannot be switched off by blanking it — the live value silently wins and the test exercises the wrong branch. Use'0'where the setting is a flag; where the setting is free text whose emptiness means "off" (anti_spam_answer), read the live value and satisfy the check rather than trying to disable it.harness_test_mode()— switch to the copied test database and close it at teardown; use with tiertest-db.
harness_finish() is mandatory. If the script ends without it — a fatal
error, an uncaught exception, an early exit() — the harness records a failing
check so a crash can never be misread as a pass. Do not scatter bare exit()
calls in a test body; return, or reach harness_finish().Talking to the site over HTTP
tests/lib/http.php is the one HTTP client for tests — require it instead of the
harness (it pulls the harness in) whenever a test makes real requests. There is
no per-suite curl to copy, and nothing to configure: the base URL comes from the
webDir setting, and requests to that site are automatically pinned to the
machine's own origin IP so they bypass Cloudflare and keep a stable
REMOTE_ADDR. Every request carries X-Joinery-Test: 1, so an error it causes
is recorded but never reported to the upgrade source automatically
(see Error Handling § Automatic reports).
require_once(__DIR__ . '/../lib/http.php');
harness_boot();
$r = harness_request('POST', '/api/v1/action/foo', array(
'headers' => harness_csrf_header($token), // or key_headers(...) for API keys
'body' => array('a' => 1), // arrays default to a JSON body
));
check($r['status'] === 200, 'foo ran', $r['raw']);harness_request($method, $url, $opts) returns status, body/raw, json,
headers (array), header_string, content_type, redirect_url, and error.
$url is a path against the base URL, or a full absolute URL (signed links).
The options cover the whole surface: encode (json|form|raw|multipart),
files for multipart uploads, jar for a cookie session, cookies for extra
cookies, follow for redirects, accept (defaults to application/json, pass
null to omit), timeout, insecure, and pin to force origin pinning on/off.
For a browser (cookie) session rather than an API key:
harness_jar_new($prefix)— a cookie jar file, deleted at teardown.harness_web_login($jar, $email, $password)— form-login and return the CSRF token from the resulting page, ornullif login failed.harness_jar_csrf($jar)— thejoinery_api_csrfcookie (present for anyone, anonymous included);harness_meta_csrf($html)reads thejoinery-api-csrfmeta tag instead (rendered only into a logged-in page). The two are not interchangeable — pick the one the endpoint expects.harness_csrf_header($token)— theX-Joinery-Csrfheader a browser-session call must send.harness_put_chunk($path, $key_headers, $content_range, $body)— a raw-body chunk PUT for the upload transport.
[base_url] [origin_ip] on the command line; call
harness_http_boot($argv) before harness_boot() to honour it (runner flags
like --json are ignored). The REST API suites layer api_test_boot() /
api_request() / key_headers() on top of this in
tests/functional/api/api_test_harness.php.Calling a logic function directly
Admin and page logic is normally reached through process_logic(), which exits
on the post-save redirect — fatal to a test. tests/lib/logic.php calls the
logic function itself instead and hands back the LogicResult:
require_once(__DIR__ . '/../lib/logic.php');
$result = harness_call_logic('logic/foo_logic.php', 'foo_logic',
array('action' => 'add', 'name' => 'X'), 'POST');
check(!$result->error, 'save succeeded', (string)$result->error);harness_call_logic($path, $fn, $data, $method) simulates the request method
(REQUEST_METHOD, $_POST/$_GET/$_REQUEST) and restores the superglobals
afterwards — $method matters, since form saves are gated on a POST while GET
actions (e.g. new_version) must not be sent as a form save. It does not
throw on failure, so a test can assert on the result; harness_call_logic_ok()
is the variant that throws, for fixture-setup steps where a failure should stop
the run.
The call runs as a web request for the model check: every save it makes is
asked against the session and refused below the model's level, as on the site
(Permissions). Outside a logic call,
any write a suite makes while a simulated person is signed in is checked the
same way (harness_boot() sets Access::$check_cli_persons); fixtures made
with no one signed in are not. A fixture a suite builds as one of its users
goes inside SystemBase::server_initiated_write() when no person's rule
should apply to it.
What a test file asks of a model directly ($user->save(), make_user(),
User::CreateNew(), Conversation::add_message()) is a fixture and is not
checked, whoever is signed in. So a suite proves a flow passes the check only
when it drives the flow through its logic (harness_call_logic()) or through a
service that is not a model; and a visitor's flow is checked only through
harness_call_logic(), since with no one signed in a CLI write is exempt.
The result contract
A declared .php test must emit this contract. If it doesn't — it crashed,
or was never converted to the harness — the runner marks it failed regardless of
its exit code (a script that prints "Failed" but exits 0 must never read as
green). Only .sh gates are exit-code-only; that is their contract.
Every test emits the same JSON contract on a --json run (prefixed on the CLI
by the sentinel @@JOINERY_TEST_RESULT@@ so the runner can locate it even after
error output). The runner and the dashboard consume only this:
{
"name": "cloud_offload_engine",
"tier": "safe",
"stats": { "total": 12, "passed": 12, "failed": 0, "skipped": 0 },
"sections": [
{ "title": "Creation", "checks": [ { "label": "…", "passed": true, "detail": "" } ] }
],
"duration_ms": 8
}The test database
test-db tests run against a separate database so CRUD is isolated from live
data. Rebuild it from /admin/admin_test_database.
It carries the schema, not the content. A rebuild takes the full structure
of the live database — every table, column, index and foreign key — and seeds
only the handful of tables the site cannot boot without
(TestDatabaseHelper::referenceTables(): settings, the plugin registry, admin
menus, the deletion rules permanent_delete() reads, and small reference data
like zone names and email templates). The seeded tables are as live had them at
the rebuild, so a suite that pins a new deletion rule or setting rebuilds the
copy after update_database has written it. Every
other table arrives empty with its constraints intact. Against a 1.3 GB live
database the copy is about 16 MB, nearly all of which is the floor cost of
~200 empty tables and their indexes.
Nothing should be written that expects to find real rows there. The model suites create their own — see How the model suite satisfies foreign keys.
The runner keeps the copy current. A copy goes stale the moment
update_database adds a table or column, and a stale copy fails every
test-db suite with "column does not exist" noise. So when a run is about to
execute test-db suites on a dev environment and the schema comparison says
the copy is behind, the runner rebuilds it (structure copy, seconds, atomic
rename) before the lane starts and says so in the output — and in the
aggregate JSON as test_db_refresh. A failed rebuild is reported and the
suites then fail with the real drift errors.
A full copy, content included, is a second button on the same page. It exists to reproduce a problem against real data; no test needs it, and it duplicates mail, sealed content and member records onto the same disk.
Rebuilding is a development action: the page refuses it when the debug
setting is off, and the menu entry is hidden there.
Two suites drive the model estate against the copy; if either reports schema errors, rebuild first:
tests/models/models_test.php— every data model's generic CRUD, validation and constraint behaviour, one check per model class.tests/models/multi_models_test.php— every collection class, one check per model that has one. For each it verifies that the loaded collection matches the equivalent direct SQL, that each declared filter narrows the result set, and that ordering and pagination agree withORDER BY/LIMIT/OFFSET.
$filters['col'] = [$this->options['x'], PDO::PARAM_INT])
must return rows that all carry that value — a collection which accepts an
option and then drops it returns a plausible superset, and where the option is
an owner id that superset is another user's data. An option that interprets its
value instead — a range bound, a boolean flag, a mapped literal — is exercised
but not asserted row-by-row, because the caller's value is not the stored value.
Options that take a raw SQL fragment cannot be driven from generated data and
are skipped.The copy does not receive update_database — that runs against the live
database only. After any schema change, rebuild from /admin/admin_test_database
so the copy matches; a test database that lags live validates models against a
schema production does not have. Foreign key constraints in particular are
materialized on the live database by update_database, so a stale copy silently
loses that coverage. Rebuilding is cheap, so there is no reason to put it off.
dbname_test must name a database no site uses. The rebuild drops its target,
and a rebuild refuses outright when that name turns out to be some other site's
live database — which is possible, because the installer can provision a
separate site named {site}_test alongside the main one.
Both test-db suites declare needs: [test-db]. The runner probes the
connection rather than trusting dbname_test, so a checkout with no copy
provisioned skips them with a named reason instead of failing the gate.
How the model suite satisfies foreign keys
ModelTester resolves every foreign-key-ish field to a real row. The declared
'foreign_key' field spec (target table + column — the same declaration
DatabaseUpdater materializes as a constraint) is authoritative; without one,
the naming convention applies: an FK column is the child prefix followed by the
parent model's primary key (abv_abx_ab_test_id → AbTest). For each resolved
reference the tester creates a fresh parent row through the target model
(recursively, so the parent's own references are satisfied too) and removes it
before the model's test finishes. Fresh parents keep results independent of
which ids exist in the database and keep child-delete cascades away from real
rows. Fields that resolve to no model (polymorphic ids like entity_id) get
plain integers, which is only valid because they reference no single table.
Per-model test fixtures
A model whose validity rules a generic generator cannot infer — cross-field business rules, enum values enforced in code — declares them:
public static $test_fixture = array(
'values' => array('mem_scope' => self::SCOPE_SHARED), // pinned field values
'update_field' => 'mem_content', // safe field for the update probe
);values override generated data in every dataset the tester builds (and pinned
fields are exempted from null-value probing — the pin exists because other
values are invalid). update_field names the field the CRUD update step
modifies, for models whose first spec-order field is enum-validated. generate
lists optional fields the tester fills anyway, for a rule that spans fields:
User lists usr_email, since an account needs an email or a username and
neither is required alone. Declare a fixture only when the field specs
genuinely cannot express the rule.
Plugin tests
A plugin's tests live in plugins/{plugin}/tests/ and carry the same header.
They are discovered and listed alongside core tests (the dashboard includes
them), and are reachable directly at /plugins/{plugin}/tests/{page}
(superadmin). Follow the same harness conventions; the relative path to the
harness from a plugin test is __DIR__ . '/../../../tests/lib/harness.php'.