Archived snapshot. This is the parity harness we used to port Maple's web app from React to Foldkit, extracted from MapleTechLabs/maple@da83971 after the cutover deleted it (#1589). It imports Maple workspace packages (
@maple/backend,@maple/domain,@maple/query-engine, ...) and does not build on its own. Paths below are relative to the Maple monorepo. Read it as a reference for building your own harness.
Pixel and behavior comparison of the legacy React frontend (apps/web) against the Foldkit
frontend (apps/web-foldkit). It is the acceptance test for the migration
(docs/foldkit-migration.md §3): a screen counts as ported when its scenarios come back identical.
There is no root script. Run everything from the repo root with:
bun run --cwd packages/ui-parity parity <command>parity build legacy # apps/web at LEGACY_BASELINE_REF (src/config.ts), in a pinned worktree
parity build legacy-head # apps/web from the working tree
parity build foldkit # apps/web-foldkit from the working tree
parity run # capture legacy and foldkit, diff, write the report| Target | Source | serve port |
|---|---|---|
legacy |
apps/web at LEGACY_BASELINE_REF, detached worktree in ~/.cache/maple-ui-parity/worktrees |
base + 1 |
foldkit |
apps/web-foldkit (working tree) |
base + 2 |
legacy-head |
apps/web (working tree) |
base + 4 |
local-legacy |
apps/local-ui at LEGACY_BASELINE_REF (the same pinned worktree as legacy) |
base + 5 |
local-legacy-head |
apps/local-ui (working tree) |
base + 6 |
local-foldkit |
apps/local-ui-foldkit (working tree) |
base + 7 |
- Local mode. The
local-*targets build apps/local-ui and its Foldkit port. They only capture scenarios withapp: "local"(src/scenarios/local.ts), and web targets only the others; a run across apps is refused. local-ui calls its server same-origin (/local/query,/local/status), so captures forward those canonical-origin paths to the fixture backend, which answers them fromsrc/backend/local.ts: the CLI's own query guard (apps/cli/src/server/query-guard.ts) on chDB, one database per datasetlocalstore (rows under OrgIdlocal). Calls are logged aslocal.query(payload: the SQL) andlocal.status, which is logged once per capture because its poll count follows wall time. Faults use the same tags. - The pinned worktree is created on first use:
git worktree add --detach,bun install --frozen-lockfile,bun run --cwd packages/effect-sdk build. A.parity-setup-donemarker makes a half-done setup retry. - Builds are production
vite builds with every URL pointed at the fixture backend (buildEnvinsrc/config.ts):VITE_API_BASE_URL,VITE_API_PUBLIC_URL,VITE_INGEST_URL,VITE_MAPLE_SELF_INGEST_URL= backend origin,VITE_ELECTRIC_SYNC_URL= Electric origin,VITE_MAPLE_REPLAY=off,VITE_MAPLE_CAPTURE=off, a fakepk_testClerk key. - Vite runs with a wrapper config (
src/runtime/vite-wrapper.ts, written to the app'snode_modules/.parity/). It pointsenvDirat an empty directory so no.env*file leaks in, and adds the lab-gate plugin (below).PORTLESS_URL,VITE_COMMIT_SHA,VITE_MCP_URLandVITE_MAPLE_REGION_APP_URLSare stripped from the environment. - Auth flavors.
--auth self_hosted(default) or--auth clerkbuilds with thatVITE_MAPLE_AUTH_MODE; the Clerk flavor lands in<build>--clerk/. A capture picks the flavor from its dataset'sauth. Scenarios on a Clerk dataset need that flavor:run,selfcheckandcapturebuild a missing one first from the self-hosted build's stamp (same target, same pinned ref; working-tree targets from the tree as it is then). An existing Clerk flavor is not rebuilt, so after changing the app runparity build <target> --auth clerkyourself. - Custom outputs.
--out <name>builds into.parity/builds/<name>/, and every command that takes a build (run --baseline/--candidate,selfcheck --target,capture) accepts that name. Use it to compare a working-tree change against a clean build of the same target.
apps/web/src/routes/lab/route.tsx throws notFound() unless import.meta.env.DEV. Parity builds are
production builds, so the wrapper config adds a pre transform that rewrites import.meta.env.DEV
to true in that one file only. A global DEV define would also switch on the dev-only banners, the
lab dials panel and the service map's layout debug overlay. No apps/web source changes.
- One origin. Every build is loaded through
http://localhost:4800, fulfilled inside Playwright straight from the build directory (nothing binds it), so anything printinglocation.originmatches across builds and port bases. Requests to any other origin than the fixture backend are aborted and listed asblockedRequests. - Ports. Offsets from
PARITY_PORT_BASE(default 4790): backend+0, legacy+1, foldkit+2, Electric+3, legacy-head+4. Give each worktree its own base (PARITY_PORT_BASE=4900). Bases that reach a port Chromium refuses (or 5000/7000, macOS AirPlay) are rejected. Builds embed the backend URLs, so a non-default base builds into.parity/builds-<base>/and a build made for another base is refused. - Fixture backend.
src/backend/index.tsexportsstartBackend(the seam issrc/contract.ts). Every request to it carriesx-parity-dataset(the scenario's dataset) andx-parity-capture(<label>/<variant>), so the backend can attribute calls. Until the module exists the harness runs with a null backend: API and Electric requests are answered 404 and listed asunmocked, which is reported but does not fail a variant, so the backend-free/labscenarios still work (PARITY_NULL_BACKEND=1forces this mode even when the backend exists). Scenarios that name a dataset fail with "needs the fixture backend". With a real backend, any unmocked request fails the variant. - Auth.
self_hosteddatasets get the token in sessionStoragemaple.self_hosted.token(apps/web/src/lib/services/common/self-hosted-auth.ts). The token (selfHostedTokenForinsrc/runtime/session.ts) is an unsigned JWT carrying the dataset'sorg_id, whichsrc/backend/auth.tschecks, withexp30 days past the frozen clock so the app never renews it.clerkdatasets getwindow.Clerkfromsrc/runtime/clerk-stub.ts:@clerk/clerk-react5 finds it loaded, never downloads clerk-js, and hydratesuseAuth/useUser/useOrganizationfrom itsaddListeneremit. Members the app reads that the stub lacks are recorded per capture asclerkUnknown. - Theme. localStorage
maple-theme=light|dark(the pre-paint script inapps/web/index.htmland@maple/ui/hooks/use-theme), plus Playwright'scolorScheme. - Determinism (
src/runtime/stabilize.ts). Frozen clock (dataset.now, orNO_DATASET_NOWwithout a dataset), UTC, en-US,deviceScaleFactor: 1,reducedMotion, seededMath.random, intervals clamped to one per frame, CSS animations and transitions frozen at their end state, infinite Web Animations paused after their first iteration, SMIL at t=0, carets and scrollbars hidden, service workers blocked. A capture waits for fonts and every image to decode, then 400ms without DOM mutations, then two consecutive frames in which no scroll offset, scroll extent, image or canvas pixel hash changes (charts draw on<canvas>). Then the screenshot is retaken until two in a row are byte-identical. Full-page captures (fullPage, default true) first grow the viewport until no page-sized inner scroller overflows (h-screenlayouts scroll a container, not the document), up to 12,000 px, settling after each step. WebGL canvases withoutpreserveDrawingBufferread back blank, so only their layout counts. - Healthy captures only. A capture that crashes, fails a step, logs console errors, or (with a real backend) hits an unmocked endpoint fails its variant even when the pixels match.
- Behavior. The call log as of the screenshot (
FixtureBackend.takeCalls) is stored in<variant>.jsonascallsand diffed as a multiset per tag (src/behavior.ts): order ignored, keys sorted, v4 UUIDs replaced by<random-uuid>. Any difference fails the variant.Scenario.pageCallslists tag prefixes (warehouse:,shape:alert_) left out of the diff while a shell scenario's page is still a placeholder; the shell's own calls still count. - Accessibility.
bodyARIA snapshots are diffed as multisets of lines and reported in an "a11y deltas" section. Report-only unless--strict-a11y. - Two kinds of diff (
src/compare.ts). Pixels: strict (any channel) and perceptual (pixelmatch, anti-aliasing ignored), clustered into regions. Structure: every visible text run and control with its box and the computed styles that decide its look, matched by text or role + name (never DOM shape). Identical style changes are grouped into root causes, e.g.{padding-left: 16px → 12px} on 9: button "Save" | ..., then knock-on moves.
parity build legacy [--ref <sha>]
parity selfcheck --target legacy # must print "All N variants deterministic."Run selfcheck again after adding scenarios. A flaky scenario cannot tell you whether a port is correct.
parity build foldkit && parity run --filter lab-infra- Read
.parity/runs/<run>/summary.md: worst variants first, root-cause style groups, moves, then elements missing from or extra in the candidate. - Fix the Foldkit view and repeat until the variant is
identical. - Open
index.htmlin the same run folder for swipe, onion skin, blink, side-by-side and diff (keys1-5,j/kto step). Click a red region to highlight the elements responsible.
parity compare --run <run> re-diffs captures without capturing again. --filter takes
comma-separated terms; a variant matches when any term equals its area (lab, routes) or is a
substring of its id (lab-infra--desktop--dark): --filter route,lab,kit. The exit code is
non-zero on any fail or missing.
parity build legacy-head # clean
# edit a class in packages/ui, then:
parity build legacy-head --out legacy-head-edit
parity run --baseline legacy-head --candidate legacy-head-edit --filter lab
# revert the editEvery capture writes <variant>.html, the rendered DOM with sorted attributes.
parity codegen <file.html> --line 120 --name header turns a subtree into Foldkit h(...) code with
class strings kept verbatim.
parity serve [--dataset default] starts the fixture backend and serves every built target on its
port, signed in as the dataset's user. The clock is not frozen there.
parity coverage lists every route in apps/web/src/routeTree.gen.ts at LEGACY_BASELINE_REF
(read with git show, /lab excluded) with the scenarios that visit it. parity list prints every
scenario × viewport × theme variant with its dataset.
Add it to the area file src/scenarios/<area>.ts (an AreaModule) and list a new area in
src/scenarios.ts. Steps must use accessible locators (getByRole, getByText) so the same script
drives both apps; a step that works in one app and not the other is a parity bug. Datasets belong to
the fixture backend (src/fixtures/); a scenario names one by dataset.
Scenario options beyond path, dataset and steps (src/scenarios/types.ts):
targets: the targets the screen exists in.run,selfcheckandcaptureskip the scenario unless every build they capture is one of them (data-probeis["foldkit"]).faultsandsettle: timed and error states, below.pageCalls,fullPage,mask,viewports,themes.
Two areas are generated rather than listed:
lab: everysession: "none"entry ofapps/web/src/lab/registry.tsplus/lab, parsed from the registry source so new lab pages are picked up, desktop, light and dark, signed in on datasetdefault(the root shell calls the API on lab pages too).lab-canvas.tsadds interaction states.routes: core app routes on datasetdefault,/sign-inonsigned-out, and the session-only/lab/query-builder. They pass once the fixture backend serves those datasets.
Each product area's fixtures live in src/fixtures/areas/<area>.ts exporting fixtureArea
(src/fixtures/areas.ts), discovered at startup like the scenario areas, so parallel work never edits
a shared file: built-in endpoint answers for every dataset (a dataset's own endpoints still win),
rows appended to the default dataset's Electric tables, extra datasets, and raw routes outside the
HttpApis (chat SSE). Two areas answering one endpoint is a startup error.
Faults (src/contract.ts) delay or fail HttpApi endpoints by tag prefix, the longest prefix
winning: delays: { "queryEngine.executeBatch": 60_000 } holds matching requests (after logging
them), failures: { "queryEngine.executeBatch": { status: 502, body: { _tag, message } } } answers
with that encoded error, which the app decodes when the endpoint declares it. A dataset's faults
apply to every scenario on it; Scenario.faults travel in the x-parity-faults header and override
them per prefix. Raw routes are matched by their tag (chat.history); a raw route's request body is part of its logged payload. Electric shapes are not covered.
refusals: ["local.status"](local targets) aborts matching local server requests asconnectionrefusedin the capture, before the backend sees them, as whenmaple startis not running (local-traces-disconnected). They are not in the call log, and Chromium'snet::ERR_CONNECTION_REFUSEDconsole line is expected for them.settle: "held"captures a loading state: before each settle point the capture waits until every request in flight to the API is one the backend is holding (FixtureBackend.held), then settles visually as usual. The default"quiet"would also work most of the time, but could screenshot while an unheld request is still arriving.- Chromium's
Failed to load resource ... status of <n>console line is expected for a status the scenario injects and does not fail the variant (scenario-levelfailuresonly). - Examples:
services-loadingandservices-errorinsrc/scenarios/services-page.ts.
bun run test runs the pure suites (call log, faults, behavior diff) and is what CI runs.
bun run test:backend starts the fixture backend (needs ~/.maple/bin/libchdb.so and apps/api
installed) and checks every endpoint family; run it locally after backend or fixture changes.
.parity/ (gitignored by its own .gitignore):
builds/<build>/: static builds, with.parity-build.json(target, ref, base, auth mode)runs/<run>/<label>/<variant>.png|.json|.htmland_results.jsonruns/<run>/diff/: pixelmatch diff imagesruns/<run>/summary.md,summary.json,index.html
- Chromium only, and font rendering differs across OSes: compare captures made on the same machine.
- The
routesarea and the Clerk flavor need the fixture backend; the Clerk stub no-ops Clerk's prebuilt components (mountSignInand friends), so those containers capture empty. lab-worst-casecan flake under load:span-hierarchy.tsx's scroll-to-selected loop stops on estimated row heights unless ResizeObserver measured the rows first, leavingscrollTop96 px short. An app race; freezing onlyDate(real rAF instead ofpage.clock's timer) did not fix it and made the service-map fit race (service-map-canvas.tsxmarks the fit done before its rAF) flaky. Foldkit's port (waterfall/virtualize.ts) races twice: its attempts read a DOM one render behind the virtualizer (legacy'sflushSynckeeps them in step), and after the latch the rows below the fold grow 8 px each, with or without a scroll adjustment. Waiting for a settled DOM after each scroll write reproduces legacy's write sequence (1451, 1515, 1921) in every capture, but the post-latch growth still varies, so the port stays verbatim.dashboards-view-behind-reconnectsendsshape:dashboardstwice where legacy sends it once: a documented legacy-bug divergence. Legacy'suseLiveQuerykeys a query by collection id, and the recreated set keeps the same ids, so legacy goes on reading the old collections and Reconnect does nothing visible. Foldkit resubscribes on purpose; the extra call is the expected behavior diff.perffromdocs/foldkit-migration.md§3 is not ported yet.