Testing
See also: Development · Breaking change detection · Runtime & request-scoped context
The Dédalo server ships its automated suite under test/, run by bun test
(bun:test, Bun's built-in Jest-compatible runner). Two things make this suite
different from an ordinary one, and both are on this page: the parity tier replays a
frozen fixture store rather than calling a live reference engine, and the invariants
of the codebase are enforced by tripwire tests — the rule is tripwired or deleted.
Read the green-suite trap section before you trust a green run.
The suite at a glance
| Tier | Location | Runner | What it covers |
|---|---|---|---|
| Unit + DB gates | test/unit/ |
bun test |
The engine's internals against pure logic or the real Postgres: codec round-trips, security fail-closed, locator law, media, import, RAG, tools, the write-path *_native.test.ts gates, and the tripwires. |
| Parity | test/parity/ |
bun test |
*_differential.test.ts gates that replay the frozen fixture store and diff it against what the engine emits today. |
| Integration | test/integration/ |
bun test |
Diffusion against the real MariaDB deployment. Skips (loudly, with a logged message) when the diffusion DB credentials or socket are absent. |
| Client harness | client/dedalo/test/client/ |
bun run test:client |
The vanilla-JS client's Mocha/Chai suites, driven in headless Chrome against a running server. Not a bun test file. |
Running the suite
bun test # everything (minutes)
bun test test/unit # one directory
bun test test/parity # replays the frozen store — credless, no oracle
bun test test/unit/save_roundtrip.test.ts
bun test --test-name-pattern "save round-trip"
Discovery and timeouts come from bunfig.toml:
root = "test"confines discovery to thetest/tree. This matters:client/dedalo/contains view files whose names match Bun's default test globs (core/widgets/test/…), and without the confinement Bun would try to run them.timeout = 30000— DB-touching gates get a generous budget.- Preloads run before any test module is imported, and they matter because
src/config/config.tsfreezes the database connection and the media root at import:test/preload/test_database.tsrepoints the suite at its own database,test/preload/test_media.tsrepoints it at its own media root (below),test/preload/session_db.tspointsDEDALO_SESSION_DB_PATHat a throwaway sqlite store (a test run once wiped the live session store and logged everyone out — never bypass this by hardcoding a path), andtest/preload/component_registry.tsregisters the ontology↔components lookup that production boot performs as a module-load side effect.
The static gates are separate: bunx tsc --noEmit (strict, zero-new-errors) and
bun run lint (Biome, biome.jsonc).
The suite database, and why it cannot be yours
The suite runs on its own database, built from files vendored in the repository:
bun run test:db:setup # drops and rebuilds <app db>_test (or DEDALO_TEST_DATABASE)
That script restores the install seed, materializes the generic test TLD ontology,
imports the hierarchies and registers the tools — and then stamps the database
with a dedalo_test_marker row: one row, pinned by database constraints, carrying
the build stamp, the git revision that built it and the sha256 of both the seed and
the test TLD ontology. It is the only producer of that row anywhere.
Every writer of test data — the corpus loader, the situation builder, the media kit,
the ontology materializer, the scratch-record helpers — calls assertTestDatabase()
first and refuses to write anything at all on a database that does not carry the
marker. This is the mechanical half of the rule: a database name is a claim someone
made about a database (point DEDALO_TEST_DATABASE at a colleague's install and the
name still looks right), while the marker is the database's own declaration that it is
disposable. Dédalo runs in production with irreplaceable data, and the suite must be
runnable at any moment; that guarantee cannot rest on anyone remembering.
A refusal reads:
createScratchRecord: REFUSING to write test data into database 'dedalo_v7_mht' —
it carries no 'dedalo_test_marker' row … Build the test database with
'bun run test:db:setup'. Nothing was written.
The one bypass is the installer: a fresh, real installation has no marker and must
still receive the test TLD ontology (definitions, no records), so db_restore.ts
calls the materializer with allowAnyDatabase. Nothing else may.
The gate over all of this is test/unit/test_db_marker_tripwire.test.ts.
The suite media root, and why it cannot be yours either
The database is not the only thing a test can write to. Media derivatives, staged uploads, publication markers and the corpus's own image/av/pdf files all land on disk, and until 2026-08-19 they landed in the installation's media tree — beside the masters an institution cannot re-acquire.
They now land in a tree of the suite's own:
<repo>/../private/test_media/<suite database name>/
../private/ is already the home of this checkout's non-served, non-repo state (the
.env, the session store, processes/), and keying the tree by the suite database
name keeps the two together: files_info.file_path rows in that database name files
in that tree, so they are one fixture and are rebuilt by one command.
bun run test:db:setup sweeps and rebuilds the tree beside the database, and
plants a .dedalo_test_media marker file in its root. bun test creates and marks it
too, so a fresh clone works without the setup step.
The seam is a single key, DEDALO_TEST_MEDIA_ROOT, and it does two things at once
on purpose: it replaces the media root (it outranks MEDIA_PATH) and it arms
the refusal. Neither half is settable without the other, so a run cannot be armed at
the installation's root, nor repointed with the guard asleep. Three places set it —
the bun test preload, test:db:setup, and scripts/client_test_server.ts, which
hands it to the server it spawns for the browser suite.
Armed, every door that resolves a media root demands the marker and refuses without it, naming itself:
requireMediaRoot REFUSED: the media root '/var/…/T/scratch' carries no
'.dedalo_test_media' marker file, so it has not declared itself a disposable test
root — it may be an installation's media tree. NOTHING WAS WRITTEN.
That applies to a scratch root a gate makes for itself, too — a path is a claim,
and "it is under /tmp" is not a guarantee. A gate that needs one calls
markMediaRoot() / scratchMediaRoot() / resetMediaRoot() from
test/helpers/media_scratch_root.ts, which plants the declaration for it.
The gate over all of this is test/unit/test_media_root_tripwire.test.ts: it derives
the door inventory from the source, proves each door refuses an unmarked root and
that the directory is still empty afterwards, and proves a marked root writes.
Tier 1 — unit and DB gates (test/unit/)
An ordinary bun:test file importing the module under test from ../../src/…. Some
gates are pure; many run against the real Postgres, because the engine's contract is
the shape of that schema.
This tier also owns the write-path contracts. The parity tier can only replay reads
(see below), so every write contract — create, save, delete, duplicate, the Time Machine
audit rows, dataframe id_key stamping, portal writes, hierarchy provisioning — is
pinned by a TS-native gate. There are 17 of them, named *_native.test.ts
(test/unit/observer_native.test.ts, test/unit/delete_multi_native.test.ts,
test/unit/portal_edit_writes_native.test.ts, …). Their goldens were derived from the
pinned contract shapes, never from whatever the engine happened to emit — that is the
two-sided drift rule, and it is why they can still fail.
Tier 2 — parity against the frozen fixture store (test/parity/)
A parity gate replays one request against the engine in-process (dispatchRqo(),
src/core/api/dispatch.ts) and diffs the result against the reference response that the
retired v6 engine produced for the identical request, harvested before the 2026-07-11
cutover and frozen in test/parity/fixtures/oracle_harvest/ — 76 gate files, 449
recorded interactions, matched by a canonical hash of the request.
This store is the read-path baseline of record — and it is corpus-bound: every gate
was harvested against one real install's records (entity: monedaiberica). The suite
database is built from vendored files only and holds none of those records, so on it
the tier is red by construction (measured 2026-08-18: 173 pass / 208 fail; 186 reds are
corpus absence). That is not a reason to restore the harvest DB — a gate that passes only
against one install's records tests that install, not the engine. The rule now in force is
that a test uses the generic test TLD and builds the situation it tests; each
corpus-bound differential is being replaced by a test-TLD twin proving the same contract
and then deleted (engineering/ORACLE_HARVEST.md, "Generic-TLD replacement map").
ORACLE_MODE
oracleMode() (test/parity/oracle_fixtures.ts) reads process.env.ORACLE_MODE ?? 'fixtures'.
An unknown value throws — a typo can never silently mean the default.
| Mode | Behavior |
|---|---|
fixtures |
The default. No network, no credentials. Each recorded response is served by request hash; a miss throws loudly and never falls through to green. |
record |
Historical. Froze a live reference response into the store. |
live |
Historical. Called the reference engine over HTTP. |
record and live remain selectable, but nothing answers them: the reference engine is
decommissioned, so a re-harvest is impossible by definition. Any change to a fixture
from here on is a deliberate contract edit and needs its engineering/wire_contract/
entry in the same change.
Consequence for you: bun test test/parity/ needs no credentials, but it is not green
on the suite database until the corpus-bound gates are replaced (see above). It still
needs Postgres and the normal ../private/.env config, because the engine half of every
diff runs for real. New parity gates are written on the generic test TLD from day one.
The green-suite trap
A green suite does NOT mean the assertions ran
Every differential is gated at collection time on an oracle-presence probe:
describe.if(<probe>)(…) (or test.if(<probe>)(…) per case). When no oracle is
available the body is never collected, so the gate passes trivially. Before the
gating existed, an oracle-less run made about 83% of parity assertions vanish while the
runner reported PASS with zero skips — pure false confidence.
The probe is a predicate exported by the parity API-client module in test/parity/. Under
the default fixtures mode it means "is the harvested store on disk"; under live it
meant "are credentials configured". Copy the guard verbatim from a neighbouring
*_differential.test.ts — do not hand-roll one.
Two further rules follow from the same trap:
- Never write a differential that could pass on an empty or degenerate response.
Assert on real emitted structure. A diff of
[]against[]is the trap wearing a costume. - A handful of tests still guard on the stricter live-oracle predicate (a value that can only be produced by an engine that no longer runs — for example a byte-compare of live Postgres sequence counters). Those report explicit SKIP. A skip is honest; a silent pass is not.
The canary
test/parity/oracle_canary.test.ts is the one parity file that is deliberately not
gated — its whole job is to make an oracle-less run impossible to mistake for a verified
one. Never add a guard to it.
Under the default fixtures mode it asserts the store is present and non-empty, throws
with harvest guidance if it is not, and logs a [oracle_canary] line stating exactly what
the run does and does not verify: how many frozen responses are being served, from how many
harvested gate files, that read-path parity is verified against the frozen capture, and
how many fixture-exempt gates were skipped.
The fixture-exempt list (FIXTURE_EXEMPT_GATES in test/parity/oracle_fixtures.ts) is now
empty: the write-path gates that could not be served from a frozen response retired
with the reference engine, and their surviving contracts live in the *_native.test.ts
twins. engineering/ORACLE_HARVEST.md maps every retired gate to its twin.
ORACLE_OPTIONAL=1 and ORACLE_REQUIRED=1 still exist but only bite under an explicit
ORACLE_MODE=live run; they are vestigial in the default mode.
Did my test actually assert anything?
The honest checks, in order of cost:
- Read the runner's own counts. A differential that reports
0 pass, N skipasserted nothing. Bun prints skips explicitly — that is the entire point of gating at collection time rather than returning early from the body. - Watch the canary output. It names the number of frozen responses being served.
- Break it on purpose. Change the expected value, run the file, confirm it goes red, revert. A gate you have never seen fail is not a gate.
- A fixture miss is loud. If your gate issues a request that was never harvested, the lookup throws and names the hash — it does not quietly return nothing.
Normalization: ledger it, never smooth it over
test/parity/normalize.ts starts empty of cleverness. Every field it strips carries a
written justification in the file; anything not listed is compared byte-for-byte. Today it
strips only the per-session csrf_token, the transient dedalo_last_error, and the
recursive debug block.
When the engine deliberately differs from the frozen reference shape, the gate
transforms the reference side before diffing — and that transformation must be recorded as
an entry in the wire-contract ledger, engineering/wire_contract/ (for example the
unified empty-component
value entries: []). A normalization key with no ledger row is a regression in disguise.
The reviewer's question is always: is this divergence deliberate and ledgered, or are you
papering over a diff?
The tripwires — the invariant-enforcement backbone
A documented invariant with no test that goes red on violation will rot. The 2026-07 foundation audit proved it from both sides: every invariant guarded only by prose had been violated in practice; every tripwired boundary held. Hence the codebase's load-bearing law — invariants are tripwired or deleted.
A tripwire is an ordinary test in test/unit/ that greps the tree or asserts a boundary,
and reddens the moment a stated rule is broken. There are 26, and they are what make
rules like "no process.env. outside src/config/" or "no cross-request module state"
mechanical instead of aspirational. A representative sample:
| Tripwire | Invariant it guards |
|---|---|
test/unit/sql_confinement_tripwire.test.ts |
Tiered SQL confinement — raw SQL only where it is allowed to live. |
test/unit/config_env_tripwire.test.ts |
No process.env. / Bun.env / import.meta.env outside src/config/ — a stray read silently bypasses the typed catalog, and the setting "isn't taking effect". |
test/unit/module_state_tripwire.test.ts |
No cross-request module state. A module-level mutable Map/Set/let holding request, principal or language state bleeds one request into another under concurrency. |
test/unit/ws_a_tripwires.test.ts |
Every jsonb bind goes through the one codec; no inline locator comparisons. |
test/unit/import_scc_tripwire.test.ts |
No static value-import cycle of size > 1. |
test/unit/descriptor_completeness_tripwire.test.ts |
Component descriptors declare their required facets. |
test/unit/client_serving.test.ts |
The client serving contract: assets serve byte-identical to the client/ tree on disk. |
test/unit/css_build_tripwire.test.ts |
The committed CSS still matches the LESS it came from. The compiled CSS is the shipped artifact (deploy is a checkout), so a stale .css means the browser gets bytes no source produces. See CSS architecture. |
test/unit/ci_workflow_tripwire.test.ts |
The CI wiring itself, including the two rules below. |
test/parity/oracle_canary.test.ts |
Oracle absence is loud, never a silent green. |
The authoritative index is engineering/TRIPWIRES.md — a machine-read contract, not a
status note. Two rules keep it from drifting:
- the
TRIPWIRESarray inscripts/verify.tsmust equal that index exactly; scripts/ci/hermetic.shruns a subset of the same list — the hosted CI tier may run fewer gates, never unknown ones.
Both are asserted by test/unit/ci_workflow_tripwire.test.ts, so adding a tripwire means
adding a row to the index and a line to scripts/verify.ts in the same change; either
alone is a red gate.
When you rely on a tripwire, prove it honest: plant a violation, watch that exact tripwire go red, revert.
scripts/verify.ts — the pre-merge gate
The deterministic "definition of done" for a change. It is not the full suite; it is the fast gate that proves you did not break a tripwired invariant and that your change's nearest gates still pass.
bun run scripts/verify.ts # verify uncommitted work (vs HEAD)
bun run scripts/verify.ts --base master # verify the whole branch
bun run scripts/verify.ts --no-tests # typecheck + lint only
bun run scripts/verify.ts --changed # print the changed-file set and exit
Four stages, in cost order — typecheck (bunx tsc --noEmit), lint (bunx biome check .),
all 26 tripwires, then neighbours: the unit and parity test files that import any
src/ file you touched, discovered from the git diff. Exit 0 only if every enabled stage
is green.
scripts/ci/hermetic.sh — the DB-less tier
The public repo's CI runs on a bare hosted runner with no ../private/.env, no Postgres
and no secrets. scripts/ci/hermetic.sh is the single source of truth for that tier —
both the GitHub workflow and the GitLab mirror invoke this one script, so the two platforms
cannot drift.
It runs bun install --frozen-lockfile, bunx tsc --noEmit, bun run lint,
bun run lint:browser, the tripwires empirically proven to pass with no database
(DB_PORT points at a deliberately closed port, so an accidental DB touch fails loudly
rather than silently connecting — the current count is the array in the script itself),
the dependency-audit ratchet and the two isolated publication packages. The tripwires that
need a database run on the hosted db job of .github/workflows/db.yml
(scripts/ci/db_tier.sh, against a throwaway service container), together with the whole
unit and parity tiers; the gates that boot a real server — the browser client suite and the
two code-update drills — run on its instance job (scripts/ci/instance_tier.sh). Every
tripwire is assigned to exactly one of those tiers (ci_workflow_tripwire rule 3c), and
tier_wiring_tripwire holds that each tier script is actually run by an executing workflow.
scripts/verify.ts is the developer's pre-push gate, not a CI tier: every stage it reports
has a hosted twin.
The script stubs every required-no-default key in src/config/config.ts. That list is
pinned by a rule of ci_workflow_tripwire, for a reason worth internalising: the first
version stubbed four of the eight required keys and passed on every developer machine —
because ../private/.env was sitting right there, silently satisfying the other four. It
died on the first real runner. A gate that only passes because of your local environment is
not a gate. See engineering/CI.md for the full pipeline map.
The client harness
bun run test:client # scripts/client_test_runner.ts
The vanilla-JS client keeps its own in-browser Mocha + Chai suites, served at
/dedalo/test/client/index.html. scripts/client_test_runner.ts drives them headlessly
with Puppeteer: it starts its own server on the test database, launches Chrome, opens
the runner page, logs in, clicks run all, polls until the button re-enables, scrapes
window.global_stats plus the per-group and per-suite DOM stats, stops its server, and
exits non-zero on any failure or any pending suite (a suite that never completed is
not a pass).
The server is the run's own, on the test database. This is a browser writing through a
live server, so the marker every other test-data writer asks
(src/core/test_data/test_database_marker.ts) cannot see those writes. The command
therefore does not use anybody else's server: it starts one with the test database, a
scratch unix socket, a scratch session store and a scratch state file, and stops it at the
end. Build the database once with bun run test:db:setup; nothing else is needed to run
the suite, and no client test can reach the application's records.
The target is verified, never assumed. Before Chrome is launched the runner asks the
server it is about to drive, over /health, for the fingerprint of its test-database
marker — an opaque hash, served only in development mode, never the database name. A server
that answers no fingerprint (it is on an application database) or a different one (another
checkout's test database) is refused with an explanation. --url still points the run at a
server you started yourself and is checked exactly the same way.
It is deliberately not a bun test file — it needs a live server and a real browser,
so it stays outside bunfig.toml discovery and is invoked explicitly.
Operator facts:
- Options (each with an env fallback):
--port(TEST_PORT, default4390— the run's own listener; it walks upward to the first free port),--url(TEST_URL, a server you started yourself),--timeout(TEST_TIMEOUT, default300000ms),--headless(HEADLESS, defaulttrue— pass--headless falseto watch it run),--user/--password(DEDALO_TEST_USER/DEDALO_TEST_PASSWORD),--auth(cookiedefault,form,mint),--strict,--no-reseed,--update(after a green run, bank the observed inventory intoengineering/client_gate_inventory.json— it refuses to lower a floor or raise a budget) and--replay <file>(TEST_REPLAY: no browser, no server — run the same interpret-conclude-exit tail over a scraped observation saved as JSON, the subprocess legclient_gate_inventory_tripwiremeasures the exit code through). - Credentials. The test database is disposable, so the run supplies its own: it sets
the login password on the seed's
rootuser (which ships without one) and then performs a real, password-verified login.--user/--passwordoverride it;--auth formdrives the client's own login UI;--auth mintmints a session without verifying any credential and announces itself every run. - Chrome comes from
PUPPETEER_EXECUTABLE_PATHif set, otherwise a system Chrome install via Puppeteer'schannel— the bundled-Chromium download is deliberately not required. - Reseed. The suites save random values into the shared
test3playground records, so the runner restores the canonicaltest3fixture from its single verified source (src/core/test_data/) before and after the run. Suppress with--no-reseed. The reseed is DB-only: a long-lived dev server may still hold staletest3-derived caches afterwards, so restart it when full cache coherence matters. - In CI,
scripts/ci/client_gate.shis now a one-line wrapper around the same command. Every stateful surface it used to scope by hand (port, unix socket, session sqlite, engine state file, diffusion job and activity tables) is scoped by the runner itself, so an interactive dev server on the same machine is untouched whether the suite is started by CI or by a developer. Note thatmochaandchaiare devDependencies — a runner that installed with--productioncannot serve the harness.
Scratch-write hygiene
Tests share the corpus Postgres with the running system. A careless write corrupts a real record.
- Never assert against a mutable production record. Clone a scratch twin, exercise
the real path against it, assert, and delete it — at both ends, not just in
afterAll. - The database must carry the marker (above). Every helper below refuses without it, so a suite pointed at an installation writes nothing rather than writing carefully.
- DB writes go only to the scratch surfaces. The conventions live in one place,
test/helpers/test_data.ts— do not invent new ones:test2— a real ontology section resolving tomatrix_test; use a reserved highsection_id(900000+), clear of genuine records.- synthetic tipos (
testrt1,zztws*, …) for write-kernel gates that must not touch a real section's ontology. test3— only for gates that need the real playground ontology (children, relations). Create scratch records throughcreateSectionRecord()and never touch the canonical ids.dedalo_ts_test_*tables — the schema-enforced prefix for any scratch table (diffusion queues, writers, migrations).
- Clean up everything you caused, not just the row you created: Time Machine snapshots, activity rows, and diffusion log rows are all side effects of a write and will pollute the next gate.
- Never read or write the live session store. The
bunfig.tomlpreload already redirects it; do not defeat the redirect.
Writing a new test
Copy a neighbouring test in the right directory and adapt it — that is the fastest correct path, and it inherits the guards.
A pure or DB-backed gate — the default for anything new:
- Put it in
test/unit/as<thing>.test.tsand import the module under test from../../src/…. - If it writes, use a scratch surface and clean up in
afterAll. - If it asserts a write contract, model it on an existing
*_native.test.ts: derive the golden from the specified contract, never from the engine's current output.
A new invariant — a rule you want to state in a header or a doc:
- Write the tripwire in
test/unit/first. If a rule cannot be mechanically checked, do not write the rule. - Add its row to
engineering/TRIPWIRES.mdand its line toscripts/verify.tsin the same change. - Plant a violation and confirm it goes red.
A parity gate. New read-path coverage cannot be harvested — there is nothing left to
harvest from. A new parity gate can only replay interactions already in the frozen store;
new coverage belongs in test/unit/, and any change to a fixture is a deliberate contract
edit with a engineering/wire_contract/ entry.
Finally, run your file while iterating and the gate before pushing:
bun test test/unit/my_thing.test.ts
bun run scripts/verify.ts
A test that fails only in the full suite
Almost always one of two things, and almost never a real regression:
-
A leaked module mock.
mock.moduleis process-global, andmock.restore()does not revert it — a mock installed in one file stays installed for every file that runs after it. The pattern (seetest/unit/record_scope_gates.test.ts): snapshot the real module's exports at import time and re-install them in anafterEach.import * as record_scope from '../../src/core/security/record_scope.ts'; const REAL_RECORD_SCOPE = { ...record_scope }; afterEach(() => { mock.module('../../src/core/security/record_scope.ts', () => REAL_RECORD_SCOPE); }); -
A scratch collision — two gates reaching for the same scratch row, or a gate that did not clean up after itself.
Check both before you "fix" the code.
See also
- Development overview — code style, commit convention, the broader dev guide
- Breaking change detection — how contract stability is guarded
- Runtime & request-scoped context —
handleRequest()/dispatchRqo(), the surfaces these gates drive - Performance metrics — latency signal in the runtime
- Code documentation standard — doc-blocks inside the source