Testing
Seven layers, ordered by how often they run. Layer 2 is the most valuable thing in the repo.
The organising principle: a gate people trust is worth ten they route around. Every choice below trades
sensitivity for stability in that direction, and where a check is inherently flaky it runs nightly rather
than per-PR. A visual gate that gets --update-snapshots'd reflexively has stopped being a test.
1. Unit — pure math
No DOM, no network, no clock. Fast enough to run on save. This is most of
packages/geometry-math/src/snapEngine.ts and friends, and the whole of packages/core.
Add property-based tests, not just examples. Examples pin cases someone thought about; properties pin
the invariant, and the invariant is what a refactor breaks. See
packages/geometry-math/src/properties.test.ts — and read its comments, because two of them record real
findings that only a property test surfaces:
resolveSnap's tie-break is epsilon-based, so at exactly 1e-6 the winner depends on array order. That is not a defect — one micron is the same point for any construction purpose — but it means the contract is "within epsilon of the true minimum", not "the true minimum". The property is stated in those terms, and the looser guarantee is now written down instead of assumed.checkPolygon's exact orientation predicate is algebraically winding-invariant but not numerically so at magnitudes around 1e-232, where the determinant underflows through subnormals and loses its sign. Real, and irrelevant: no building has a dimension of 1e-232 m, and the placement checks reject anything near it first. So the generator is scoped to 0.1 mm – 100 km, which is scoping the property to its domain rather than weakening it. A separate example asserts the predicate still returns a well-formed verdict on subnormals, because aNaNescaping the determinant would make every downstream comparison false and the polygon silently "valid".
Both notes exist because the honest answer to a failing property is sometimes "the property was wrong" — and that has to be written down, or the next person re-derives it. The orientation-determinant note went through two rounds for exactly this reason: the first diagnosis blamed subnormal underflow and bounded the coordinate magnitudes, and then CI found a second counterexample at 1e-4 on a seed the local run had never reached. The real cause is catastrophic cancellation when subtracting nearly-equal coordinates, of which underflow was one instance — so the property is now scoped on vertex separation rather than magnitude, which is the region where a naive determinant can be trusted at all.
Property runs are seeded by default. Unseeded, fast-check finds things nobody thought of; it also makes
the suite pass locally and fail in CI, and a gate that behaves differently in two places gets muted. So PR
runs are deterministic and reproducible, and exploration moves to a nightly job with FAST_CHECK_SEED=random
that opens an issue with the reproducing seed rather than blocking a PR that did not cause the failure. Run
FAST_CHECK_SEED=random npm run test locally before a release.
2. Kernel conformance — the executable specification
@massing/kernel-conformance is a published test library, not a test directory:
describeKernel("LocalKernel", () => createLocalKernel(), declaredCapabilities);
Both first-party kernels call it. So does anyone writing a third kernel — which is what turns "write a
MassingViewer kernel" from a reverse-engineering exercise into npm i -D and fix the reds.
Seven invariant families:
| # | Family | Why it is in this order |
|---|---|---|
| 1 | GUID stability across apply / reload / re-serialise | The invariant everything rests on, and the one most likely to differ silently between an ifcopenshell writer and a web-ifc writer |
| 2 | Refusal parity — same error code for the same bad input | Codes asserted, messages never (they are localised). A test pinning wording either blocks copy improvements or gets updated unread |
| 3 | Idempotence and commutativity | Catches hidden global state in the Worker |
| 4 | Units round-trip to 1e-9 m | The other half of the metres-only rule |
| 5 | Version monotonicity | A stale write must get version_conflict, never a silent overwrite |
| 6 | Capability honesty | How a partial kernel ships without lying: everything it does not claim returns unsupported rather than throwing, hanging, or doing nothing |
| 7 | Recipe parity ledger | A ratchet over the remote kernel's 96 operations. CI fails on regression and prints coverage |
Refusal parity has one case worth knowing about. massing's server refuses set_extrusion_depth on a
non-extrusion, and there is deliberately no client-side allowlist — the refusal arrives through the
normal error path. So LocalKernel must produce a compatible refusal or that design collapses into "works
against one kernel, silently does nothing against the other".
Mechanics. LocalKernel runs in-process on every PR. RemoteKernel runs against recorded cassettes on
every PR (for speed) and against a live docker-composed backend nightly, with the nightly job failing if the
cassettes have drifted from reality. Cassettes that are never revalidated are fiction.
3. Golden drawings — semantic digests, never raw SVG
Never snapshot SVG text. It changes on any generator refactor, attribute reordering, or whitespace change, none of which are regressions — and it fails unreadably, so the reflex is to accept the new output without reading it.
Three tiers:
Tier 1 — semantic digest (every PR, the real gate). Normalise the drawing to a canonical form: per
layer, a sorted list of typed geometry operations, coordinates quantised to 0.1 mm at paper scale, GlobalIds
retained, all id/class/timestamp/generator-version attributes stripped. Snapshot that. It is invariant
to ordering and refactors, it fails on the things that matter (a missing wall, a cut line in the wrong
place, a lost guid), and it diffs readably — a reviewer sees "layer A-WALL lost 1 polyline at
(3.2, 4.8)".
Tier 2 — structural assertions (same PR). Element count by IFC class matches the model. Every drawn
entity's guid resolves to a real GlobalId. No geometry outside the sheet border. All text inside its
bounding box.
Tier 3 — rasterised perceptual diff (nightly). resvg (Rust, deterministic, no browser) to PNG, then
SSIM against a baseline at 0.995, with the resvg version pinned. This catches what a digest cannot express —
hatch pattern changes, line-weight errors — without gating every PR on pixel luck. As built it also asserts
pixel equality, and there is no masked title block; both differences are explained below.
What is implemented
Tiers 1 and 2 are live in fixtures/golden.test.ts, over 2 fixtures × 8 views = 16 committed digests under
fixtures/golden/. digestDrawing and formatDigest are in packages/drawings2d/src/digest.ts.
Two fixtures, not the six the plan names, and the difference is deliberate. The plan's six (2 MB house →
240 MB tower, one deliberately broken) are specified for the M0 bake-off harness — a fidelity and performance
comparison between two candidate 2D engines. A 240 MB IFC is a perf fixture: it cannot be committed to a public
repository, and its golden digest would be tens of megabytes of text no reviewer will ever read, which destroys the
one property that makes this suite work. Scale belongs in a perf job against a generated model — and perf.yml does
not exist yet either; the gap is recorded in .github/workflows/nightly.yml. What a golden
suite needs is semantic coverage, and the eight views supply it: each one is chosen to fail for a reason none of
the others can.
fixtures/broken.ifc exists solely to exercise DrawingProvenance.incomplete. A field with no failing input has
never actually been tested — and it found a real one.
Tier 3 is implemented, in fixtures/raster.test.ts, with eight baselines under fixtures/raster/ and a
nightly raster job. It departs from the recipe above in four places, each measured rather than chosen:
- The gate is pixel equality first, SSIM second. These renders are deterministic —
resvgis pinned, system fonts are off, no GPU is involved — so the honest assertion is that the pixels match, within a 2/255 tolerance for anti-aliasing arithmetic that might differ between host architectures. SSIM's 0.995 floor is kept as an independent second check, because it is what catches a difference spread too thinly to trip a pixel count. SSIM cannot be primary here:mssimis a mean over the whole sheet, and a single hairline lightened by 30% is 0.06% of it — it scores 0.9994 and passes. Line weight is one of the two things this tier exists to catch. ssim.js's default options are overridden, and that mattered more than the threshold. It defaults to the original paper's automatic downsampling atmaxSize: 256, which is right for photographs and destroys linework. Measured: with the defaults, a line shifted three pixels scores exactly 1.000000. No floor below 1.0 catches that.raster-compare.test.tsasserts this specific number so a library change cannot silently re-blind the gate.- There is no masked title block, because there is no title block.
svg.tsemits a border and the drawing — no date, no revision, no generator stamp — so nothing in the output is nondeterministic. The one thing that would be is text, sinceresvgresolvessans-serifagainst host fonts. System fonts are off, which makes text deterministic by making it invisible, so the suite refuses text entities rather than masking a region. A mask hides a region from the gate for ever; a refusal makes the next person choose. When grid bubbles or dimensions land, the fix is a committed licensed font, and the failure message says so. - One fixture is baselined, not two.
brokenrenders byte-identically tosamplein all eight views — its only difference isprovenance.incomplete, which is not drawn. Eight duplicate PNGs would be 150 kB of binary that looks like coverage and asserts nothing, so the equality is a test instead.
It also asserts something no per-view baseline can: the equivalence structure of the eight views. Pairs listed
as identical must be pixel-identical, and every unlisted pair must differ. If the theme stopped distinguishing
cut from below, every view would render the same plan and every individual baseline would still match its own
file. Only comparing views notices.
Sabotage-tested, twice, and the second result is the argument for the tier existing. Removing the below role's
dash pattern failed six views with "5117 of 1809600 pixels differ … worst by 75/255, mssim 0.994124. First differs
near 41.7, 20.2 mm" — while Tier 1 and Tier 2 passed all 28 tests, because a digest carries no paint. Making
belowDepth silently ignored failed the shallow view and named all three view pairs that wrongly collapsed.
Tier 3 also draws the one line where the two tiers disagree, which is worth knowing: plan-0050 and plan-0300
have different digests and identical pixels. The section walk emits collinear intermediate vertices at
different points along the same edges — same drawing, different vertex list. Each tier is right about the question
it asks, and it is the clearest illustration of why neither replaces the other.
Two bugs the suite found on its first run
incomplete[]was blind to the stage that loses most elements.DrawingProvenance.incompletecan only report elements the generator was handed. The tessellator drops what it cannot build — and already records each one with a reason — but there was no channel to pass them on, so a plan built from a model three elements short reportedincomplete: []and full coverage. That is precisely the failure the field was added to prevent, relocated one stage upstream where nothing was looking.DrawingInput.skippedis the channel;generatePlanseedsincompletefrom it, prefixedbefore generation:so a reader knows which stage lost it.- Doors and windows were not cut into plans. The tessellator (then in the demo, now
packages/tessellate/src/index.ts) had no handling ofIFCRELVOIDSELEMENTat all, soIfcOpeningElementvoids were never subtracted and a plan showed an unbroken wall where the door is.build-sample.mjsauthors both as real voids and states the expectation — six wall loops at a 1.2 m cut — which the pipeline did not meet. Fixed. The suite carried a test asserting the defect on purpose, with the reasoning written out, so it could not be forgotten; that test failed with the message "voids are now subtracted — see the comment, this is good news" and has been deleted, which is what it was for. The digests now split the south wall at 3.0 m and 3.9 m and the north wall at 2.0 m and 3.5 m — exactly the intervals the fixture authors.
The wart that was here, and what it cost
This section used to read: "The IFC → mesh tessellator lives in apps/demo/src/tessellate.ts. It is not app
code, and both the golden suite and any future consumer need it, so fixtures/golden.test.ts imports it by
relative path rather than growing a second copy. Unscheduled." (The path is unbackticked because it no longer
exists, which is the doc-path gate's rule and the reason this paragraph is accurate rather than aspirational.)
It had already grown a second copy by then. apps/shell had its own, and it had drifted in two ways that a
reader would not notice: no refDirection, so a rotated wall drew unrotated and the transform gizmo appeared to
do nothing; and no IfcRelVoidsElement, so a wall with a door drew solid — the same defect item 2 above records
being fixed in the demo's copy, still live in the shell's.
It is now @massing/tessellate at layer 2, imported by both apps and by the fixtures, and the architecture gate
refuses a third copy. Both lost behaviours are pinned by tests that were checked by removing each one and
watching the matching test fail. The general lesson is worth more than the fix: "unscheduled" on a known
duplication is a decision to let it drift, and the drift is silent by construction.
Updating a golden safely
- Run the drawing bake-off harness and read the diff, not the summary.
- Confirm the change is intended by naming which entities moved and why. "The generator changed" is not a reason.
- Check
guidCoveragedid not drop. It is the one number that must never regress. - Check
incomplete[]did not grow. A drawing that lost an element renders perfectly and says nothing — this list is the only thing that says something. - Update, and put the reasoning in the commit message.
4. 3D viewport visual regression
WebGL is not deterministic across GPUs, drivers or ANGLE backends. Making it deterministic enough:
- Playwright Chromium in a digest-pinned container, launched with
--use-gl=angle --use-angle=swiftshader --deterministic-mode --force-device-scale-factor=1. SwiftShader is a software rasteriser: identical bytes on any host. - Remove non-determinism in the scene: fix device pixel ratio to 1, disable MSAA and dithering, fixed tone mapping, seeded RNG, frozen clocks.
- Render N frames, then explicitly read pixels. massing's hero-capture code documents why:
preserveDrawingBufferis off, so buffers do not persist between frames and a stale read returns a black image. Drive frames from the test rather than waiting on wall time — the frame loop takes an injectable frame API for exactly this. - Baselines are keyed by renderer signature (unmasked renderer + Chromium version + container digest). A baseline from a different key is a hard failure with a clear message, never a silent pass.
- Gate on structure, not pixels: alpha-threshold the render into a coarse occupancy grid and compare silhouettes, plus a luminance histogram distance. That catches "the model did not load", "the camera is wrong", "geometry vanished". Full SSIM runs nightly only.
Deliberately no Safari or iPad pixel parity. Their renderers differ and always will. Chasing it is how this suite gets abandoned; cross-browser gets functional E2E instead.
What is implemented
e2e/visual.spec.ts, as its own Playwright project (npm run e2e -- --project=visual), run nightly. Two signals,
both computed from a freshly rendered frame via renderSignature() on the demo's test hook — which calls
render() and reads pixels immediately, because preserveDrawingBuffer is off and a later read returns black:
- Occupancy, a 16×16 grid, 16 samples per cell, quantised to eighths. A cell moving by more than 1/8 is geometry rather than antialiasing; more than one such cell fails. A quarter of all cells moving slightly also fails, because that is a camera nudge no single cell reveals.
- Luminance, 8 buckets, compared as a normalised distribution with a total-variation limit of 0.15. This is the half that notices shading: a material that lost its light response keeps its silhouette exactly and collapses its histogram into one bucket.
Baselines live in e2e/visual/, keyed by renderer string. A missing baseline is written and then failed — never
silently accepted, because a baseline nobody looked at blesses whatever was on screen, including a black frame.
There is also a baseline-independent guard asserting the frame is not blank and shading exists; it cannot be
blessed away.
Sabotage-tested: deleting the baseline produced the write-then-fail message; zeroing a 3×3 block of occupied cells (a wall vanishing) reported "9 cell(s) changed occupancy by more than 1/8 — that is geometry, not antialiasing".
What it demonstrably does not catch, measured rather than assumed. Subtracting every door and window opening from the model did not move this gate: the baseline was unchanged and the test passed. That is correct, not a bug — the building's outline is identical and an opening in a wall face is interior detail seen edge-on from the default camera — but it is worth stating plainly, because it is easy to read "visual regression" as "notices any visible change". It notices geometry appearing, vanishing or moving. Openings, hatch patterns and line weights are what Tier 3 rasterisation is for. The semantic digests caught the same change loudly, across fourteen of sixteen goldens, which is the division of labour working — and Tier 3 now covers the paint half of it for 2D.
Not implemented: the digest-pinned container, --deterministic-mode, seeded RNG and frozen clocks. The current
job pins the rasteriser, DPR and colour profile and relies on the renderer-keyed baseline to fail loudly when the
runner image changes ANGLE. Nightly SSIM over the 3D viewport is not implemented either — the 2D raster job
is a different thing, over resvg output where determinism is achievable. Both recorded in
.github/workflows/nightly.yml.
5. E2E — Playwright
Matrix: chromium + webkit + firefox on every PR, and webkit is a required check. Safari and
iPad support is a stated differentiator (the nearest competitor is Chrome/Edge only), so it cannot be a
nightly afterthought. iPad runs emulated per-PR plus a weekly real-device run — emulated WebKit does not
reproduce real iOS memory pressure or WASM limits, which is precisely where an iPad fails.
Firefox does not run on a Windows host, and the runner says so once instead of sixty-two times
scripts/e2e.mjs probes each requested project's browser engine before starting anything, and reports a launch
failure as one diagnosed environment problem rather than as a test result.
The occasion was a real one. On the Windows development host, every Firefox test fails with
browserType.launch: spawn UNKNOWN — Node's placeholder for a CreateProcess error it has no mapping for, and the
least informative string Windows can return. What it actually means, from the Application event log:
Activation context generation failed … Dependent Assembly mozglue … could not be found
mozglue.dll is present and carries a correct embedded assembly manifest, in two independently downloaded
Firefox builds, on a host where Chromium and WebKit both launch. So it is a host-level side-by-side fault, not a
corrupt download, and nothing in this repository can fix it. CI runs Firefox on ubuntu-latest under xvfb, where
Windows activation contexts do not exist, so the matrix leg is unaffected — the gap is local only.
The preflight does not make such a run green. It exits 1, because "the browser would not start" and "the tests
pass" are different claims and neither can be made. E2E_SKIP_UNLAUNCHABLE=1 runs the remaining projects and
prints NOT RUNNING firefox in the summary; it is never set in CI, where an unlaunchable browser is a real failure.
The first version of this preflight was worse than the problem, and the fix is the interesting part. It derived
the project list by regex over playwright.config.ts with a 400-character window between a project's name: and
its devices[…]. shell's are 445 apart. So it dropped shell, and the full matrix ran five projects and
reported green — a diagnostic that silently shrank the run it existed to explain. The project list now comes from
playwright test --list --reporter=json, which is Playwright's own answer, and a project whose engine cannot be
determined disables the preflight entirely rather than being quietly excluded from the run.
The suite runs serially, on purpose
fullyParallel: false, workers: 1. This is not a flakiness workaround — it is what the tests contend for.
Every E2E test rasterises WebGL in software (SwiftShader, so the bytes are host-independent), which means they are all CPU-bound on the same scarce resource. Running them in parallel makes each one slower, and slower frames make the adaptive pixel-ratio governor step the resolution down mid-test. The canvas then changes size underneath assertions written against its previous size, and pick coordinates shift.
Two failures in the first full run were exactly this, and both were the harness being wrong rather than the app:
| Symptom | Actual cause |
|---|---|
| A click found no element; the same test passed in isolation | Parallel contention. Nothing about the app was wrong. |
canvas.w >= container.w * 0.9 failed at 495 vs 990 |
The governor had correctly dropped to its 0.5 floor. The assertion was fighting a working feature. |
The lesson generalises: when a suite shares one saturable resource, parallelism buys nothing and costs correctness. The canvas assertion was rewritten to check the buffer tracks the container at whatever ratio is currently in force, and that the ratio is one of the legal steps — which is the real invariant, and now holds under load instead of only when the machine is idle.
The third failure in that run was a real bug: Escape cleared the viewport selection but not the properties
panel, because the click handler and the key handler each held their own opinion about what was selected. The
fix routes both through one applySelection, so they cannot disagree. See apps/demo/src/main.ts.
Flows:
- Local golden path, zero network. Import a fixture IFC, orbit, select a wall, read its properties,
generate a plan, place a markup pin, export PDF, export BCF. This is
LocalKernel's acceptance test and the demo's smoke test in one. - Author flow. Arm the wall tool, snap to a grid intersection, type
12'6, commit. Assert the wall appears in 3D and in the plan with a matchingguid. This is the only test that exercises snapping, imperial parsing and identity together in a real DOM. - Refusal UX. Author a zero-length wall; assert the refusal surfaces and the tool stays armed. A refusal that disarms the tool loses the user's work.
- Offline. Load, go offline, author, reload; assert work persisted.
- Capability gating. Assert unavailable controls are dimmed with a visible reason, not absent.
6. Performance and memory
- Frame time: scripted orbit, gate on p95 with a 20% tolerance band, and report p50/p99 to the job summary. A tight gate on noisy runners trains people to ignore it, so the trend is tracked in a committed JSON where a regression is visible even while under threshold.
- Long tasks: nothing over 50 ms during the golden path. This is the gate that keeps the Worker boundary real rather than nominal.
- Drawing generation: per-fixture wall-clock budgets.
- Bundle budget: per package. Parse the entry from
index.htmlrather than filename-matching — massing learned that a lazily-loaded vendor chunk whose hashed name happens to start withindex-gets miscounted as shell. - Memory leaks — the highest-value and most-neglected gate for a long-lived three.js app. Mount, load, author 50 elements, unmount, force GC, then assert: renderer geometry and texture counts back to baseline, the three.js cache empty, JS heap within 5% of pre-mount, zero pending animation-frame callbacks, and listener counts at baseline. The frame-loop helper exists because an animation loop with no way to stop outlives whatever it was drawing for; test that property directly.
What is implemented
Drawing generation only, via scripts/perf-drawings.mjs (npm run perf), run nightly. Five synthetic cases,
p95 against perf/budgets.json with a +20% band, and an append-only trend uploaded as a nightly artifact (perf/trend.jsonl — described as committed until 2026-08-15, when it turned out never to have been tracked; see perf/README.md). Read
perf/README.md before changing a budget — every entry currently carries "baselined": false, because the numbers
are developer-machine measurements times three and three is a guess rather than a measurement.
The second check is the one a per-case budget cannot make: per-mesh cost is compared between two single-storey cases where every element is genuinely sectioned, so 25× the elements should cost roughly 25× the time. A quadratic sectioner bends that curve while every absolute number stays comfortably inside its budget.
That comparison is the way it is because the first version got it wrong. It compared per-mesh cost between a 40-mesh case and a 10 000-mesh, 20-storey case and reported scaling improving — 26 µs/mesh down to 1 µs/mesh. Meaningless: the small figure was JIT-dominated and the large one dominated by cheap vertical-extent rejections, so the ratio measured the mix of the workload rather than the cost of the work. A quadratic would have sailed through. Sabotage-tested afterwards by injecting an O(n²) loop into the sectioner, which the corrected check reports as "per-mesh cost grew 3.7x".
floor-large — 5000 sectioned elements on one floor — takes about 100 ms and so exceeds the 50 ms long-task rule
above. That is not a defect to optimise away; it is the argument for the boundary that already exists.
LocalKernel is Worker-only by construction, and drawing generation belongs on the same side of it. The case is
there to keep the number visible.
The memory-leak gate
Implemented, in e2e/memory.spec.ts, and it runs per-PR on every browser project rather than nightly — it takes
about five seconds and it is exactly deterministic, so there is no reason to defer it.
Two departures from the plan's recipe, both deliberate:
showModelis driven directly instead of authoring fifty walls. It is the same call every authoring round trip makes, and fifty round trips would spend a minute of wall clock to exercise one line of disposal. The thing under test isdisposeScene, not the kernel. A separate test does go through the real authoring path four times, to prove the round trip actually reaches that line.- GPU resource counts rather than JS heap.
renderer.info.memoryis exact and available everywhere;performance.memoryis Chromium-only, quantised, and needs--expose-gcto mean anything. A gate that works in one browser behind a launch flag is a gate that gets dropped. And GPU buffers are the leak that actually threatens this app, because three does not free them when an object leaves the scene graph.
What it checks: geometry and texture counts are exactly equal after 20 re-shows as after one (a tolerance would
let a one-buffer-per-edit leak pass for as long as the tolerance lasted, which is the only kind that reaches
production); shader programs are not recompiled per model; THREE.Cache is empty, so the first TextureLoader
added without a clear is noticed; dispose() leaves zero geometry, zero textures and no canvas in the DOM; and
dispose() is idempotent, because React strict mode unmounts twice.
Sabotage-tested by deleting disposeScene(current) from showModel, which failed with
"geometry count grew over 20 re-shows: 23 → 34".
Both now implemented (2026-08-15), in the frames job of .github/workflows/nightly.yml:
e2e/frametime.spec.ts and e2e/longtask.spec.ts.
Neither gates on a tuning number, and that is the settled posture rather than a shortcut. Both measure timing on
a shared runner through a software rasteriser, so an absolute threshold fires on whichever CPU the job drew — and
docs/testing.md's own risk #11 is test-suite abandonment via reflexive re-runs. What they fail on is liveness:
a render loop not producing frames, or a quarter-second of uninterrupted main-thread work. Both are design
regressions rather than slow machines. p50/p95/worst go to perf/frames.jsonl so the 20% band described above can
be set from a week of data instead of a guess.
Two things worth keeping from building them:
longtask.spec.tshad run in no workflow at all since it was written on 2026-08-12. It was listed as outstanding in the nightly's header, which was true — and the file existing made it look done to anyone who grepped for it. A spec nobody runs is indistinguishable from a spec that always passes.- The frame-time gate's first draft had an unreachable failing branch. It sampled a fixed 180 frames, so any stall large enough to reach the threshold also blew the 60-second test timeout, which fired first. Found by injecting a real main-thread stall rather than by lowering the threshold until it went red — the second proves the assertion is wired and nothing about whether a stall can reach it. Both branches are now verified reachable with two different stall shapes; the spec header records which.
7. Accessibility
axe-core on every route and every open panel, gated at serious and above. Implemented in
e2e/a11y.spec.ts as its own Playwright project (npm run e2e -- --project=a11y), Chromium-only by choice.
moderate and minor findings print to the log and do not fail the build, for the same reason visual
regression is nightly: one gate people trust beats ten they route around.
Ribbon-specific, beyond what axe can see: keyboard traversal of every tool (roving tabindex, arrows within
a group, Tab between groups), correct toolbar roles and pressed/expanded state, 3:1 focus contrast, focus
returning to the invoking control on close, and live-region announcements for tool arm/disarm and for
refusals. Covered in packages/ribbon/src/ribbon.test.ts, except pressed state — see the gaps table in
docs/accessibility.md, which records what is enforced, what is implemented but not tool-checkable, and what
is not done. That third list is the point of the page.
State the 3D-canvas limit honestly in docs/accessibility.md rather than claiming parity — and name the
alternative, because there is a real one: the CAD command grammar in
packages/geometry-math/src/cadCommands.ts means WALL 0,0 5,0 authors a wall with no pointing device at
all. That is a genuine and underrated accessibility story, and it is worth saying out loud — including that
it is a parser with no UI wired yet, which is the difference between an accessibility story and an
accessibility claim.
Running things
npm run test # layers 1–3, with coverage thresholds enforced
npm run test:fast # the same tests without coverage — for the inner loop
npm run test:watch # while working
npm run verify # lint + typecheck + test + repo gates — what CI runs
npm run gates # the repo gates alone
The repo gates are described in CONTRIBUTING.md. Each fails the build
rather than warning, and each one's failure paths are themselves verified — a gate that has never been
observed to fail is decoration, so scripts/check-architecture.mjs, scripts/check-licenses.mjs and
scripts/check-provenance.mjs were each run against deliberate violations before being trusted.
A flake, and the four wrong diagnoses before the right one
Worth writing down in full, because the method transfers and three of the four fixes were plausible.
The symptom. Running all four E2E projects locally, about one test per run failed on webkit or ipad — and
a different test each time: the plan click, the units toggle, the discipline switch, a BCF export. Each passed
3/3 in isolation. Each project passed 32/32 alone.
Wrong diagnosis 1: slowness. timeout went from 30 s to 60 s, since workers: 1 already exists because every
test rasterises WebGL in software. It reduced nothing — the failures were not near the limit.
Wrong diagnosis 2: two writers of #status. This was a real bug and did fix one test: a 500 ms
setInterval overwrote every message the app produced with "7 draw calls · 7 geometries", so a plugin's output was
clobbered before the assertion ran. Chromium's timing hid it; webkit surfaced it. Fixed by giving the perf readout
its own element — the same "two places holding an opinion" pattern the selection code already carries a comment
about. It was not the main cause.
Wrong diagnosis 3: ambiguous locators. getByRole("button", { name: "Plan" }) is a case-insensitive
substring match, so it also matches the ribbon's "Section plane" — a genuine fragility, and name: "m" had
already broken once. Every header control now uses its id. Also not the cause.
Wrong diagnosis 4: clicking during startup. The ribbon relayouts under a ResizeObserver, so a click measured
before a shift could land elsewhere. Waiting for kernel-ready in beforeEach was reasonable and changed nothing.
The actual method that worked: make the failure describe itself. cutPlan() replaced a bare
expect(...).toBeVisible() — which reports "timeout exceeded" for at least three distinct causes — with a probe
that answers each of them. On the next occurrence it said:
atCentre: "button#plan" the button is at the click point, nothing covering it
inViewport: true not scrolled out of view
headerOverflows: false the header is not even overflowing
planInfo: "Press Plan to cut one" generate() never ran
page errors: (none) the handler did not throw
programmaticClickWorks: true the listener is attached and works
Every app-side explanation is eliminated by that block. The listener works; the synthetic event delivery is what intermittently fails on WebKit.
The fix. Tests whose subject is the plan pipeline dispatch the event directly with dispatchEvent("click"),
which bypasses the input-routing layer that is the flake. Tests whose subject is real input keep it — "clicking
an element selects it" drives page.mouse, and "pinch zooms" synthesises pointer events against the canvas.
Using dispatchEvent there would be testing a mock of the interaction.
Result: four consecutive clean all-projects runs at 105/105, and about 20% faster.
The transferable part. Four plausible hypotheses cost more than one good diagnostic. A retry would have made
the suite green immediately and destroyed the only evidence — and two of the four fixes were worth keeping anyway
(the #status collision and the locators were real bugs), which is exactly why "it got greener" is not evidence of
a correct diagnosis.