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-testid strings in spec bodies. Helpers do the resolving.
  • DO NOT mention Y.Doc, applyOp, batch, nanoid in 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

  1. Convergence — every actor's final view equals the canonical state.
  2. OpId dedup — replaying the same op (same opId) does not double-apply.
  3. No lost writes — every op landed in workspace_sub_doc_ops exactly once.
  4. 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.ts Journey 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's subscribe() callback. L3 has the pattern.
  • agent-api-parity.spec.ts:233 — surfaces a real divergence between API-tailoring and GUI-tailoring. Don't pin to test.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:

  1. Reads this doc (10 minutes).
  2. Identifies which layer their feature belongs in (1 minute).
  3. Opens the layer's reference test, copies the shape, replaces the contents (15-30 minutes).
  4. Runs locally, lands the test green.
  5. 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.

Source: wiki/content/runbooks/TESTING-ARCHITECTURE.md