Plan: Test-Seed Harness Refactor (Issue #450)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Add |
Done (2026-05-13) |
2 |
Single source-of-truth seed call. |
Done (2026-05-13) |
3 |
Predicate-based Playwright fixture helpers. New |
Done (2026-05-13) |
4 |
Documentation. Update |
Done (2026-05-13) |
5 |
Verification. |
Done (2026-05-13) |
Issue: #450
Branch: feat/test-seed-harness-refactor
Labels: type::chore, priority::medium, service::web, service::xtask, service::seed, workflow::ready
Context
The 2026-05-12 push cycle for #392 (worker-portal program-action handlers) and the following #448 batches surfaced a recurring flake: Playwright assertions referencing seed.<table>.<row0>.<id> fields against seed.ts would silently match a manifest UUID that no longer existed in the database, producing assert!(panel).toContainText('provider-001')-style failures that looked like real product bugs.
The root cause was a two-entry-point seed pipeline:
-
cargo xtask seed --households 50 --seed 42regenerated both the SQL files AND thetests/e2e/lib/seed.tsmanifest, then loaded the SQL into the running devstack DBs. This was the manual debugging path. -
cargo xtask e2eALSO invoked the canopy-seed binary internally with its own defaults (--households 9, no seed) BEFORE running Playwright — regenerating the manifest but NOT reloading the DB. The manifest UUIDs would then point at rows that didn’t exist in the DB (becausextask seed --households 50had loaded 50 households' worth of rows under a different UUID series).
Workaround during the session was cargo xtask seed --households 50 --seed 42 && cargo xtask e2e --no-refresh. Documented in the feedback_seed_args_match memory.
This plan replaces the dual-entry-point with a single source of truth, captures the resolved seed at every generation, and migrates Playwright fixtures off manifest-positional UUID access so the failure class is architecturally impossible.
Code references
-
xtask/src/cmd/seed.rs:70— currentxtask seedentry point; spawnscargo run -p canopy-seed — --households {N} --seed? {N} --manifest tests/e2e/lib/seed.ts. -
xtask/src/cmd/e2e.rs:80-110— currentxtask e2eentry point; also spawns canopy-seed with its own defaults (--households 9historically). -
tools/canopy-seed/src/main.rs:60-72—--manifestflag handling; no seed-capture-to-file today. -
tools/canopy-seed/src/manifest.rs— manifest renderer. -
tests/e2e/lib/seed.ts— auto-generated positional UUID index; consumed by specs viaseed.<table>.<row>.<field>. -
tests/e2e/specs/caps.spec.ts:14— example positional access; this assertion was rewritten during #396 (caps-provider-registry) because the literal "provider-001" UUID-equivalent no longer existed after the FK retype. -
ADR-016 — no schema relevance, but the test-seed harness is the development analog of the production forward-only discipline (the seed pipeline must produce the same DB state given the same inputs).
Scope
In scope:
-
All three layers from the issue body (Layer 1 single source-of-truth, Layer 2 random-by-default + replay capture, Layer 3 predicate-based Playwright fixtures).
-
canopy-seed binary: new
--seed-outputflag, random default, seed stamping in the manifest. -
xtask:
seed.rswriteslast.txt;e2e.rsreadslast.txtand only regenerates when necessary. -
Playwright fixtures: new
tests/e2e/lib/fixtures.tspredicate helpers; migrate everyspecs/*.spec.tsreference to manifest-positional UUIDs. -
.claude/docs/testing.mdupdate.
Out of scope:
-
Database-side "snapshot/restore" for fast test reset (would require pg_dump/pg_restore round-trips per test class — a separate plan).
-
Cross-platform seed reproducibility (only Linux/Docker; macOS isn’t a target).
Dependencies
-
No prerequisite plans on disk.
-
The Playwright
auth/caseworker.jsonstorageState must work for the predicate-query helpers (it already does — every existing spec uses it). -
Devstack must be up + healthy before
xtask seed/xtask e2e(unchanged precondition).
Design
Layer 1: single source-of-truth seed call (Step 2)
xtask e2e stops invoking canopy-seed directly. Instead:
-
Reads
test-results/seed/last.txtfor the last-resolved seed + household count. -
If
--seed/--householdsflags are supplied AND differ fromlast.txt, callsxtask::cmd::seed::run(…)programmatically. -
Runs Playwright.
xtask seed keeps its existing CLI but always writes last.txt. The --households default is reconciled from the historical mismatch (9 in xtask seed, 50 in xtask e2e) onto a single shared constant. Pick 50 — the existing seed manifest already uses it for the SNAP UAT data and the per-program lifecycle rows from #398.
Layer 2: random seed + replay capture (Step 1)
canopy-seed gets a new --seed-output PATH flag. When set:
-
Resolves the seed:
args.seed.unwrap_or_else(|| rand::random::<u64>()). -
Writes
<seed>\n<households>\n<jurisdiction>\nto PATH atomically (write to PATH.tmp, thenrename). -
Stamps the manifest’s preamble:
/** Generated by canopy-seed with seed=N households=M jurisdiction=X */+export const SEED: number = N;. -
Echoes
Seed: N (replay with --seed N --households M).to stderr.
xtask seed passes --seed-output test-results/seed/last.txt by default; xtask e2e reads from that path.
Layer 3: predicate-based fixtures (Step 3)
New tests/e2e/lib/fixtures.ts:
import type { Page } from '@playwright/test';
export interface ApprovedDetermination {
id: string;
householdId: string;
personId: string;
program: string;
}
/**
* Find an approved determination for the named program via a live
* `GET /v1/determinations?household_id=<any>` against the program's
* service. Returns null when no approved rows exist in the seed (caller
* uses `test.skip(!result, ...)` to skip cleanly).
*/
export async function pickFirstApprovedDetermination(
page: Page,
program: 'snap' | 'tanf' | 'medicaid' | 'caps' | 'wic',
): Promise<ApprovedDetermination | null> { /* ... */ }
export async function pickAssessmentFor(page: Page, personId: string): Promise<{ id: string } | null> { /* ... */ }
export async function pickAuthorizationFor(page: Page, determinationId: string): Promise<{ id: string } | null> { /* ... */ }
Specs migrate from:
import { SEED } from '../lib/seed';
const wicDet = SEED.wicDeterminations.wicDet0;
await page.goto(`/cases/${wicDet.householdId}?program=wic`);
to:
import { pickFirstApprovedDetermination } from '../lib/fixtures';
const wic = await pickFirstApprovedDetermination(page, 'wic');
if (!wic) {
test.skip(true, 'no approved WIC determination in seed');
}
await page.goto(`/cases/${wic.householdId}?program=wic`);
The auto-generated seed.ts becomes a thin metadata file (seed value, household count, named fixture availability map). Pre-existing specs that reference manifest-internal positional structure (SEED.wicDeterminations.wicDet0) get rewritten.
Files Touched
| File | Change |
|---|---|
|
Add |
|
Manifest preamble updated to emit |
|
Pass |
|
Stop invoking |
|
Predicate-query helpers backed by live API calls. |
|
Migrate every positional UUID access ( |
|
Becomes a thin metadata file — seed value + household count + named-fixture availability map. |
|
New section on the |
|
Single entry covering all three layers. |
Verification
-
cargo xtask seed --households 50 --seed 42 && cat test-results/seed/last.txt— produces42\n50\ngeorgia\n(replay receipt). -
cargo xtask e2e(no flags) — uses `last.txt’s captured seed; passes 128/128 specs. -
cargo xtask e2e --seed 12345— different seed; reseeds the DB; passes 128/128 specs (or surfaces real failures if any exist for that seed — Layer 3 makes specs predicate-driven so positional drift can’t cause flakes). -
Running
cargo xtask seedtwice without--seedproduces two different seeds inlast.txt(random by default). -
cargo xtask validategreen. -
Manual:
cat tests/e2e/lib/seed.ts | head -3shows the seed stamp.
Documentation Updates
-
.claude/docs/testing.md—--seedreplay flow + predicate-fixture API +last.txtdebugging recipe. -
CHANGELOG.adoc— Tier A bundle entry covering all three layers. -
Update the
feedback_seed_args_matchmemory — note that the workaround is no longer needed post-#450. -
Plan archive: move to
plans/archive/post-merge.