CI enforces the per-directory V8 coverage floors in vitest.config.ts and a
coverage ratchet that rejects decreases greater than 0.5 percentage points.
Run npm run check:orphan-tests to catch test-like files Vitest will not
discover, and npm run check:docs validates documented source paths.
- Test runner: Vitest (
vitest run,vitest run --coverage) - Environment: jsdom (
vitest.config.ts) - Assertions & helpers:
@testing-library/react,@testing-library/user-event,@testing-library/jest-dom - Config:
vitest.setup.tsimports@testing-library/jest-dom/vitest - Path alias:
@resolves tosrc/
Test files live next to the source they exercise:
| Source | Test |
|---|---|
src/hooks/useDebouncedValue.ts |
src/hooks/useDebouncedValue.test.ts |
src/hooks/useLiveIntents.ts |
src/hooks/useLiveIntents.test.ts |
src/components/ConnectWalletButton.tsx |
src/components/ConnectWalletButton.test.tsx |
src/app/explore/[id]/page.tsx |
src/app/explore/[id]/page.test.tsx |
src/store/wallet.ts |
src/store/wallet.test.ts |
src/app/solve/accept-intent.integration.test.tsx |
co-located under the page dir |
- Hook/unit tests:
<name>.test.tsor<name>.test.tsx - Integration tests:
<feature>.integration.test.tsx
Hoist mocks with vi.hoisted, then vi.mock the module path.
Reference: src/hooks/useLiveIntents.test.ts:18-24
const { useIntentsMock, useWebSocketMock } = vi.hoisted(() => ({
useIntentsMock: vi.fn(),
useWebSocketMock: vi.fn(),
}));
vi.mock("./useIntents", () => ({ useIntents: useIntentsMock }));
vi.mock("./useWebSocket", () => ({ useWebSocket: useWebSocketMock }));Either stub global fetch or wrap with SWRConfig.
Reference: src/hooks/useActivityFeed.test.tsx
vi.stubGlobal("fetch", vi.fn());
// or
const wrapper = ({ children }) => (
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
);Freeze initial state, reset in beforeEach/afterEach, and restore after each test.
Reference: src/store/wallet.test.ts:23-33
const initialState = useWalletStore.getState();
describe("useWalletStore", () => {
beforeEach(() => {
useWalletStore.setState(initialState, true);
vi.clearAllMocks();
});
afterEach(() => {
useWalletStore.setState(initialState, true);
});
});For component tests, expose a minimal getState when only one method is needed.
Reference: src/components/ConnectWalletButton.test.tsx:20-22
vi.mock("@/store/toast", () => ({
useToastStore: { getState: () => ({ addToast: addToastMock }) },
}));Hoist all mocks, then replace the module with a stub.
Reference: src/store/wallet.test.ts:3-19, src/components/ConnectWalletButton.test.tsx:5-18
const { isConnectedMock, requestAccessMock } = vi.hoisted(() => ({
isConnectedMock: vi.fn(),
requestAccessMock: vi.fn(),
}));
vi.mock("@stellar/freighter-api", () => ({
default: {
isConnected: isConnectedMock,
requestAccess: requestAccessMock,
},
}));renderHookfor hooks:src/hooks/useDebouncedValue.test.tsuserEvent.setup()for interactions:src/components/ConnectWalletButton.test.tsx:49waitForfor async updates:src/components/ConnectWalletButton.test.tsx:54vi.useFakeTimers()+actfor timers:src/hooks/useDebouncedValue.test.ts:6-12
- Mock the data hooks the page depends on
- Assert loading, error, empty, and success states
- Use
screen.getByText/screen.queryByTextfor presence checks - Use
toHaveAttributefor link assertions
Reference: src/app/explore/[id]/page.test.tsx
End-to-end flows that span multiple hooks/components. Keep them in src/app/<route>/ alongside the page.
Reference: src/app/solve/accept-intent.integration.test.tsx:40-76
Property-based (fuzz) tests exercise parsers and validators with randomly generated inputs to surface edge cases that hand-written example tests miss.
- Library: fast-check (
fast-checkv3) - Arbitraries: shared in
src/test/arbitraries.ts - Test files:
<module>.fuzz.test.tsnext to the source they exercise
| Arbitrary | Produces |
|---|---|
arbitraryXdrEnvelope |
Valid XDR envelopes built via TransactionBuilder |
arbitraryMutatedXdrEnvelope |
Valid XDR with random byte-flips |
arbitraryCsvCell |
CSV cells including formula-trigger characters |
arbitraryUnicodeString |
Strings across all Unicode blocks |
arbitraryDangerousUnicodeString |
Strings containing bidi/zero-width control chars |
arbitraryValidStellarAddress |
Valid G-strkeys from Keypair.random() |
arbitraryCorruptedStellarAddress |
Strkeys with corrupted checksums or wrong lengths |
arbitraryJson |
JSON with extreme nesting and prototype keys |
FUZZ_RUNS— number of iterations per property (default100; set to10000in CI nightly)FUZZ_SEED— fixed seed for reproducibility; logged in CI output
- Create
src/lib/<module>.fuzz.test.ts. - Import the relevant arbitraries from
../test/arbitraries. - Write a
fc.property(...)that encodes the invariant you want to verify. - Wrap it in
fc.assert(..., { numRuns: getNumRuns(), seed: getSeed() }). - Add a corpus-replay
describeblock that loadssrc/test/fuzz-corpus/entries and re-runs the same property on them.
Example:
import { describe, expect, it } from "vitest";
import fc from "fast-check";
import { arbitraryCsvCell, getNumRuns, getSeed } from "../test/arbitraries";
import { escapeCsv } from "./csv";
describe("escapeCsv — fuzz properties", () => {
const numRuns = getNumRuns();
const seed = getSeed();
it("output never begins with a formula trigger character", () => {
fc.assert(
fc.property(arbitraryCsvCell, (cell) => {
const escaped = escapeCsv(cell);
expect(escaped[0]).not.toBe("=");
expect(escaped[0]).not.toBe("+");
expect(escaped[0]).not.toBe("-");
expect(escaped[0]).not.toBe("@");
}),
{ numRuns, seed }
);
});
});When a fuzz test fails, the failing input is saved to src/test/fuzz-corpus/ as a JSON file. On the next npm test run the corpus-replay block in each .fuzz.test.ts file loads those entries and replays them as ordinary unit tests, ensuring regressions are caught even without the fuzz runner.
# Short run (100 iterations, fast feedback)
npm test
# Extended run (10 000 iterations, useful for CI-like depth)
FUZZ_RUNS=10000 npm test
# Reproducible run with a fixed seed
FUZZ_RUNS=10000 FUZZ_SEED=42 npm testA nightly scheduled job (cron: "0 3 * * *") runs the extended fuzz suite with FUZZ_RUNS=10000 and a 5-minute timeout. The random seed used is logged in the job output for reproducibility. Corpus artifacts are uploaded on failure.
src/mocks/ holds one MSW route table for the relay REST API. The same handlers
serve the browser (npm run dev:mock), Vitest (src/mocks/server.ts, started by
vitest.setup.ts) and Playwright (getResponse in e2e/swap-happy-path.spec.ts).
- Handlers:
src/mocks/handlers.ts(createHandlers(scenario)) - Fixtures:
src/mocks/fixtures.ts— seeded generators (createRng,createFeed,createQuote,createSolver) and the in-memoryMockDb - Scenarios:
src/mocks/scenarios.ts - Browser:
src/mocks/browser.ts+MockApiGate(wired insrc/app/layout.tsx, inert unlessNEXT_PUBLIC_MOCK_API=1outside production)
Select in the browser with ?mock=<name> on npm run dev:mock.
| Scenario | Feed rows | Latency | Behaviour |
|---|---|---|---|
default |
12 | none | Normal responses |
empty |
0 | none | Empty lists |
error |
0 | none | Every mocked request returns 500 |
slow |
12 | 2 s | Normal responses after a delay |
large |
5,000 | none | Large feed for virtual-list checks |
POST /intentsadds the intent toGET /intentsand to the top ofGET /intents/feed.POST /intents/:id/submitmoves the intent topending.- Unknown intent IDs return 404.
- The mock WebSocket server is not implemented yet; live-update UI will not receive pushed transitions in
dev:mock. - Fixtures are not yet validated against the runtime schemas in
src/lib/schemas.ts. - Storybook is not wired to MSW yet.
Line coverage does not show whether tests would catch a bug. Stryker
(v9, @stryker-mutator/vitest-runner) injects small faults (e.g. >= → >) into the
pure libraries under src/lib/** and checks that some test fails for each one.
Configuration lives in stryker.config.mjs.
- Scope:
src/lib/**/*.{ts,tsx}, excluding*.test.*,*.fuzz.test.*,*.d.tsandsrc/lib/i18n/messages/**. React components are out of scope. - Coverage analysis:
perTest, so each mutant only runs the tests that reach it. - Thresholds: Stryker fails the run below
break: 75. The PR check also fails if a changed file is below its floor: 85% forcsv,xdrReview,textSafetyanddecimal, 75% otherwise (seescripts/mutation-summary.mjs). - Timeouts:
timeoutMS: 10000withtimeoutFactor: 3, so slow runners are not counted as "timeout" kills.
# Full run over all of src/lib (nightly CI uses this)
npm run test:mutation
# Incremental run; reuses reports/stryker-incremental.json (PR CI uses this)
npm run test:mutation:incremental
# A single file: pass the module with --mutate (the test file is found via perTest coverage)
npx stryker run --mutate src/lib/csv.tsThe HTML report is written to reports/mutation/index.html; the JSON report used by the
PR summary is reports/mutation/mutation.json. Both directories are git-ignored.
To print the PR summary locally against a base branch:
npm run check:mutation-summary -- origin/main- Pull requests: incremental run restoring
reports/stryker-incremental.jsonfromactions/cache. The changed-file score table is posted as a PR comment. Target: under 5 minutes. - Nightly (
cron: "0 3 * * *"): full run, uploaded as themutation-reportartifact.
-
Open the HTML report and filter to Survived or NoCoverage mutants.
-
If a test should have caught the mutant, add an assertion that fails under it. Prefer explicit assertions to snapshots: a snapshot can hide the change.
-
If the mutant is equivalent (it cannot change observable behaviour), suppress it on that line with a justification:
// Stryker disable next-line EqualityOperator: `<=` and `<` are identical for integers hereNever suppress a mutant only because it is hard to kill.
- Inject clocks and random sources instead of calling
Date.now()orMath.random()inside the unit under test, so boundary tests can pin the value. - Test both sides of every comparison at the boundary (
=,-1,+1). - Assert the exact value returned, not only that it is truthy or non-empty.
Baseline scores are recorded here once the first full nightly run completes. Ratchet
break upward in stryker.config.mjs as the scores improve.
src/components/VirtualList.tsx is a reusable windowed list (single-column
role="grid" with aria-rowcount/aria-rowindex and aria-activedescendant).
Rows are measured with TanStack Virtual's measureElement, so wrapped chips and
long solver names never clip.
- Keyboard: Up/Down/Home/End/PageUp/PageDown move the active row, Enter opens
the intent, and
/focuses the search box from anywhere on/explore. - Scroll restoration: scroll offset + active row are saved to
sessionStorageundervortex-vlist:explore?<query>(filters live in the URL) and restored on back navigation. Restoration is skipped if the first row changed or the row count moved by more than 20%. - Prepends: when new intents stream in above the viewport the scroll offset is shifted so the visible row stays anchored.
- Pure helpers (
nextActiveIndex,isRestorable) are unit-tested insrc/components/VirtualList.test.ts.
npm run build && npm start, open/explorewith a relay (or mockeduseLiveIntents) returning 5,000 intents.- Chrome DevTools → Performance → CPU 4× slowdown ("mid-range laptop" profile).
- Record while scrolling the list top→bottom with the mouse wheel for ~10 s.
- Read the FPS track / frames summary; the budget is ≥ 55 fps average with no long tasks > 50 ms during scroll.
Specs live in e2e/*.spec.ts. playwright.config.ts runs each spec under three
projects (chromium, firefox, webkit), serves the production build on
:3000 unless BASE_URL is set, and enables traces on the first retry.
npm run test:e2eruns the full suite;npx playwright test --project=chromiumruns one browser. Pull requests run Chromium only;mainand nightly runs use the full matrix (.github/workflows/ci.yml).- Selectors use roles, labels and visible text (
getByRole,getByPlaceholder), not CSS selectors.
e2e/fixtures/wallet.ts—mockFreighter(page, overrides?)replaces the Freighter extension bridge (window.postMessage) with a stub. Options:publicKey,network("TESTNET"/"PUBLIC"), andsigning("approve"/"reject"/"timeout"). CallsetFreighterState(page, patch)to switch account, network or signing outcome mid-test. State resets on every navigation because the init script re-runs.e2e/fixtures/api.ts—mockBackend(page)stubs the API onhttp://localhost:4000for the swap flow. Addpage.routecalls after it to override a single response.
✅ = spec exists and is scheduled in CI for that project. "Planned" = journey is in the scope of #515 but no spec exists yet. Nothing in this table has been run through the 10× flake check yet.
| Journey | Spec | Chromium | Firefox | WebKit |
|---|---|---|---|---|
| Command palette: open with shortcut, filter, navigate | e2e/command-palette.spec.ts |
✅ | ✅ | ✅ |
| Swap happy path: connect, quote, submit | e2e/swap-happy-path.spec.ts |
✅ | ✅ | ✅ |
| Contributors page renders with no CSP violations | e2e/contributors-csp.spec.ts |
✅ | ✅ | ✅ |
Preview smoke: /, /markets, /portfolio |
e2e/smoke/preview-smoke.spec.ts |
✅ | ✅ | ✅ |
| Solver registration wizard (happy + unhappy) | planned | — | — | — |
| Accept intent, including conflict | planned | — | — | — |
| Explore: filters, search, URL restoration, virtual list keyboard nav | planned | — | — | — |
| My Intents: connect gating, export | planned | — | — | — |
| Governance: browse and vote (mocked) | planned | — | — | — |
| Offline banner and recovery | planned | — | — | — |
| Network mismatch blocking | planned | — | — | — |
| Locale switch persistence | planned | — | — | — |