Testing Architecture
The foundation for every test in CareerVector. Read this once; write tests against the layers it defines.
Mission
Per wiki/content/architecture/PRINCIPLES.md §4: tests are the spec, not regression guards. The product's behavior is what we test; the implementation is what we refactor. A test that breaks when an internal helper is renamed is poorly written. A test that breaks when the user-visible behavior changes is doing its job.
CareerVector has shipped many architectural waves — multi-actor primitives, IDB persistence, Y.Text collaborative prose, pipeline stage extraction, structured provenance, the human-write protection gate, SSE fanout — and the existing test surface has grown unevenly under those waves. The job of this architecture is to give every future change a layer to land its tests in, so coverage stays honest as the product evolves.
Layered taxonomy
Tests are organised by what the test is proving, not by what file the implementation lives in. A "drag a job to Applied" test is a story-level spec regardless of whether the implementation is React, Svelte, an op envelope, or a hand-rolled dispatchEvent. Refactor the inside; the test stays.
| Layer | Directory | What it proves | Vocabulary |
|---|---|---|---|
| L1 Story specs | ui/e2e/stories/ |
A user-visible scenario in Given/When/Then form | User's vocabulary: workspace, job, "applied", "tailored CV" |
| L2 Multi-actor convergence | ui/e2e/multi-actor/ |
N actors (browser tabs + MCP + agent fallback) converge to one canonical state | Actors, ops, convergence |
| L3 Persistence + recovery | ui/e2e/persistence/ |
Writes survive disconnect, reload, IDB cycle, batch atomicity | Writes, reload, durability |
| L4 MCP contracts | ui/e2e/mcp-contracts/ |
MCP tool calls produce expected workspace state; concurrency follows the broker contract | Tool name, arguments, product state |
| L5 Performance budgets | ui/e2e/perf/ |
Per-surface latency caps; CI gate | p50/p95/p99, TTI |
| L6 Visual regression | ui/e2e/visual/ |
Layout-critical surfaces don't drift unintentionally | Snapshots, viewports |
| L7 Real-world journeys | ui/e2e/journey-real/ |
Real URLs / real LLMs / full pipeline. Gated by env vars to stay free | End-to-end, real network |
Below this sit the package-internal vitest suites in packages/<name>/tests/ and apps/<name>/test/. Those are framework-agnostic, they always run, and they're where pure logic / op invariants / SDK contracts get their first proof.
The seven layers above are the new, named, semantic surface. Each layer has a directory, a helper module, a reference test, and a CI policy.
L1 — User story specs
A story spec is a single user goal, written in Given/When/Then form, using the user's vocabulary. No implementation details. No selectors in the spec body — selectors live in helpers.
Format
storySpec({
persona: 'solo applicant',
goal: 'track a job through pipeline stages',
given: 'a workspace with one imported job',
when: 'I drag the job to "Applied"',
then: [
'the job shows in "Applied" on this tab',
'the job shows in "Applied" after a reload',
'a collaborator opening the same workspace sees the job in "Applied"',
],
}, async ({ workspace, dashboard, secondTab }) => { ... });
The storySpec helper (see ui/e2e/helpers/stories/story.ts) wraps Playwright test() with a structured header that becomes the spec's title and prepares the shared fixtures (workspace, dashboard, secondTab, etc.).
Index
Story specs are indexed by persona × goal. Personas are the actor types we design for:
| Persona | Description |
|---|---|
solo applicant |
One human, one workspace, one device. The MVP user. |
family collaborator |
Multiple humans (e.g. parent + child) on the same workspace. Editing is concurrent. |
agent operator |
Human in a chat client (Claude.ai, ChatGPT) driving the workspace via MCP. |
automated agent |
Headless MCP tool calls; no human in the loop. |
read-only viewer |
Someone with a /view/... URL only. Edits should be invisible / blocked. |
Goals are user intents, not features. "Track a job through pipeline stages" is a goal; "drag-and-drop functionality" is a feature. Test goals.
Anti-patterns
- DO NOT put
data-testidstrings in spec bodies. Helpers do the resolving. - DO NOT mention
Y.Doc,applyOp,batch,nanoidin spec text. Those are implementation. - DO NOT chain unrelated assertions into one story. One goal per story.
- DO NOT rely on global state across stories. Every story creates its own workspace.
Why a separate layer
Existing e2e specs (cvl-reorder.spec.ts, journeys.spec.ts, qa-gate.spec.ts) are valuable but mix three things: smoke verification, behavior assertion, and bug-pinning regressions. Story specs separate the user contract from the smoke layer (qa-gate stays for that) and from the bug-pin layer (known-bugs/ stays for that). A story spec is the spec form of the product; if it doesn't pass, the product is broken at the user level.
L2 — Multi-actor convergence harness
V3 realtime is the product's hardest claim. Multiple actors edit the same workspace concurrently and converge on one canonical state. Today most tests are single-actor; the few multi-actor specs are mostly single-tab + single-API-write.
The convergence harness spawns N "actors" — each one a Playwright BrowserContext, an MCP HTTP client, or a server-fallback simulator — and runs an interleaved op script against them. After draining, it asserts every actor's view of the workspace matches a single canonical answer.
Actor types
| Actor type | Implementation | Purpose |
|---|---|---|
BrowserActor |
Playwright BrowserContext + Page |
Realistic user tab. Holds a Y.Doc, has IDB, opens WS per V3 Rule 2. |
McpActor |
Headless HTTP client to mcp worker |
Tool calls land via the agent path. |
ApiActor |
Direct request.post to /api/workspaces/.../ops |
Skips MCP. Tests the bare op surface. |
FallbackActor |
Direct DB write via wrangler d1 |
Simulates a write that bypassed realtime (offline-queue replay, manual SQL fix). |
Convergence test shape
multiActorTest({
actors: ['browser-a', 'browser-b', 'mcp', 'api'],
setup: async ({ workspace, actors }) => { ... },
operations: [
{ actor: 'browser-a', op: { kind: 'job.update', id: 'j1', patch: { status_id: 5 } } },
{ actor: 'mcp', op: { kind: 'job.update', id: 'j1', patch: { salary: 180 } } },
{ actor: 'browser-b', op: { kind: 'selection.update', ... } },
],
expect: async ({ workspace, actors }) => {
const canonical = await readCanonicalState(workspace);
for (const actor of Object.values(actors)) {
const view = await actor.read();
expect(view).toEqual(canonical);
}
},
});
The harness handles the ordering quirks — letting the realtime broadcast settle, draining the coalesce buffer, waiting for IDB writes — so spec authors only describe the operations and expectations.
Invariants the harness checks for every test
- Convergence — every actor's final view equals the canonical state.
- OpId dedup — replaying the same op (same
opId) does not double-apply. - No lost writes — every op landed in
workspace_sub_doc_opsexactly once. - Order independence — the same set of ops applied in any order produces the same final state (where the ops are commutative, which Yjs guarantees for most).
Why this is its own layer
These tests are inherently slow (multi-context spin-up), error-prone (many timing windows), and require dedicated infrastructure (the harness). Mixing them with L1 stories would make L1 slow. Pulling them out into their own layer means L1 can stay fast and L2 can take the time it needs.
L3 — Persistence + recovery contracts
Writes have to survive bad networks, browser crashes, tab reloads, and IDB upgrades. Each of those is a contract.
Test categories
| Category | Setup | Assertion |
|---|---|---|
| Reload survival | Write, reload page | State unchanged |
| Offline + reconnect | Disconnect WS, write, reconnect WS | Write reaches server, server state matches client |
| IDB cycle | Write, close context, open fresh context with same workspace | Local view restored from server |
| Batch atomicity | Cross-sub-doc batch op, kill mid-flight | Either all sub-docs commit or none |
| Snapshot replay | N writes, snapshot, more writes, full reload | Final state correct |
Helper
ui/e2e/helpers/persistence/lifecycle.ts exposes killConnection(actor), simulateOffline(actor), freshContext(workspaceId), and expectConvergedAt(actor, snapshot).
Why this is its own layer
Reload survival is currently smeared across many L1 specs (cvl-reorder.spec.ts reloads, qa-gate.spec.ts reloads). When a reload-related bug appears, the search space is huge. Centralising in L3 means: one place to look, one place to add regression coverage.
L4 — MCP behavior contracts
The MCP server is its own product surface. Per PRINCIPLES.md §7 (Agent API parity), every human gesture has an MCP equivalent. Per §18 (no identity inference), every MCP tool requires explicit identifiers. These are contracts the layer enforces.
Test categories
| Category | What it proves |
|---|---|
| Per-tool happy path | Tool call → product state changes as expected |
| Per-tool error path | Missing wsId, malformed args → InvalidShape |
| Idempotency | Same call twice with same opId produces one commit |
| Concurrent MCP calls | Two MCP clients calling at the same time both succeed and converge |
| MCP + human concurrency | MCP call + human edit on same field → human-write-protection gate triggers correctly |
| Permission posture | MCP cannot call cloud_keys.update, cannot bypass maxCount without force: true |
Test client
ui/e2e/helpers/multi-actor/mcp-client.ts is a thin HTTP client that speaks the MCP JSON-RPC wire format. It does not import @modelcontextprotocol/sdk — the SDK is for production clients; tests use a hand-rolled client to keep dependencies thin and to assert on raw wire shapes.
Why this is its own layer
The MCP worker is deployed separately, has its own wrangler.toml, and runs against the ui worker via service binding. Testing it as if it were just another e2e surface would tangle dependencies. Layer 4 owns the MCP contract independently.
L5 — Performance budgets
Per PRINCIPLES.md §1 (compute economics), perf is a design concern. Budgets make it a CI gate.
Per-surface budgets
| Surface | Metric | Initial budget | Tightening cadence |
|---|---|---|---|
| Landing page | TTI | 1500ms | Tighten by 10% every 3 months until floor |
| Dashboard (50 jobs) | Ready (data-dashboard-hydrated) |
2500ms | Tighten as data shape stabilises |
| Dashboard (100 jobs) | Ready | 3500ms | — |
| CVL editor | Ready (data-cvl-hydrated) |
3500ms | — |
| CVL edit roundtrip | Edit → preview ready | 4000ms | Cold WASM excluded |
| MCP tool call | RPC round trip | 800ms | Excludes LLM stages |
| Op apply | Optimistic local apply | 16ms | One frame budget |
Budgets are stored in ui/e2e/perf/budgets.json so a future tightening pass is a single-file change.
How a budget fails
A perf test runs N iterations (default 5), takes the p95, and asserts p95 < budget. Failure logs the actual numbers and a 60-day rolling history (committed under ui/e2e/perf/history/).
Grace period for new tests
When a new perf test is added, its first PR sets the budget to Math.ceil(p95 * 1.10) (10% headroom over measured value). The author may relax this once for clear documented reasons. After landing, the budget can only tighten — no upward drift without a // reason: ... comment.
Local vs CI
Local runs print numbers; CI runs assert. The same suite runs in both; PERF_ENFORCE=1 switches assertion mode on.
L6 — Visual regression
Pixel-diff for layout-critical surfaces. Per feedback_look_at_images.md in the user's memory: "any visible difference is a regression."
Surfaces under visual regression
| Surface | Viewport(s) | Why |
|---|---|---|
| Landing page | 1366×900 | First impression |
| Empty dashboard | 1280×800, 1920×1080 | Most common dashboard state |
| Populated dashboard (5 jobs, fixture seed) | 1280×800 | Layout regression sniffer |
| CVL editor (CV mode, default fixture) | 1280×900 | Editor layout |
| CVL preview (cold render) | 1280×900 | Typst output stability |
| Kanban board | 1280×800 | Card layout |
| PDF preview (first page) | rasterised at 96 DPI | PDF stability |
| Add Job modal | 1280×800 | Modal layout |
| Workspace Intelligence panel | 1280×800 | Status panel |
Snapshot strategy
Playwright's built-in expect(page).toHaveScreenshot(). Snapshots checked into ui/e2e/visual/__snapshots__/. Diff threshold: maxDiffPixels: 50 (allows minor anti-aliasing variation).
When a snapshot changes intentionally
bun --filter @cv/ui test:visual:update regenerates. Commit the new snapshots with a message describing the intentional change.
Why not Chromatic / Percy
Both are paid SaaS. CareerVector is on free-tier Cloudflare and a strict no-spending posture. Playwright's built-in works, snapshots live in git, and we have full control. If volume ever justifies SaaS, the migration is straightforward.
L7 — Real-world journey flows
End-to-end against real URLs, real LLM providers, real Typst rendering. Gated by env vars so dev runs stay free.
Journeys
| Journey | What it exercises | Cost gate |
|---|---|---|
| Real LinkedIn job → extract → score | RADAR + EXTRACT + ENRICH + EVALUATE with real LLMs | JOURNEY_LIVE_LLM=1 |
| Real LinkedIn job → tailor CV → PDF | Above + TAILOR + Typst PDF compile | JOURNEY_LIVE_LLM=1 |
| Two-tab live collaboration (deployed) | Realtime fanout via deployed DO | PLAYWRIGHT_BASE_URL=https://... |
| MCP tool from real Claude Desktop config | Stress the deployed MCP | JOURNEY_MCP_LIVE=1 |
Why gated
Per CLAUDE.md §22, CF free-tier is 100k requests/day account-wide. L7 burns real quota; running it on every PR would 1027 production. Gate it with env vars, run it manually before releases or on a nightly cron with budget math.
Local-only L7 patterns
For an wrangler dev / Miniflare run, L7 falls back to live LLM via real provider API. This is still a cost gate (LLM tokens) but does not touch CF quota.
Below the layers: package-internal vitest
Some tests don't fit the user-story shape because they're testing pure logic at the bottom of the stack: op apply, schema validation, scoring math, cell-origin projection, locale resolution.
These live in packages/<name>/tests/ and apps/<name>/test/. They're always run (no gate), they're fast (under 30s for the whole suite), and they're framework-agnostic. The existing tests in lib/domain/tests/ and lib/mutations/test/ are the model.
Three sub-categories:
| Sub-category | Examples | Style |
|---|---|---|
| Pure invariants | scoring-algo.test.ts, convention.test.ts |
Plain assertions; deterministic |
| Property-based | lib/mutations/test/properties.test.ts |
fast-check to fuzz inputs |
| Round-trip | sync-realtime.test.ts, tailor-variant-sync.test.ts |
Apply → serialize → reload → compare |
Mutation testing
StrykerJS is installed in lib/mutations and lib/domain. Configs are checked in (stryker.conf.json). Run with bun --filter @cv/mutations mutate. Use for high-confidence layers — the apply path, the gate logic, the cardinality validator. Don't run on every PR (too slow); run nightly or before significant refactors.
Framework choices
| Concern | Choice | Why |
|---|---|---|
| e2e runner | Playwright (already in repo) | Mature, integrates with Svelte 5, fast |
| Story format | Plain Playwright + storySpec() helper |
Gherkin/Cucumber adds a runtime layer for marginal gain; helper gives us the structure without the framework |
| Component tests | Skip Playwright's experimental component-test feature for now | Svelte 5 + Vite + Playwright ct is unstable; e2e covers it |
| Property-based | fast-check (already in lib/mutations, lib/domain) |
Best-in-class for JS; integrates with vitest |
| Visual regression | Playwright toHaveScreenshot() |
Free; snapshots in git |
| Mutation testing | StrykerJS with vitest runner (already configured) | High-value for invariant layers, nightly only |
| Accessibility | @axe-core/playwright (already installed) |
One scan per story spec, layer in ui/e2e/helpers/stories/a11y.ts |
| MCP test client | Hand-rolled HTTP client over JSON-RPC | Tests assert on wire shapes; SDK would hide them |
Justifications
No Gherkin / Cucumber. The cost of a separate spec syntax (parsed at runtime, separate IDE support, no type checking on spec → code binding) outweighs the benefit. storySpec() gets us 90% of the readability with no runtime cost and full TypeScript.
No Playwright Component Tests. The feature is experimental and the Svelte 5 support story is still rough. When Svelte components have logic worth testing in isolation, lift the logic into lib/domain/ and unit-test it there. Visual behavior is e2e.
No Chromatic / Percy. Free-tier ethos. Playwright built-in works.
Mutation testing nightly, not per-PR. A full Stryker run can take 30+ minutes. Per-PR would slow every change. Nightly catches regressions in invariant layers and produces an HTML report.
Spec authoring vocabulary
Below is the only vocabulary spec authors should use in spec text (titles, descriptions, given/when/then). Implementation terms are forbidden; if you need to refer to one, use a helper.
Allowed (user-visible)
- Workspace, job, applicant, collaborator, agent
- Dashboard, kanban, CV editor, CL editor, preview
- Pipeline stages: extract, enrich, evaluate, tailor (these surface to the user)
- Status names ("Applied", "Interview", etc.), view names ("By Priority"), industry names
- "Drag", "click", "type", "navigate", "share link", "reload"
- "Appears", "disappears", "renders", "is visible", "is hidden"
Forbidden (implementation)
- Y.Doc, sub-doc, op, batch, opId, nanoid, IDB, IndexedDB
- Selectors, data-testid, CSS classes, role attributes
- HTTP, REST, PATCH, PUT, fetch
- Worker, DO, durable object, broker, relay
- xstate, machine, state, transition
- Yjs, CRDT, awareness, sync vector
- React, Svelte, $state, $derived, hydration
- Cloudflare, D1, edge, miniflare, wrangler
Any of these in a spec title is a code smell. Push them into helpers.
CI integration
Per CLAUDE.md §22, no push-triggered runs hit deployed Workers. All CI runs locally against wrangler dev / Miniflare. Deployed-worker runs are scheduled or manual.
Per-PR (push-triggered)
Runs locally in CI containers against Miniflare. Total budget: 15 minutes.
| Layer | What runs |
|---|---|
| Below the layers (vitest) | Full suite. Fast. ~5min. |
| Bundle equivalence (vitest) | lib/domain/tests/bundle-equivalence.test.ts — bundles each load-bearing shared module twice (browser + CF Worker conditioned via esbuild) and asserts identical observable output. The cheap structural guarantee that universal TS doesn't drift between bundlers. Runs as part of bun --filter @cv/domain test. ~30s. |
| L1 Stories | All passing stories. Fast browser tests. ~5min. |
| L2 Multi-actor | Smoke subset (2 actors, simple convergence). ~2min. |
| L3 Persistence | Smoke subset (reload + IDB). ~2min. |
| L4 MCP | Per-tool happy path only. ~1min. |
| L5 Performance | Measurement only (no enforcement on PR). Records numbers. |
| L6 Visual | All snapshots. ~2min. |
| L7 Journeys | Skipped on push. |
Nightly (scheduled, off-hours)
Bigger budget. Allowed to spend tokens.
| Layer | What runs |
|---|---|
| L1 Stories | Full suite including slow stories |
| L2 Multi-actor | Full convergence matrix (4 actors, 50 interleavings) |
| L3 Persistence | Full suite including offline + IDB cycle |
| L4 MCP | Concurrency, force-override, full tool coverage |
| L5 Performance | Enforcing mode. Fails the build on budget breach. |
| L6 Visual | Full snapshot suite |
| L7 Journeys | Live LLM journeys. Real LinkedIn URL. |
| Mutation testing | bun --filter @cv/mutations mutate, bun --filter @cv/domain mutate |
Pre-release (manual)
Same as nightly + deployed-worker realtime tests (PLAYWRIGHT_BASE_URL=https://careervector.corbet.ch test:qa:realtime).
Workflow files
This document describes the policy. The actual workflow YAMLs are not committed (per CLAUDE.md §22, push-triggered deployed-worker runs are forbidden, and we deliberately keep workflows minimal). When a workflow is added, it must:
- Run against Miniflare for L1-L6
- Be scheduled (not push-triggered) for L7
- Stamp the configured Cloudflare quota check before any deployed-worker work
Local dev
| Command | Runs | When |
|---|---|---|
bun test:qa:core |
Smoke (existing) | On every meaningful change |
bun --filter @cv/ui test:e2e -- e2e/stories/ |
L1 stories | When touching user-facing logic |
bun --filter @cv/ui test:e2e -- e2e/multi-actor/ |
L2 convergence | When touching ops, broker, realtime |
bun --filter @cv/ui test:e2e -- e2e/persistence/ |
L3 | When touching SDK, IDB, snapshot |
bun --filter @cv/ui test:e2e -- e2e/mcp-contracts/ |
L4 | When touching MCP, tools, agent paths |
bun --filter @cv/ui test:visual |
L6 | When changing UI |
bun --filter @cv/ui test:perf |
L5 measurement | When changing critical path |
Decisions on the existing test surface
The existing ui/e2e/*.spec.ts files are not deleted. They continue to run via bun --filter @cv/ui test:e2e. The new layers are additive. Over time, agents picking up a spec for maintenance should consider whether the spec is best expressed as L1 (move + rewrite), L2 (the multi-tab portion lifts), L3 (the reload portion lifts), or stays where it is.
The existing helpers/api.ts, helpers/cvl.ts, helpers/dnd.ts, helpers/quality.ts, helpers/settle.ts, helpers/demo.ts remain. They're working and battle-tested. The new helpers under helpers/stories/, helpers/multi-actor/, and helpers/perf/ are supersets, not replacements. New helpers compose old helpers; old helpers don't compose new ones.
Known sharp edges (diagnosis)
cvl-reorder.spec.tsJourney 1 occasional flake — y-indexeddb write race. The 2_000ms timeout is best-effort; the real fix is asserting on IDB write completion via the SDK'ssubscribe()callback. L3 has the pattern.agent-api-parity.spec.ts:233— surfaces a real divergence between API-tailoring and GUI-tailoring. Don't pin totest.fail— this is the kind of bug L1 stories should catch and L4 MCP contracts should encode permanently.canonical-collaboration.spec.ts— already a good template for L4-style API-only assertions. Keep it as-is; L4 grows around it.mcp-dispatch-headless.spec.ts— exemplary headless-MCP test. L4 reference.qa-gate.spec.ts— exemplary smoke gate. Keep using it. Don't expand it; expansion goes into L1.
Spec author quick reference
I want to test... Layer Where
─────────────────────────────────────────────────────
A user goal end-to-end L1 e2e/stories/
N tabs converging L2 e2e/multi-actor/
Reload / offline / IDB L3 e2e/persistence/
MCP tool call → state L4 e2e/mcp-contracts/
Latency / throughput L5 e2e/perf/
Layout stability L6 e2e/visual/
Real LinkedIn + LLM L7 e2e/journey-real/
Pure op / scoring / locale below packages/<x>/tests/
Mutation kill rate below bun --filter @cv/<x> mutate
If a test crosses layers, split it. A "drag job to Applied + verify on another tab + verify on reload" test is three tests: one L1 story, one L2 convergence, one L3 persistence. Each independently fails for an independently fixable reason.
What "good" looks like
A new test agent arrives in the repo and:
- Reads this doc (10 minutes).
- Identifies which layer their feature belongs in (1 minute).
- Opens the layer's reference test, copies the shape, replaces the contents (15-30 minutes).
- Runs locally, lands the test green.
- Test continues to pass through 6 months of unrelated refactors.
If any of those steps doesn't work, this doc has a bug. Fix it.
Maintenance
This doc is a living artifact. When a layer's reality drifts from this description, update the doc, don't paper over. The doc is the spec; tests are the spec; refactoring the docs is a real change.
Owner: whichever agent or human is doing significant test work this week. Trade off and document changes in commit messages.