Compass UI dev-boot smoke gate (RIG-1536)
Status: Draft
Problem / Intent
Section titled “Problem / Intent”vite dev can serve a completely broken app — blank page, render() never
runs — while every gated task in the repository stays green. RIG-1535 was
exactly that on main, and it surfaced only because a human drove a real
browser. This record adds a dev-smoke gate so a dev-boot regression reds CI
instead of waiting for a human.
Why the current gate cannot see it, each cause verified on today’s main
(ba12d920):
-
No task exercises the serving path.
apps/ui/moon.yml:59-60wires the gate asci:deps: ['typecheck', 'build', 'test', 'stylelint']The
devtask (moon.yml:11-14,command: 'bunx vite') is in nocichain — correctly, it’s a server preset — so nothing that CI runs ever instantiates the dev server. -
vite buildis a different code path fromvite dev. The RIG-1535 defect class lives in dev-serving only:vite-plugin-solidprepends thedevelopmentexport condition while serving, and Vite itself re-supplies it (vite.config.ts:32-35: “Vite re-supplies the condition itself (DEV_PROD_CONDITIONin its default client conditions, expanded todevelopmentoutside a production build)”). A productionvite buildnever applies it, sobuildis green over a dev-broken config by construction. -
The unit suite never instantiates Vite’s dev server.
moon.yml:43runsbun test --conditions browser src/under happy-dom; the one suite that touches Vite at all (src/preview-build.test.ts:1-2, the previewable-build gate) runs a realvite build— again the build path, not serving. -
Cache-correctness is necessary but not sufficient.
mainhas already repaired the caching half of RIG-1536’s analysis:vite.config.tsIS intest’s inputs today (moon.yml:50), with the comment atmoon.yml:47-49(“Without these a vite.config-only change would leavetestunaffected and serve the gate a cached-green”). Sotestnow re-runs on a config edit — and still passes over the dev-broken config, exactly as RIG-1536 measured (“re-ran, hash moved … still PASSES”). A freshly-computed green over the same defect. The only real repair is an instrument that reads the served module graph.
Compounding: the RIG-1535 fix itself can rot silently. vite.config.ts:44-49
pins two nested dep paths:
optimizeDeps: { include: [ "solid-markdown > remark-parse > mdast-util-from-markdown > micromark > debug", "solid-markdown > remark-parse > unified > extend", ],},and the comment at vite.config.ts:40-43 names the hazard: “Vite silently
skips an unresolvable INTERMEDIATE segment (it keeps the previous basedir), so
if these paths ever rot there is no warning, just the blank page again.” A
dependency bump can re-break dev with zero diff to any file a gate hashes.
Issue: RIG-1536. Two of its claims are stale against today’s main and are
corrected here: (1) “no CI exists on main today” — .github/workflows/ci.yml
exists and runs moon ci :ci on PRs (ci.yml:230-231) and moon run :ci on
main pushes + nightly (ci.yml:240-241); (2) “vite.config.ts is absent from
test’s inputs” — it is present now (moon.yml:50, see above).
Approach
Section titled “Approach”A headless-boot smoke (RIG-1536’s Shape B) as a Playwright spec, wired as a
dev-smoke moon task into compass-ui:ci with cache: false.
-
The assertion: boot the real dev server, load the page, and require BOTH (a) the app’s mount selector
.bridgebecomes visible, and (b) zeropageerrorevents over the whole load. Both clauses are load-bearing: the.bridgewait alone misses a mount that renders but then throws asynchronously (a deferred chunk explodes after mount);pageerroralone misses a mount that silently no-ops (renders nothing, throws nothing). The.bridgewait is what makes clause (a) sound where a bare#root-non-empty check would not:renderBootErrorpaints the caught-error screen INTO#root— the fixture-boot catch routes through it (index.tsx:52-58), andboot.tsdocuments the contract (“this module catches at that boundary and paints the message into #root”) — so#root-non-empty false-greens on a caught boot failure, while.bridgevisibility never does. A#root-non- empty check is therefore redundant given the.bridgewait, and is kept (if at all) only as a cheap secondary guard, never a co-equal clause. -
The server: the existing fixture-boot dev server, exactly as the visual harness runs it —
playwright.config.ts:43:command: "bunx vite --port 5173 --strictPort --mode fixture",Fixture mode is still a dev SERVE (the
developmentcondition applies — the defect reproduces) and boots fully offline on the in-memory stub store (e2e/visual-smoke.spec.ts:5-8: “the app boots fully on the in-memory fixture store (stub-data.ts) with no daemon on :50051 and no VITE_COMPASS_BASE_URL — offline by construction”). No daemon, no env plumbing, deterministic in CI. Fixture mode cannot mask the defect class, structurally: the defect kills module-graph EVALUATION, which undervite devhappens for the entire static import chain before any runtime branch executes —index.tsx:9statically imports the mount module (import { mountShell, newAppQueryClient } from "./mount"),mount.tsx:17,19statically importsAppandAppRoutes, androutes.tsx:14-20eagerly imports every route component (nolazy()anywhere in the table). The fixture/live fork (index.tsx:49-51) is a runtime branch over an already-loaded graph, so a broken chain reds before it even executes; dev-serving applies thedevelopmentcondition identically in every mode. -
The tooling: the repo’s existing Playwright harness.
@playwright/test1.62.1is already a devDependency (package.json:31),playwright.config.tsalready exists with the webServer + nix-chromium wiring, ande2e/visual-smoke.spec.tsis a working spec against it. Adopting the issue’spuppeteer-coreproposal now would plant a second browser-automation convention beside a working first one. -
The wiring: a
dev-smoketask inapps/ui/moon.ymlwith explicitinputsANDcache: false, added toci.deps. The two settings compose, each doing a different job.inputsdrive PR-path affected-detection: a project task with noinputskey defaults to project-relative**/*, which excludes the workspace-root/bun.lock— every sibling task that needs the lockfile lists it explicitly ('/bun.lock'intypecheck/build/test,moon.yml:18,22,50) — sodev-smokemirrorsbuild’s list (moon.yml:22) plus its own two files ('e2e/dev-boot.spec.ts','playwright.config.ts'), and a lockfile-only dependency-bump PR is detected directly, with no reliance on any propagation mechanism.cache: falseis still not optional: the task’s subject is the served module graph — resolved at run time out ofnode_modules— not an input moon can hash, so whenever the task IS scheduled it must really run; a cached green would survive exactly the change (a dependency bump rotting theoptimizeDepspins) it exists to red on. Precedent in the same file:ciitself iscache: false(moon.yml:61-62). -
CI browser provisioning: CI has no Chromium today —
grepof.github/workflows/ci.yml,devenv.nix, andtools/toolchain/finds no playwright/chromium/puppeteer reference, andplaywright.config.ts:31-33falls back to a dev-box path (/etc/profiles/per-user/mattw/bin/chromium) overridable viaPLAYWRIGHT_CHROMIUM_PATH. Chromium enters CI the same way every other tool does: added todevenv.nix’spackageslist, whichparity.ts --print-nix-attrsparses andgate-tools.nixresolves onto the CI PATH (gate-tools.nix:5-8: “It is never hand-listed here: adding a tool to the dev shell must extend CI and the gate with no edit to this file or to the workflow”). The workflow then exportsPLAYWRIGHT_CHROMIUM_PATHfrom the resolved store path.
Red→green acceptance (the gate is only real if it has been seen to fail):
with the optimizeDeps.include block of vite.config.ts:44-49 temporarily
removed — the main-era dev-broken configuration RIG-1535 fixed — moon run compass-ui:dev-smoke MUST fail; with the block restored it MUST pass. Both
runs recorded in the implementation PR body. This is the record’s rendering of
RIG-1536’s two-clause general check: “1. can editing the artifact invalidate
the gate? 2. if you make it invalidate, does the gate then FAIL?”
Precedent for this shape of hygiene gate: src/design-citations.test.ts:12-13
(“this test gives the sweep teeth so it can’t regress”) — a manual sweep that
got a permanent instrument. Same move here: the manual “drive a browser at the
dev server” check becomes a task.
Alternatives considered
Section titled “Alternatives considered”Guard shape
Section titled “Guard shape”- Shape B — headless boot (chosen). Boots the real server, loads the real
page, asserts the app mounted with zero page errors. Catches the WHOLE
defect class “dev serves but the app doesn’t boot” — CJS-interop holes,
a future plugin misconfiguration, a top-level throw in the module graph, a
broken index.html — not just the one failure mode we’ve seen. Cost: needs a
browser in CI (~seconds of boot + load per run, plus a one-time Chromium
provisioning task) and carries browser-harness flake risk, mitigated by the
existing harness’s determinism choices (
playwright.config.ts:42-57:strictPort,reuseExistingServer: false, generous webServer timeout). - Shape A — module-graph crawl. RIG-1536 validated it: ~1.8s, no browser,
crawl the served graph from
/src/index.tsx, fail on any raw-served/@fsdependency default-importing into a CJS-only package with no__vite__cjsImportshim; proven red-on-main / green-on-fix over 308 modules. Loses on breadth and maintenance: it detects exactly the CJS-interop class and nothing else, and it is a bespoke crawler encoding Vite serving internals (/@fsURLs, interop shim markers) that Vite is free to change — a maintenance liability with no other consumer. Kept as the named fallback if the CI browser cost is vetoed (Open Question 3). - Both. Maximum signal, but B subsumes A’s verdict TODAY: every CJS leaf
(
debug,extend) sits in the STATIC boot graph —routes.tsx:17eagerly importsChannelView,ChannelView.tsx:30importsMarkdownText,MarkdownText.tsx:18importssolid-markdown; shiki is ESM — so a graph that fails the crawl produces a blank page that fails the boot. That is a present-day fact, not a structural guarantee: a crawl of the served graph can follow DYNAMIC imports, while B executes only the chunks the boot path loads. The named residual: a CJS-interop rot inside a lazily-loaded chain — the ~15 dynamicimport()s inapps/ui/src/markdown/highlighter.ts:38-56, which load only on the first code block highlighted, never on the bridge board — would pass B’s boot while failing A’s crawl; a futurelazy()route or CJS-bearing dynamic import likewise exits B’s coverage silently. Beyond that, A’s only unique value is a faster, more pointed failure message. Two instruments for one contract is maintenance without coverage; the residual is accepted and named here.
Automation tooling
Section titled “Automation tooling”- Playwright reuse (chosen). Already the repo’s browser convention:
@playwright/testpinned at1.62.1(package.json:31), a working config with the exact webServer this gate needs, atest:visualscript (package.json:8). Zero new dependencies; the gate is one spec file plus a moon task. puppeteer-core(the issue’s proposal, rejected). RIG-1536 predates the repo’s Playwright adoption (SEA-2034 T1); its claim “Chromium andpuppeteer-coreare already in the environment” no longer describes the repo —puppeteer-coreis not inpackage.json(its devDependencies are fully listed atpackage.json:27-43; no puppeteer entry), andPUPPETEER_EXECUTABLE_PATHis a dev-box env var, not CI state. Adopting it would add a dependency to stand up a SECOND browser-automation convention beside a working first one — forbidden by the repo convention rule.
Do nothing / inputs-only repair
Section titled “Do nothing / inputs-only repair”Rejected with measurement, by RIG-1536 itself: adding vite.config.ts to
test’s inputs (already done on main, moon.yml:50) converts a stable-hash
green into a freshly-computed green over the same defect — “which is worse
than leaving it”, because a moved hash reads as “the config was checked”. For
a task whose runner never reads the served graph, the input list is the wrong
lever.
Global Constraints
Section titled “Global Constraints”- Tooling:
@playwright/testat the repo pin1.62.1(package.json:31) — no version bump in this work, nopuppeteer-core, no second browser-automation convention. - Browser: Chromium via
launchOptions.executablePath(playwright.config.ts:31-33), env-overridable throughPLAYWRIGHT_CHROMIUM_PATH; CI supplies a nix-resolved Chromium through thedevenv.nixpackages→gate-tools.nixpipeline (never asetup-*action, never Playwright’s own unpatched-for-NixOS download —playwright.config.ts:8-14). dev-smokecarries explicitinputsANDcache: false, both mandatory and composing (see T2):inputsdrive PR-path affected-detection (a no-inputsproject task defaults to project-relative**/*, which excludes the workspace-root/bun.lock);cache: falseforces a real run whenever the task is scheduled, because the subject is the served module graph resolved out ofnode_modulesat run time, not a moon-hashable input — a cached green would survive exactly the dependency-bump rot (vite.config.ts:40-43) the gate exists to red on.- Boot environment: the fixture-mode dev server
(
bunx vite --port 5173 --strictPort --mode fixture,playwright.config.ts:43) — offline by construction, noVITE_COMPASS_BASE_URL, no daemon, no caller-id env var (the UI has none:.env.development:46-48— caller identity comes from the WhoAmI RPC, and in fixture mode from the stub store). RIG-1536’sVITE_COMPASS_CALLER_IDnote predates fixture boot and is obsolete — the var does not exist in the tree (grep ofapps/uifinds zero occurrences). - Task hygiene: the new spec must never be picked up by
bun test—moon.yml:37-42documents whytestis scoped tosrc/(a@playwright/testspec underbun testthrows); the new spec therefore lives undere2e/, keeping that boundary intact. - No planning metadata in source: the spec and moon task carry maintainer-voice comments only; the issue reference lives in this record.
T1 — the dev-boot Playwright spec
Section titled “T1 — the dev-boot Playwright spec”Add apps/ui/e2e/dev-boot.spec.ts: one test that navigates to /#/ on the
config’s baseURL (playwright.config.ts:21, http://localhost:5173; the
shared webServer block boots the fixture-mode dev server), collects every
pageerror from before navigation to the end of the test (the
page.on("pageerror", …) listener attaches before page.goto), waits for the
app’s mount (the .bridge root surface, the same selector the visual harness
keys on at e2e/visual-smoke.spec.ts:22), and asserts (a) #root innerHTML is
non-empty and (b) the collected pageerror list is empty.
The failure path is ordered so the collected pageerror list is REPORTED even
when the .bridge wait times out: either race the .bridge wait against a
promise that rejects on the first pageerror, or assert the pageerror list
in a finally/soft-assertion that still runs on the timeout path. Without
this ordering, the exact defect class the gate exists for — the module graph
dies before mount, so .bridge never appears — surfaces as an undiagnostic
30s locator timeout (“Timeout 30000ms exceeded waiting for
locator(“.bridge”)”) and the log never names the broken module; with it,
every red names the underlying error.
Interfaces:consumesapps/ui/playwright.config.ts(unchanged:baseURL,webServer,use.launchOptions.executablePath); producesapps/ui/e2e/dev-boot.spec.tsexporting Playwright tests only (no runtime exports). Usespage.on("pageerror", (e: Error) => …)andpage.locator("#root")/page.locator(".bridge").- Test cycle:
bunx playwright test e2e/dev-boot.spec.tsfromapps/uipasses on the current tree.
T2 — the dev-smoke moon task + ci wiring
Section titled “T2 — the dev-smoke moon task + ci wiring”In apps/ui/moon.yml, add:
dev-smoke: command: 'bunx playwright test e2e/dev-boot.spec.ts' deps: ['install'] inputs: ['src/**/*', 'index.html', 'tsconfig.json', 'package.json', 'vite.config.ts', 'e2e/dev-boot.spec.ts', 'playwright.config.ts', '/bun.lock', '/packages/compass-client/src/**/*', '/packages/compass-client/package.json'] options: cache: falseand extend ci.deps (moon.yml:59-60) to
['typecheck', 'build', 'test', 'stylelint', 'dev-smoke']. Comment the task
with the two-setting rationale (inputs for affected-detection, cache-false so
a scheduled run is always a real run) in the file’s existing voice. The
explicit inputs are load-bearing, not hygiene: a project task with no
inputs key defaults to project-relative **/*, which excludes the
workspace-root /bun.lock — so without them a dependency-bump PR touching
only bun.lock (the PR shape most likely to rot the optimizeDeps pins)
could be skipped by the PR-path affected gate. The list is build’s
(moon.yml:22) plus the spec and the Playwright config, the two files whose
edits must re-schedule the gate.
Interfaces:consumes T1’s spec path; produces thedev-smoketask targetcompass-ui:dev-smokeand the extendedcompass-ui:cidep list.- Test cycle:
moon run compass-ui:dev-smokepasses; a second invocation re-executes (no cache hit); andmoon ci :ciagainst a synthetic lockfile-only diff (touch/bun.lockon a scratch branch) showscompass-ui:dev-smokescheduled — themoon runcycle ignores affected entirely, so only themoon cistep exercises the inputs’ affected-detection.
T3 — CI Chromium provisioning
Section titled “T3 — CI Chromium provisioning”Add nixpkgs chromium to the packages list in devenv.nix (:40), with a
comment in the list’s established style naming this gate as the consumer. The
parity pipeline picks it up with no workflow edit for PATH purposes
(gate-tools.nix:5-8), but Playwright needs the executable PATH, not the
command on PATH. Set PLAYWRIGHT_CHROMIUM_PATH into GITHUB_ENV after the
nixpkgs tools land on PATH, choosing the form by arm — because a step’s
$GITHUB_PATH append only affects SUBSEQUENT steps, never the current one:
a separate later step uses
echo "PLAYWRIGHT_CHROMIUM_PATH=$(command -v chromium)" >> "$GITHUB_ENV";
extending the phase-two step at ci.yml:197-212 instead must use the
in-scope store path directly,
echo "PLAYWRIGHT_CHROMIUM_PATH=$out/bin/chromium" >> "$GITHUB_ENV" (there
$(command -v chromium) would resolve nothing — $out/bin is not yet on the
running step’s PATH — silently falling back to the dev-box path in
playwright.config.ts:31-33).
Interfaces:consumesdevenv.nixpackageslist and.github/workflows/ci.ymlphase-two step; producesPLAYWRIGHT_CHROMIUM_PATHin the CI job env, consumed byplaywright.config.ts:31-33.- Test cycle: CI run on the implementation PR shows
compass-ui:dev-smokeexecuted and green;bun tools/toolchain/parity.tsstays green locally. - Risk to name up front: the Chromium SANDBOX on the runner. GitHub’s
ubuntu-24.04 images restrict unprivileged user namespaces via AppArmor, and
a nix-wrapped Chromium (no setuid sandbox helper) launched via a bare
executablePath(playwright.config.ts:26-34—launchOptionscarries noargs) can fail to launch there. A launch failure reds the gate loudly — not a false green — but it would present as “gate is flaky/broken in CI” on the first T3 run. Resolve launch args (--no-sandboxvialaunchOptions.args, orchromiumSandbox: false) by first-run evidence — the same decide-by-measuring posture as the closure-weight fallback below. - Risk to verify during implementation: nixpkgs
chromiumis a heavy closure; if realizing it in the parity gate is unacceptable, fall back to theenv-block patterndevenv.nixalready uses for the WebKitGTK closure (devenv.nix:141-145) plus an explicit workflow-level nix build — decide by measuring the first CI run, not up front.
T4 — red→green proof
Section titled “T4 — red→green proof”On the implementation branch, temporarily delete the optimizeDeps.include
block (vite.config.ts:44-49), run moon run compass-ui:dev-smoke, and record
the failure output; restore the block, re-run, record the pass. Both
transcripts go in the implementation PR body. Never committed — the broken
state exists only in the working copy between the two runs.
With T1’s failure-path ordering, the red transcript names the broken module
(debug/extend) rather than a bare locator timeout — that naming is part of
what T4 records.
Interfaces:consumes T1+T2; produces the two transcripts in the PR body.- Test cycle: IS the test cycle — red on the RIG-1535-era config, green on
current
main’s.
- T1:
apps/ui/e2e/dev-boot.spec.ts— headless boot spec (wait for the.bridgemount selector AND zeropageerror, with#root-non-empty as the redundant secondary guard; pageerrors reported even on a mount timeout), passing locally viabunx playwright test e2e/dev-boot.spec.ts. - T2:
apps/ui/moon.yml—dev-smoketask (explicitinputs+cache: false) wired intoci.deps;moon run compass-ui:dev-smokegreen and uncached;moon ci :cischedules it on a synthetic lockfile-only diff. - T3: CI Chromium —
chromiumindevenv.nixpackages;PLAYWRIGHT_CHROMIUM_PATHexported in the workflow; parity gate green;compass-ui:dev-smokegreen in a real CI run. - T4: red→green proof — failure transcript on the de-pinned config, pass transcript on the restored one, both in the PR body.
Resolved decisions
Section titled “Resolved decisions”All three ratified by Matt at the design-PR gate; the Approach above is written to each. The design-critic red-team found no genuine fork here — each carried a validated recommendation, and Matt ratified the set.
- Guard shape: headless boot (Shape B) alone (Matt, 2026-08-18). B covers
the whole “dev serves, app doesn’t boot” class, not just CJS-interop holes;
the module-graph crawl (Shape A) is ~1.8s and browser-free but a bespoke
crawler over Vite serving internals that only catches the one class, and B
subsumes its verdict today. Accepted cost: a CI browser and some flake
surface. Named residual: a CJS rot in a LAZILY-loaded chain (e.g. shiki at
highlighter.ts:38-56) exits B’s static-boot coverage; Shape A stays the documented fallback if the CI browser cost is later vetoed. - Tooling: reuse the Playwright harness (Matt, 2026-08-18) — not the
issue’s
puppeteer-core. The repo already has@playwright/test1.62.1, aplaywright.config.ts, and a working spec;puppeteer-coreis not a dependency, and adopting it would plant a second browser-automation convention for zero capability gain. The issue’s puppeteer suggestion predates the repo’s Playwright adoption. - CI wiring:
dev-smokeincompass-ui:ci(Matt, 2026-08-18) — every affected UI PR, every main push, and nightly, not full-sweep-only. The boot cost is a dev-server start + one page load (tens of seconds) inside a job whose timeout is 90m for nix builds (ci.yml:107-109) — negligible marginal cost, and a PR that breaks dev boot should red on the PR, not a day later. Named residual:dev-smoke’s explicitinputs(T2 —build’s list plus the spec andplaywright.config.ts) make abun.lock-only dependency bump schedule the gate directly, but a repo-external rot (nothing changed in this project at all) is caught only by the main-push/nightly full sweep — the designed backstop (ci.yml:31-36).
Assumption designed against (flagged, not blocking): nixpkgs chromium is
buildable/substitutable on the CI runner within acceptable time; T3 carries the
measured fallback if not.