Browser-functional test architecture for the results explorer¶
Status: Accepted - 2026-04-18
Scope: Locks the architecture decisions that gate implementation of the
browser-functional test layer tracked by
implement-results-explorer-browser-functional-tests.
Audience: Maintainers adding or modifying the explorer’s e2e/ suite.
This note records the four architecture decisions required by the TODO’s research gate. Each section states the decision, evidence from the current repo, rejected alternatives, and the implementation consequences that follow. No implementation starts on w2 or later until the decisions below are considered binding.
Current state the decisions must fit¶
Routes are defined by
preact-routerinresults-explorer/src/App.tsxunder a hard-coded/results/base prefix, including/results/compare,/results/query,/results/r/:resultId,/results/:benchmark/, and/results/p/:platform/.The app is built by Vite with
base: "/results/"(seeresults-explorer/vite.config.ts) and is deployed as a static SPA underbenchbox.dev/results/. Cross-origin isolation headers (Cross-Origin-Embedder-Policy,Cross-Origin-Opener-Policy) are set on the dev server; production serves similarly.DuckDB-WASM is the sole browser store for user-visible metrics (
results-explorer/src/db.ts). It lazy-loads a Vite-bundled same-origin worker/WASM bundle, attaches/results/data/results.duckdbas read-only viaATTACH ... (READ_ONLY), and has no JSON fallback - any attach failure surfaces to the user.The DuckDB snapshot is produced by
_project/scripts/explorer_pipeline/pipeline.pyviauv run -- python _project/scripts/explorer_publish.py build --data-dir <bundles> --output <dist-data>.Local
npm run devfirst runsnpm run dev:snapshot, which rebuildsresults-explorer/public/data/results.duckdbwhen the local snapshot is missing or older thanresults-data/bundles/or the Explorer publish pipeline. SetEXPLORER_SKIP_PREDEV=1only when intentionally testing a missing or stale snapshot error path.Unit and component coverage lives under
results-explorer/src/__tests__/,results-explorer/src/components/__tests__/,results-explorer/src/lib/__tests__/, andresults-explorer/src/pages/__tests__/. Vitest runs in jsdom - no real browser, no real WASM worker, no realWorker/BlobURL creation.The only prior real-browser verification work is
verify-trust-badge-rendering-in-explorer(done 2026-04-12). It used an ad-hoc local Playwright-driven check plus a throwaway fixture branch and explicitly discarded the branch in itsscope_limit. There is no durable harness, fixture generator, or CI gate yet.The production archived handoff (
_project/_archive/handoffs/handoff-results-publishing-phase2.md) notes cross-browser acceptance stayed manual through Phase 1 launch.
Decision 1 - Built app as the test target¶
Decision. The browser-functional suite runs against the production build
output (results-explorer/dist/) served by a Playwright webServer, not
against vite dev.
Why.
DuckDB-WASM is code-split into its own chunk by
vite.config.ts’smanualChunks.duckdbrule. Dev-mode ESM module graph loading behaves differently from the built chunked output, so asset resolution and worker URL construction are only validated indist/.The
/results/base path, theATTACH ... (READ_ONLY)URL derived indb.tsvianew URL("/results/data/results.duckdb", window.location.origin), and relative asset references are all path-sensitive and only match production when Vite has emitted built assets under/results/.The dev server re-transforms modules on request and injects HMR - that masks real browser MIME and caching behavior, and it cannot reproduce the same-origin worker/WASM asset URLs that DuckDB-WASM resolves in the built explorer.
must_preserverequires that “the explorer continues to run correctly under the/results/base path” and that deep links stay valid. The only way to verify that claim per run is to load the compileddist/under the real prefix.
Rejected alternatives.
Run against
vite dev. Fast to start but masks chunking, base-path, and worker behavior (the exact surface we need to cover).Run against a previewed dev build (
vite preview). Acceptable in principle - it servesdist/- but it does not allow us to swap in a test-only/results/data/directory without rebuilding. We need that swap to be cheap per-test-run (see Decision 2), so we frontdist/with a tiny static wrapper instead.
Consequences.
The harness runs
npm run buildonce per Playwright run (Playwright’swebServerblock handles this), then servesdist/through a minimal static server that routes/results/data/*to the generated fixture corpus and everything else todist/.Suite startup is slower than dev-server tests. That is the right tradeoff for a quality gate; developers keep Vitest as the fast inner loop (explicit in
must_preserve).Playwright
webServerwithreuseExistingServer: truelocally keeps iteration tolerable once the first build lands.
Decision 2 - Generated fixture corpus in an isolated workspace¶
Decision. Each test run regenerates its fixture DuckDB snapshot into
results-explorer/test-fixtures/.generated/ (gitignored). The static
server mounts that directory at /results/data/ for the duration of the
run. results-explorer/public/data/ and results-data/bundles/ are not
touched.
Why.
results-explorer/public/data/bundles/andresults.duckdbare the committed curated corpus.must_preserveandscope_limit.do_not_modifyexplicitly forbid mutating them. Tests that need compare-invalid cohorts, mixed trust labels, or missing sidecars would otherwise have to either pollute that data or depend on whatever happened to be checked in at test time.results-data/bundles/is the public curated source - same prohibition.The pipeline already has a supported data-in / data-out shape:
uv run -- python _project/scripts/explorer_publish.py build --data-dir <in> --output <out>reads bundles from any directory and writesresults.duckdbplus copied bundle downloads to any directory. We do not need a new seam; we need a thin fixture generator that:Copies canonical schema-v2 bundles (and companion
.plans.json/.tuning.json/<result>.manifest.jsonsidecars) from a small committed source set underresults-explorer/test-fixtures/source/into an ephemeral staging dir.Applies controlled metadata mutations in memory per variant (trust label, tuning mode, sidecar presence, compare-invalid mismatches).
Runs
uv run -- python _project/scripts/explorer_publish.py buildagainst the staging dir, writing intoresults-explorer/test-fixtures/.generated/data/.
Mutating metadata in an ephemeral staging copy - rather than via the published corpus - means the generator can produce compare-invalid cohorts (benchmark mismatch, scale mismatch) without violating the ≥3-platform depth invariant that
launch-results-explorer-acceptance-and-seedpinned on the real corpus.
Rejected alternatives.
Mutate
results-explorer/public/data/during test setup and restore after. Explicitly forbidden bymust_preserve. Also fragile: a test crash mid-run leaves the public corpus in a bad state and can leak into commits.Commit a second fixture DuckDB file. Binary artifacts decay, do not round-trip pipeline changes, and couple the suite to whatever version of
explorer_pipeline/duckdb_builder.pybuilt them. Regenerating forces each run to exercise the actual pipeline - which is the real contract under test.Mock
getDb()to return a stub. That is what Vitest already does. The missing coverage is specifically the real WASM worker, real HTTP attach, and realATTACH READ_ONLYbehavior. Mocking defeats the point.
Consequences.
results-explorer/test-fixtures/source/is committed (small, curated - one bundle per benchmark we need plus a compare-invalid cohort).results-explorer/test-fixtures/.generated/is gitignored.results-explorer/test-results/(Playwright artifacts) is also gitignored.A small Python glue module under
_project/scripts/explorer_pipeline/is permitted if a test-only metadata mutator is needed, but the first slice prefers a pure-fixture approach where the committed source bundles already encode each variant and the fixture generator just re-runs the pipeline.The static server used in Decision 1 routes
/results/data/→ the generated directory. That makes the fixture swap a directory mount rather than a file copy.
Decision 3 - Chromium blocking, Firefox/WebKit smoke only (initially)¶
Decision. Chromium runs the full suite and is a blocking gate in CI from
the first merge. Firefox and WebKit run only tests tagged @smoke and
are non-blocking for two weeks of stable data, after which they graduate
based on observed flake rate.
Why.
anti_patternswarns: “DO NOT make all three browsers blocking before flake characteristics are known.” DuckDB-WASM has known browser-specific loading quirks (the archived handoff calls this out explicitly), and WebKit’sWorker/SharedArrayBuffersupport varies by version.The phase-1 launch acceptance (
launch-results-explorer-acceptance-and-seedw2) treated cross-browser as manual per-release. That is the residual manual pass we keep (w6). The automation’s job is to catch Chrome-class regressions deterministically and give us Firefox/WebKit early-warning signal, not to block merges on WebKit flake.Our WebKit coverage can only run on macOS runners. Making WebKit required pins the gate to a specific runner family and adds queue pressure.
Rejected alternatives.
All three browsers blocking from day one. High false-failure rate risk, no prior flake data for this app, and likely to stall adoption.
Chromium only, forever. Misses Firefox and WebKit regressions until manual release check catches them - which is the current state we are explicitly trying to improve.
Chromium + Firefox blocking, WebKit smoke. Firefox does have the fewest known DuckDB-WASM quirks, but we still lack flake data. Stage Firefox to blocking only after real CI signal.
Consequences.
Playwright
projectsconfig declares three projects:chromium(default grep),firefox(grep@smoke),webkit(grep@smoke).Tests meant for cross-browser smoke coverage are tagged
@smokein their titles. New happy-path route tests start Chromium-only; we promote them to@smokeas they prove stable.CI has three jobs:
e2e-chromium(required),e2e-firefox(optional / warning only),e2e-webkit(optional / warning only, macOS runner).w7 explicitly gates the cross-browser promotion decision on repeatable runs, not on a single green CI job.
Decision 4 - No production seam for failure injection; use Playwright routing¶
Decision. All required failure-path coverage is achieved through
Playwright’s page.route() interception and context capabilities. No
test-only code path, feature flag, or bypass is added to the production
bundle.
Why.
The error surface the suite must prove is already user-visible:
db.tsrejectsgetDb()whenATTACHfails, and pages are documented to “surface the error to the user rather than silently rendering empty state.” We test that user-visible state, not the internal exception.The failure modes the TODO enumerates all map cleanly to HTTP-level interception:
DuckDB snapshot missing/corrupt/unreachable → intercept
/results/data/results.duckdband return 404, truncated bytes, or network abort.Tuning-config sidecar fetch failure → intercept the sidecar URL and return 404 or 500.
Online/offline →
browserContext.setOffline(true)plus the existingwindow.addEventListener("online")retry logic indb.ts.Clipboard/share URL →
context.grantPermissions(['clipboard-read', 'clipboard-write'])and assert vianavigator.clipboard.readText().Download flows →
page.waitForEvent('download')withdownload.saveAs()into the artifact directory.
must_preserverequires that the query workbench stays read-only and that no test-only writable bypass is introduced. The cleanest way to guarantee that is to add no seam at all.A production seam would also drift: tests passing against the seam could diverge from real browser behavior, which is the jsdom problem we are already trying to solve.
Rejected alternatives.
Add a
?test=1query-param branch indb.tsthat loads a fixture URL. Pollutes production code with test-only conditionals and risks leaking into shipped builds.Inject a window global for the harness to toggle error modes. Same objection; also a supply-chain footgun if any production code ever reads it.
Build a parallel “test mode” bundle. Doubles the build matrix and invalidates the point of testing the production build.
Consequences.
Failure-path tests live alongside happy-path tests; each uses
page.route()helpers defined inresults-explorer/e2e/support/.Read-only SQL enforcement is verified by running
INSERT/UPDATE/DROPthrough the Query workbench UI and asserting the user-visible error, not by poking at internals.If a future feature genuinely needs a seam (e.g., slow-network simulation that Playwright cannot express), that’s a separate decision and requires its own note here.
Decision summary¶
# |
Question |
Decision |
|---|---|---|
1 |
Built app vs dev server |
Built |
2 |
Fixture corpus location |
Generated per-run into |
3 |
Browser matrix |
Chromium full + blocking. Firefox & WebKit |
4 |
Failure-injection seam |
None. All failure paths covered via Playwright |
Implementation gating¶
w2 may begin once this note is merged. w2’s
playwright.config.ts,webServerblock, andprojectslist must match Decisions 1 and 3.w3’s fixture generator must match Decision 2; it must not write outside
results-explorer/test-fixtures/.generated/.w5 must rely on the interception surface in Decision 4 and must not introduce a production-code seam. Any deviation requires an update to this note and re-approval.
w6 must not promote Firefox or WebKit to blocking without recorded flake data; the graduation criteria are part of w7.