diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml
new file mode 100644
index 00000000..948c223a
--- /dev/null
+++ b/.github/workflows/performance.yml
@@ -0,0 +1,32 @@
+name: Performance
+
+on:
+ pull_request:
+ branches: [main]
+ push:
+ branches: [main]
+
+jobs:
+ lighthouse:
+ runs-on: ubuntu-latest
+ env:
+ NEXT_PUBLIC_STELLAR_NETWORK: ${{ secrets.NEXT_PUBLIC_STELLAR_NETWORK }}
+ NEXT_PUBLIC_CONTRACT_ID: ${{ secrets.NEXT_PUBLIC_CONTRACT_ID }}
+ NEXT_PUBLIC_RPC_URL: ${{ secrets.NEXT_PUBLIC_RPC_URL }}
+ NEXT_PUBLIC_USDC_ADDRESS: ${{ secrets.NEXT_PUBLIC_USDC_ADDRESS }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build
+ run: npm run build
+
+ - name: Run Lighthouse CI against performance budgets
+ run: npx --yes @lhci/cli@0.14.x autorun
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
new file mode 100644
index 00000000..008443ca
--- /dev/null
+++ b/.github/workflows/unit-tests.yml
@@ -0,0 +1,24 @@
+name: Unit Tests
+
+on:
+ pull_request:
+ branches: [main]
+ push:
+ branches: [main]
+
+jobs:
+ vitest:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run unit + snapshot tests
+ run: npx vitest run --ci
diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md
index e82b2510..7731f81b 100644
--- a/docs/ACCESSIBILITY.md
+++ b/docs/ACCESSIBILITY.md
@@ -53,6 +53,48 @@ The initial audit surfaced the following categories of critical/serious violatio
- **Browser coverage locally vs. CI.** A fresh dev sandbox may only have the Chromium Playwright browser installed. CI installs Chromium, Firefox, and WebKit via `npx playwright install --with-deps`, so the merge gate is multi-engine even when a local run (`npx playwright test --grep @a11y`) only exercises Chromium.
- **Moderate/minor axe findings are not gated on.** Only `critical`/`serious` impact violations fail the build, per the acceptance criteria. Any moderate/minor findings surfaced by axe during a run are not currently tracked separately.
+## Component-level automated testing
+
+In addition to the page-level Playwright + `@axe-core/playwright` gate
+above, `src/__tests__/a11y/componentA11y.test.tsx` runs `axe-core` directly
+against individual components rendered under jsdom (via Vitest). This is
+faster to run locally (`npm test -- a11y`) and isolates violations to a
+single component rather than a whole page, which is useful for catching
+ARIA/labelling regressions in shared primitives (e.g. `StatusBadge`,
+`Skeleton`) before they ripple into every page that uses them.
+
+## Manual review process
+
+Automated tooling (axe-core) catches a large share of WCAG violations but
+not all of them — contrast on dynamically-rendered custom canvases,
+meaningful reading order, and genuine screen-reader usability all need a
+human pass. The manual review checklist below should be run once per
+release cycle and whenever a new page/flow is added to the audit scope:
+
+1. **Screen reader pass** — navigate each audited route (see Scope above)
+ with VoiceOver (macOS/iOS) or NVDA (Windows), confirm:
+ - Page title and heading structure are announced sensibly on load.
+ - Every interactive element (buttons, links, form fields) has an
+ announced name that matches its visible label or purpose.
+ - Dynamic updates (toasts, status changes, form errors) are announced
+ via `aria-live`/`role="status"`/`role="alert"` rather than silently
+ appearing.
+2. **Keyboard navigation pass** — unplug the mouse and confirm every
+ interactive control is reachable via `Tab`/`Shift+Tab`, has a visible
+ focus indicator, and can be activated with `Enter`/`Space`. Confirm
+ focus order matches visual/reading order and that no keyboard trap
+ exists (a modal's `Escape` always closes it and returns focus to the
+ trigger).
+3. **Color contrast spot-check** — for any new custom-colored UI (badges,
+ charts, illustrations) not already covered by the automated gate, check
+ contrast with a tool such as the browser DevTools contrast checker
+ against the 4.5:1 (normal text) / 3:1 (large text, 18px+ bold or 24px+)
+ thresholds.
+4. **Document findings** — file an issue (or a checklist comment on the PR)
+ for anything found, tag it `a11y`, and link it from this document's
+ "Known limitations" section until fixed. Once fixed, add a short note to
+ the "What was fixed" section above so the audit trail stays complete.
+
## Extending the audit
To add a new page to the gate, add an entry to the `PAGES` array in `e2e/a11y.spec.ts` — the shared `runAxe()` helper and assertion are reused automatically. For flows that require user interaction (opening a modal, completing a multi-step form), follow the pattern in the two targeted tests at the bottom of that file: drive the interaction with Playwright, then call `runAxe(page)` and assert on `critical`/`serious` violations.
diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md
new file mode 100644
index 00000000..affb8250
--- /dev/null
+++ b/docs/PERFORMANCE.md
@@ -0,0 +1,59 @@
+# Performance benchmarking and monitoring
+
+## Overview
+
+Performance is tracked at two levels:
+
+1. **Real User Monitoring (RUM)** — Core Web Vitals (LCP, CLS, INP, FCP, TTFB)
+ are collected in production via `src/lib/performanceMonitoring.ts`, wired
+ up from `instrumentation-client.ts`. Metrics are forwarded to Sentry as
+ measurements (`Sentry.setMeasurement`) when `NEXT_PUBLIC_SENTRY_DSN` is
+ configured, giving per-metric trends/dashboards for free in the Sentry
+ Performance UI.
+2. **Synthetic / lab monitoring in CI** — [Lighthouse CI](https://github.com/GoogleChrome/lighthouse-ci)
+ (`.github/workflows/performance.yml`, config in `lighthouserc.json`) runs
+ a production build and audits the home page, dashboard, and new-invoice
+ pages on every PR and on pushes to `main`.
+
+## Baseline metrics / budgets
+
+Budgets mirror the thresholds the Web Vitals program uses for "good" vs
+"needs-improvement" vs "poor", defined in `PERFORMANCE_BUDGETS` in
+`src/lib/performanceMonitoring.ts`:
+
+| Metric | Good | Poor (regression) |
+| --- | --- | --- |
+| LCP | ≤ 2500ms | > 4000ms |
+| CLS | ≤ 0.1 | > 0.25 |
+| INP | ≤ 200ms | > 500ms |
+| FCP | ≤ 1800ms | > 3000ms |
+| TTFB | ≤ 800ms | > 1800ms |
+
+Lighthouse CI enforces a parallel, lab-measured set of budgets in
+`lighthouserc.json` (performance score ≥ 0.8, LCP ≤ 4000ms, CLS ≤ 0.25, TBT ≤
+600ms), run 3x per URL and averaged to reduce noise.
+
+## Regression detection
+
+`isRegression(name, value)` in `src/lib/performanceMonitoring.ts` flags any
+RUM metric that crosses its "poor" threshold. In production this triggers a
+`Sentry.captureMessage` warning (tagged with the metric and page path); in
+CI, Lighthouse CI's `assert` block fails the build outright when a budget is
+violated (`error` level assertions) or annotates the PR (`warn` level
+assertions).
+
+## Dashboards
+
+- **Sentry → Performance** shows trends for each Web Vital measurement over
+ time, filterable by release and page path.
+- **Lighthouse CI** uploads a report per run (temporary public storage by
+ default — point `upload.target` at your own LHCI server or Github status
+ check storage for a persistent, shareable trend view).
+
+## Extending
+
+- Add a new RUM metric: extend `MetricName` and `PERFORMANCE_BUDGETS` in
+ `src/lib/performanceMonitoring.ts`, then emit it from
+ `initPerformanceMonitoring()`.
+- Add a new audited route: add its URL to `lighthouserc.json`'s
+ `collect.url` array.
diff --git a/docs/SNAPSHOT_TESTING.md b/docs/SNAPSHOT_TESTING.md
new file mode 100644
index 00000000..d183f96a
--- /dev/null
+++ b/docs/SNAPSHOT_TESTING.md
@@ -0,0 +1,58 @@
+# Snapshot testing
+
+## Tooling
+
+Snapshot tests use Vitest's built-in `toMatchSnapshot()` (no extra
+dependency needed — Vitest is already the project's unit test runner, see
+`vitest.config.ts`). Snapshots are rendered-DOM snapshots of a component's
+`container` from `@testing-library/react`, consistent with every other
+component test in this repo.
+
+## What's covered
+
+- `src/__tests__/snapshots/StatusBadge.snapshot.test.tsx` — all three sizes
+ plus a custom description.
+- `src/__tests__/snapshots/Skeleton.snapshot.test.tsx` — the base
+ `Skeleton` primitive and the composed `SkeletonCard`.
+
+These two components were chosen as the first snapshot targets because
+they're small, purely presentational, and reused across many pages — making
+them both cheap to snapshot and high-value to protect against accidental
+markup/class changes.
+
+## Running
+
+```bash
+npm test # runs all vitest tests, including snapshots
+npx vitest run -u # update snapshots after an intentional change
+```
+
+The first run of a new snapshot test creates `__snapshots__/*.snap` next to
+the test file — **commit that file**. CI (`.github/workflows/unit-tests.yml`)
+runs `vitest run --ci`, which fails the build if a snapshot is missing or
+stale, so a snapshot update always has to go through a normal commit +
+review, never an uncommitted local artifact.
+
+## Review process
+
+A snapshot diff in a PR is a signal, not an automatic approval or an
+automatic rejection:
+
+1. The reviewer reads the diff in the `.snap` file like any other code diff.
+2. If the change matches the PR's stated intent (e.g. "updated StatusBadge's
+ padding"), approve and merge.
+3. If the change is unexpected, it's almost always an unintended
+ regression — ask the author to investigate before approving, don't just
+ `vitest run -u` it away.
+
+## Avoiding snapshot sprawl
+
+- Snapshot only small, presentational, low-churn components (badges,
+ skeletons, icons) — not entire pages or components with frequently
+ changing business data, which produce noisy diffs that reviewers learn to
+ rubber-stamp.
+- Prefer a behavioral assertion (`getByRole`, `getByText`, etc.) over a
+ snapshot whenever the test is really checking specific behavior rather
+ than "did the markup change at all."
+- When a component is deleted or substantially rewritten, delete its
+ snapshot file in the same PR instead of leaving an orphaned `.snap`.
diff --git a/instrumentation-client.ts b/instrumentation-client.ts
index 4566ad69..e9093169 100644
--- a/instrumentation-client.ts
+++ b/instrumentation-client.ts
@@ -1,4 +1,5 @@
import * as Sentry from "@sentry/nextjs";
+import { initPerformanceMonitoring, onPerformanceMetric, isRegression } from "./src/lib/performanceMonitoring";
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
@@ -12,4 +13,21 @@ if (dsn) {
});
}
+// Core Web Vitals collection + budget-regression reporting (see
+// src/lib/performanceMonitoring.ts and docs/PERFORMANCE.md).
+onPerformanceMetric((metric) => {
+ if (dsn) {
+ Sentry.setMeasurement(metric.name, metric.value, metric.name === "CLS" ? "" : "millisecond");
+ }
+ if (isRegression(metric.name, metric.value)) {
+ if (dsn) {
+ Sentry.captureMessage(`Performance budget regression: ${metric.name}=${metric.value} on ${metric.path}`, "warning");
+ } else if (process.env.NODE_ENV !== "production") {
+ // eslint-disable-next-line no-console
+ console.warn(`[perf] budget regression: ${metric.name}=${metric.value} on ${metric.path}`);
+ }
+ }
+});
+initPerformanceMonitoring();
+
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
diff --git a/lighthouserc.json b/lighthouserc.json
new file mode 100644
index 00000000..8a0a9a65
--- /dev/null
+++ b/lighthouserc.json
@@ -0,0 +1,23 @@
+{
+ "ci": {
+ "collect": {
+ "startServerCommand": "npm run start",
+ "startServerReadyPattern": "ready on",
+ "startServerReadyTimeout": 60000,
+ "numberOfRuns": 3,
+ "url": ["http://localhost:3000/", "http://localhost:3000/dashboard", "http://localhost:3000/invoice/new"]
+ },
+ "assert": {
+ "assertions": {
+ "categories:performance": ["warn", { "minScore": 0.8 }],
+ "first-contentful-paint": ["warn", { "maxNumericValue": 3000 }],
+ "largest-contentful-paint": ["error", { "maxNumericValue": 4000 }],
+ "cumulative-layout-shift": ["error", { "maxNumericValue": 0.25 }],
+ "total-blocking-time": ["warn", { "maxNumericValue": 600 }]
+ }
+ },
+ "upload": {
+ "target": "temporary-public-storage"
+ }
+ }
+}
diff --git a/src/__tests__/a11y/componentA11y.test.tsx b/src/__tests__/a11y/componentA11y.test.tsx
new file mode 100644
index 00000000..9908f3b7
--- /dev/null
+++ b/src/__tests__/a11y/componentA11y.test.tsx
@@ -0,0 +1,52 @@
+import React from "react";
+import { render } from "@testing-library/react";
+import axe from "axe-core";
+import StatusBadge from "@/components/StatusBadge";
+import { Skeleton, SkeletonCard } from "@/components/Skeleton";
+
+/**
+ * Component-level automated accessibility testing.
+ *
+ * Complements the page-level WCAG gate in e2e/a11y.spec.ts (Playwright +
+ * @axe-core/playwright) with fast, isolated axe-core scans of individual
+ * components under jsdom. Catches ARIA/role/labelling regressions in a
+ * single component without needing a full page render or a browser.
+ *
+ * See docs/ACCESSIBILITY.md for the full automated + manual testing process.
+ */
+
+async function expectNoSeriousOrCriticalViolations(container: Element) {
+ const results = await axe.run(container, {
+ runOnly: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"],
+ });
+ const seriousOrCritical = results.violations.filter(
+ (v) => v.impact === "critical" || v.impact === "serious"
+ );
+ expect(seriousOrCritical).toEqual([]);
+}
+
+describe("component accessibility (axe-core)", () => {
+ it("StatusBadge has no critical/serious a11y violations", async () => {
+ const { container } = render();
+ await expectNoSeriousOrCriticalViolations(container);
+ });
+
+ it("StatusBadge remains accessible for every status variant", async () => {
+ const statuses = ["Pending", "Active", "Funded", "Released", "Refunded", "Disputed", "Frozen", "Archived", "Expired"] as const;
+ for (const status of statuses) {
+ const { container, unmount } = render();
+ await expectNoSeriousOrCriticalViolations(container);
+ unmount();
+ }
+ });
+
+ it("Skeleton primitive has no a11y violations", async () => {
+ const { container } = render();
+ await expectNoSeriousOrCriticalViolations(container);
+ });
+
+ it("SkeletonCard has no a11y violations", async () => {
+ const { container } = render();
+ await expectNoSeriousOrCriticalViolations(container);
+ });
+});
diff --git a/src/__tests__/snapshots/Skeleton.snapshot.test.tsx b/src/__tests__/snapshots/Skeleton.snapshot.test.tsx
new file mode 100644
index 00000000..9a06778c
--- /dev/null
+++ b/src/__tests__/snapshots/Skeleton.snapshot.test.tsx
@@ -0,0 +1,15 @@
+import React from "react";
+import { render } from "@testing-library/react";
+import { Skeleton, SkeletonCard } from "@/components/Skeleton";
+
+describe("Skeleton snapshots", () => {
+ it("renders the base Skeleton primitive", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders SkeletonCard", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/src/__tests__/snapshots/StatusBadge.snapshot.test.tsx b/src/__tests__/snapshots/StatusBadge.snapshot.test.tsx
new file mode 100644
index 00000000..75fc1e68
--- /dev/null
+++ b/src/__tests__/snapshots/StatusBadge.snapshot.test.tsx
@@ -0,0 +1,26 @@
+import React from "react";
+import { render } from "@testing-library/react";
+import StatusBadge from "@/components/StatusBadge";
+
+/**
+ * Snapshot tests catch unintended rendering changes to shared components.
+ * See docs/SNAPSHOT_TESTING.md for the review/update process.
+ */
+describe("StatusBadge snapshots", () => {
+ it("renders the default (md) size", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders the sm size", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("renders the lg size with a custom description", () => {
+ const { container } = render(
+
+ );
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/src/lib/performanceMonitoring.test.ts b/src/lib/performanceMonitoring.test.ts
new file mode 100644
index 00000000..53d0dd5a
--- /dev/null
+++ b/src/lib/performanceMonitoring.test.ts
@@ -0,0 +1,58 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import {
+ rateMetric,
+ isRegression,
+ onPerformanceMetric,
+ PERFORMANCE_BUDGETS,
+ initPerformanceMonitoring,
+} from "./performanceMonitoring";
+
+describe("performanceMonitoring", () => {
+ describe("rateMetric", () => {
+ it("rates LCP within budget as good", () => {
+ expect(rateMetric("LCP", 1200)).toBe("good");
+ });
+
+ it("rates LCP between good and poor as needs-improvement", () => {
+ expect(rateMetric("LCP", 3000)).toBe("needs-improvement");
+ });
+
+ it("rates LCP above the poor threshold as poor", () => {
+ expect(rateMetric("LCP", 5000)).toBe("poor");
+ });
+
+ it("rates CLS using its own (unitless) budget", () => {
+ expect(rateMetric("CLS", 0.05)).toBe("good");
+ expect(rateMetric("CLS", 0.3)).toBe("poor");
+ });
+ });
+
+ describe("isRegression", () => {
+ it("flags a regression once a metric exceeds its poor threshold", () => {
+ expect(isRegression("LCP", PERFORMANCE_BUDGETS.LCP.poor + 1)).toBe(true);
+ });
+
+ it("does not flag a metric within budget", () => {
+ expect(isRegression("LCP", PERFORMANCE_BUDGETS.LCP.good)).toBe(false);
+ });
+ });
+
+ describe("onPerformanceMetric", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("subscribes and unsubscribes reporters", () => {
+ const reporter = vi.fn();
+ const unsubscribe = onPerformanceMetric(reporter);
+ expect(typeof unsubscribe).toBe("function");
+ unsubscribe();
+ });
+ });
+
+ describe("initPerformanceMonitoring", () => {
+ it("is a no-op when PerformanceObserver is unavailable (SSR-safe)", () => {
+ expect(() => initPerformanceMonitoring()).not.toThrow();
+ });
+ });
+});
diff --git a/src/lib/performanceMonitoring.ts b/src/lib/performanceMonitoring.ts
new file mode 100644
index 00000000..73e20747
--- /dev/null
+++ b/src/lib/performanceMonitoring.ts
@@ -0,0 +1,166 @@
+/**
+ * performanceMonitoring.ts
+ *
+ * Lightweight Core Web Vitals collection + performance-budget regression
+ * detection, with no third-party runtime dependency (uses the native
+ * PerformanceObserver / Navigation Timing APIs only).
+ *
+ * Wired up from `instrumentation-client.ts` via `initPerformanceMonitoring()`.
+ */
+
+export type MetricName = "LCP" | "CLS" | "INP" | "FCP" | "TTFB";
+
+export interface PerformanceMetric {
+ name: MetricName;
+ value: number;
+ rating: "good" | "needs-improvement" | "poor";
+ timestamp: number;
+ path: string;
+}
+
+export type MetricReporter = (metric: PerformanceMetric) => void;
+
+/**
+ * Performance budgets, aligned with the thresholds Google's web-vitals
+ * library uses for "good" vs "needs-improvement" vs "poor".
+ * Values are in milliseconds, except CLS which is a unitless layout-shift
+ * score.
+ */
+export const PERFORMANCE_BUDGETS: Record = {
+ LCP: { good: 2500, poor: 4000 },
+ CLS: { good: 0.1, poor: 0.25 },
+ INP: { good: 200, poor: 500 },
+ FCP: { good: 1800, poor: 3000 },
+ TTFB: { good: 800, poor: 1800 },
+};
+
+export function rateMetric(name: MetricName, value: number): PerformanceMetric["rating"] {
+ const budget = PERFORMANCE_BUDGETS[name];
+ if (value <= budget.good) return "good";
+ if (value <= budget.poor) return "needs-improvement";
+ return "poor";
+}
+
+/**
+ * Returns true when a metric value breaches its budget's "poor" threshold —
+ * the signal used for automated regression detection (e.g. in CI or a
+ * synthetic monitoring job).
+ */
+export function isRegression(name: MetricName, value: number): boolean {
+ return value > PERFORMANCE_BUDGETS[name].poor;
+}
+
+const listeners = new Set();
+
+/** Subscribe to collected metrics (e.g. to forward them to Sentry/analytics). */
+export function onPerformanceMetric(reporter: MetricReporter): () => void {
+ listeners.add(reporter);
+ return () => listeners.delete(reporter);
+}
+
+function emit(name: MetricName, value: number) {
+ const metric: PerformanceMetric = {
+ name,
+ value,
+ rating: rateMetric(name, value),
+ timestamp: Date.now(),
+ path: typeof window !== "undefined" ? window.location.pathname : "",
+ };
+ listeners.forEach((reporter) => {
+ try {
+ reporter(metric);
+ } catch {
+ // never let a reporter crash the page
+ }
+ });
+}
+
+let initialized = false;
+
+/**
+ * Starts collecting Core Web Vitals in the browser via PerformanceObserver.
+ * Safe to call multiple times (no-op after the first call) and safe to call
+ * on the server (no-op, since PerformanceObserver doesn't exist there).
+ */
+export function initPerformanceMonitoring(): void {
+ if (initialized) return;
+ if (typeof window === "undefined" || typeof PerformanceObserver === "undefined") return;
+ initialized = true;
+
+ // Time to First Byte, from Navigation Timing.
+ try {
+ const nav = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined;
+ if (nav) {
+ emit("TTFB", nav.responseStart - nav.requestStart);
+ }
+ } catch {
+ // ignore
+ }
+
+ // First Contentful Paint.
+ try {
+ const paintObserver = new PerformanceObserver((list) => {
+ for (const entry of list.getEntries()) {
+ if (entry.name === "first-contentful-paint") {
+ emit("FCP", entry.startTime);
+ }
+ }
+ });
+ paintObserver.observe({ type: "paint", buffered: true });
+ } catch {
+ // ignore unsupported entry types
+ }
+
+ // Largest Contentful Paint — keep reporting the latest candidate until the
+ // page is hidden/unloaded, per the standard LCP collection pattern.
+ try {
+ let lastLCP = 0;
+ const lcpObserver = new PerformanceObserver((list) => {
+ const entries = list.getEntries();
+ const last = entries[entries.length - 1];
+ if (last) lastLCP = last.startTime;
+ });
+ lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
+ const flushLCP = () => {
+ if (lastLCP > 0) emit("LCP", lastLCP);
+ };
+ document.addEventListener("visibilitychange", () => {
+ if (document.visibilityState === "hidden") flushLCP();
+ });
+ window.addEventListener("pagehide", flushLCP, { once: true });
+ } catch {
+ // ignore
+ }
+
+ // Cumulative Layout Shift.
+ try {
+ let clsValue = 0;
+ const clsObserver = new PerformanceObserver((list) => {
+ for (const entry of list.getEntries() as Array) {
+ if (!entry.hadRecentInput) {
+ clsValue += entry.value;
+ }
+ }
+ emit("CLS", clsValue);
+ });
+ clsObserver.observe({ type: "layout-shift", buffered: true });
+ } catch {
+ // ignore
+ }
+
+ // Interaction to Next Paint (approximated via `event` timing entries).
+ try {
+ let worstINP = 0;
+ const inpObserver = new PerformanceObserver((list) => {
+ for (const entry of list.getEntries() as Array) {
+ if (entry.duration > worstINP) {
+ worstINP = entry.duration;
+ emit("INP", worstINP);
+ }
+ }
+ });
+ inpObserver.observe({ type: "event", buffered: true, durationThreshold: 40 } as PerformanceObserverInit);
+ } catch {
+ // event timing isn't supported everywhere; silently skip
+ }
+}
diff --git a/src/test/factories/README.md b/src/test/factories/README.md
new file mode 100644
index 00000000..16b80b12
--- /dev/null
+++ b/src/test/factories/README.md
@@ -0,0 +1,81 @@
+# Test data factories
+
+Reusable, consistent test data generators for invoice, payment, and user
+objects, following a plain "factory function + overrides" pattern (no
+extra dependency — see [Alternatives considered](#alternatives-considered)).
+
+## Usage
+
+```ts
+import { buildInvoice, buildPayment, buildUser, resetAllFactorySequences } from "@/test/factories";
+
+beforeEach(() => {
+ resetAllFactorySequences(); // avoids id/address collisions across tests
+});
+
+it("marks an invoice as funded", () => {
+ const invoice = buildInvoice({ status: "Active", amount: "500" });
+ // ...
+});
+```
+
+Each `build*` function returns a fully valid object with sensible defaults
+for every field. Pass a partial object to override just the fields your
+test cares about — everything else keeps its default, so tests stay
+readable and don't break when unrelated fields are added to the shape.
+
+### Lists
+
+```ts
+import { buildInvoiceList } from "@/test/factories";
+
+const invoices = buildInvoiceList(3, { status: "Pending" });
+```
+
+### Fixtures
+
+For the common lifecycle states, use the pre-built fixtures instead of
+constructing overrides by hand:
+
+```ts
+import { invoiceFixtures, paymentFixtures, userFixtures } from "@/test/factories";
+
+const invoice = invoiceFixtures.fullyFunded();
+const payment = paymentFixtures.completed();
+const user = userFixtures.verified();
+```
+
+## Avoiding test data pollution
+
+- Every factory keeps an internal sequence counter so IDs/addresses are
+ unique per call. Call `resetAllFactorySequences()` (or the individual
+ `resetInvoiceFactorySequence` / `resetPaymentFactorySequence` /
+ `resetUserFactorySequence`) in `beforeEach` so output is deterministic
+ and tests don't depend on run order.
+- Factories never touch `localStorage`, `sessionStorage`, or any network
+ call — they're pure functions that return plain objects. Nothing needs
+ manual teardown.
+
+## Design notes
+
+- **Types are self-contained** (`TestInvoice`, `TestPayment`, `TestUser`)
+ rather than imported from `src/lib/*`, because this codebase doesn't yet
+ have single canonical domain types — most `src/lib/*.ts` files declare
+ their own narrow, file-local `interface Invoice { ... }` shape for just
+ what they need. Keeping the factory types separate avoids coupling every
+ test in the repo to one file's particular narrow shape, while still
+ giving every test consistent, realistic default data. If/when the
+ codebase consolidates its domain types, these factories' return types
+ should be pointed at the canonical ones.
+- **Realistic-looking addresses**: generated Stellar-style `G...`
+ addresses are fake but correctly shaped (56 chars) so UI code asserting
+ on address format/length doesn't need special-casing for test data.
+
+## Alternatives considered
+
+A library like `@faker-js/faker` or `fishery` could generate more varied
+random data, but was intentionally not added as a new dependency here —
+deterministic, readable defaults (e.g. `"Test Invoice 1"`, a fixed decimal
+amount) make test failures easier to debug than randomized data, and this
+plain-function pattern needs no new dependency to install. Revisit if a
+specific test genuinely needs randomized/fuzzed inputs.
diff --git a/src/test/factories/factories.test.ts b/src/test/factories/factories.test.ts
new file mode 100644
index 00000000..5204a06f
--- /dev/null
+++ b/src/test/factories/factories.test.ts
@@ -0,0 +1,95 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import {
+ buildInvoice,
+ buildInvoiceList,
+ invoiceFixtures,
+ buildPayment,
+ buildPaymentList,
+ paymentFixtures,
+ buildUser,
+ buildUserList,
+ userFixtures,
+ resetAllFactorySequences,
+} from "./index";
+
+describe("test data factories", () => {
+ beforeEach(() => {
+ resetAllFactorySequences();
+ });
+
+ describe("invoiceFactory", () => {
+ it("builds a valid invoice with defaults", () => {
+ const invoice = buildInvoice();
+ expect(invoice.id).toBe("invoice-1");
+ expect(invoice.status).toBe("Pending");
+ expect(invoice.recipients).toHaveLength(1);
+ expect(invoice.tokenAddress).toHaveLength(56);
+ });
+
+ it("applies overrides without losing defaults", () => {
+ const invoice = buildInvoice({ status: "Funded", amount: "999" });
+ expect(invoice.status).toBe("Funded");
+ expect(invoice.amount).toBe("999");
+ expect(invoice.recipients).toHaveLength(1);
+ });
+
+ it("builds a list of unique invoices", () => {
+ const invoices = buildInvoiceList(3);
+ const ids = invoices.map((i) => i.id);
+ expect(new Set(ids).size).toBe(3);
+ });
+
+ it("exposes lifecycle fixtures", () => {
+ expect(invoiceFixtures.fullyFunded().status).toBe("Funded");
+ expect(invoiceFixtures.disputed().status).toBe("Disputed");
+ expect(invoiceFixtures.withMultipleRecipients().recipients).toHaveLength(2);
+ });
+ });
+
+ describe("paymentFactory", () => {
+ it("builds a valid payment with defaults", () => {
+ const payment = buildPayment();
+ expect(payment.status).toBe("pending");
+ expect(payment.completedAt).toBeNull();
+ expect(payment.txHash).toHaveLength(64);
+ });
+
+ it("builds a list of unique payments", () => {
+ const payments = buildPaymentList(3);
+ const ids = new Set(payments.map((p) => p.id));
+ expect(ids.size).toBe(3);
+ });
+
+ it("exposes lifecycle fixtures", () => {
+ expect(paymentFixtures.completed().completedAt).not.toBeNull();
+ expect(paymentFixtures.failed().status).toBe("failed");
+ });
+ });
+
+ describe("userFactory", () => {
+ it("builds a valid user with defaults", () => {
+ const user = buildUser();
+ expect(user.isVerified).toBe(false);
+ expect(user.walletAddress).toHaveLength(56);
+ });
+
+ it("builds a list of unique users", () => {
+ const users = buildUserList(3);
+ expect(new Set(users.map((u) => u.id)).size).toBe(3);
+ });
+
+ it("exposes fixtures", () => {
+ expect(userFixtures.verified().isVerified).toBe(true);
+ expect(userFixtures.anonymous().email).toBeNull();
+ });
+ });
+
+ describe("resetAllFactorySequences", () => {
+ it("resets ids back to the first sequence value", () => {
+ buildInvoice();
+ buildInvoice();
+ resetAllFactorySequences();
+ expect(buildInvoice().id).toBe("invoice-1");
+ });
+ });
+});
diff --git a/src/test/factories/index.ts b/src/test/factories/index.ts
new file mode 100644
index 00000000..c51b26ac
--- /dev/null
+++ b/src/test/factories/index.ts
@@ -0,0 +1,18 @@
+export * from "./invoiceFactory";
+export * from "./paymentFactory";
+export * from "./userFactory";
+
+import { resetInvoiceFactorySequence } from "./invoiceFactory";
+import { resetPaymentFactorySequence } from "./paymentFactory";
+import { resetUserFactorySequence } from "./userFactory";
+
+/**
+ * Resets every factory's sequence counter. Call from a top-level
+ * `beforeEach` to prevent test data (and generated ids/addresses) from
+ * leaking or diverging between tests.
+ */
+export function resetAllFactorySequences(): void {
+ resetInvoiceFactorySequence();
+ resetPaymentFactorySequence();
+ resetUserFactorySequence();
+}
diff --git a/src/test/factories/invoiceFactory.ts b/src/test/factories/invoiceFactory.ts
new file mode 100644
index 00000000..59f30033
--- /dev/null
+++ b/src/test/factories/invoiceFactory.ts
@@ -0,0 +1,115 @@
+/**
+ * Invoice test data factory.
+ *
+ * See src/test/factories/README.md for the general factory pattern used
+ * across invoice/payment/user factories.
+ */
+
+export type TestInvoiceStatus =
+ | "Pending"
+ | "Active"
+ | "Funded"
+ | "Released"
+ | "Refunded"
+ | "Disputed"
+ | "Frozen"
+ | "Archived"
+ | "Expired";
+
+export interface TestInvoiceRecipient {
+ address: string;
+ share: number;
+ label?: string;
+}
+
+export interface TestInvoice {
+ id: string;
+ title: string;
+ description: string;
+ amount: string;
+ tokenAddress: string;
+ status: TestInvoiceStatus;
+ creatorAddress: string;
+ recipients: TestInvoiceRecipient[];
+ createdAt: number;
+ dueDate: number;
+ fundedAmount: string;
+}
+
+let invoiceSequence = 0;
+
+/** Resets the shared sequence counter — call from `beforeEach` to avoid cross-test ID pollution. */
+export function resetInvoiceFactorySequence(): void {
+ invoiceSequence = 0;
+}
+
+function nextInvoiceSequence(): number {
+ invoiceSequence += 1;
+ return invoiceSequence;
+}
+
+function fakeStellarAddress(seed: number): string {
+ return `G${"FACTORY".padEnd(55, String(seed % 10))}`.slice(0, 56);
+}
+
+/**
+ * Builds a single invoice recipient with sensible defaults, overridable per field.
+ */
+export function buildInvoiceRecipient(overrides: Partial = {}): TestInvoiceRecipient {
+ const n = nextInvoiceSequence();
+ return {
+ address: fakeStellarAddress(n),
+ share: 100,
+ label: `Recipient ${n}`,
+ ...overrides,
+ };
+}
+
+/**
+ * Builds a single invoice with sensible defaults. Pass `overrides` to
+ * customize any field, including nested `recipients`.
+ *
+ * @example
+ * const invoice = buildInvoice({ status: "Funded", amount: "500" });
+ */
+export function buildInvoice(overrides: Partial = {}): TestInvoice {
+ const n = nextInvoiceSequence();
+ const now = Date.now();
+
+ return {
+ id: `invoice-${n}`,
+ title: `Test Invoice ${n}`,
+ description: `Factory-generated invoice #${n} for testing`,
+ amount: "100.00",
+ tokenAddress: fakeStellarAddress(n + 1000),
+ status: "Pending",
+ creatorAddress: fakeStellarAddress(n),
+ recipients: [buildInvoiceRecipient()],
+ createdAt: now,
+ dueDate: now + 7 * 24 * 60 * 60 * 1000,
+ fundedAmount: "0",
+ ...overrides,
+ };
+}
+
+/** Builds `count` invoices, each with a unique id/sequence, sharing any given overrides. */
+export function buildInvoiceList(count: number, overrides: Partial = {}): TestInvoice[] {
+ return Array.from({ length: count }, () => buildInvoice(overrides));
+}
+
+/** Common fixtures for the invoice lifecycle, ready to import directly in tests. */
+export const invoiceFixtures = {
+ pending: () => buildInvoice({ status: "Pending" }),
+ fundedPartially: () => buildInvoice({ status: "Active", amount: "1000", fundedAmount: "250" }),
+ fullyFunded: () => buildInvoice({ status: "Funded", amount: "1000", fundedAmount: "1000" }),
+ released: () => buildInvoice({ status: "Released", amount: "1000", fundedAmount: "1000" }),
+ disputed: () => buildInvoice({ status: "Disputed" }),
+ expired: () => buildInvoice({ status: "Expired", dueDate: Date.now() - 24 * 60 * 60 * 1000 }),
+ withMultipleRecipients: () =>
+ buildInvoice({
+ recipients: [
+ buildInvoiceRecipient({ share: 60, label: "Lead" }),
+ buildInvoiceRecipient({ share: 40, label: "Collaborator" }),
+ ],
+ }),
+};
diff --git a/src/test/factories/paymentFactory.ts b/src/test/factories/paymentFactory.ts
new file mode 100644
index 00000000..d81a44f6
--- /dev/null
+++ b/src/test/factories/paymentFactory.ts
@@ -0,0 +1,75 @@
+/**
+ * Payment test data factory. See src/test/factories/README.md.
+ */
+
+export type TestPaymentStatus = "pending" | "processing" | "completed" | "failed" | "refunded";
+
+export interface TestPayment {
+ id: string;
+ invoiceId: string;
+ payerAddress: string;
+ recipientAddress: string;
+ amount: string;
+ tokenAddress: string;
+ status: TestPaymentStatus;
+ txHash: string;
+ createdAt: number;
+ completedAt: number | null;
+}
+
+let paymentSequence = 0;
+
+export function resetPaymentFactorySequence(): void {
+ paymentSequence = 0;
+}
+
+function nextPaymentSequence(): number {
+ paymentSequence += 1;
+ return paymentSequence;
+}
+
+function fakeTxHash(seed: number): string {
+ return `${"ab".repeat(32)}`.slice(0, 64 - String(seed).length) + String(seed);
+}
+
+function fakeStellarAddress(seed: number): string {
+ return `G${"PAYMENT".padEnd(55, String(seed % 10))}`.slice(0, 56);
+}
+
+/**
+ * Builds a single payment with sensible defaults, overridable per field.
+ *
+ * @example
+ * const payment = buildPayment({ status: "completed", amount: "50" });
+ */
+export function buildPayment(overrides: Partial = {}): TestPayment {
+ const n = nextPaymentSequence();
+ const now = Date.now();
+
+ return {
+ id: `payment-${n}`,
+ invoiceId: `invoice-${n}`,
+ payerAddress: fakeStellarAddress(n),
+ recipientAddress: fakeStellarAddress(n + 1),
+ amount: "50.00",
+ tokenAddress: fakeStellarAddress(n + 2000),
+ status: "pending",
+ txHash: fakeTxHash(n),
+ createdAt: now,
+ completedAt: null,
+ ...overrides,
+ };
+}
+
+export function buildPaymentList(count: number, overrides: Partial = {}): TestPayment[] {
+ return Array.from({ length: count }, () => buildPayment(overrides));
+}
+
+/** Common fixtures for the payment lifecycle. */
+export const paymentFixtures = {
+ pending: () => buildPayment({ status: "pending" }),
+ processing: () => buildPayment({ status: "processing" }),
+ completed: () => buildPayment({ status: "completed", completedAt: Date.now() }),
+ failed: () => buildPayment({ status: "failed" }),
+ refunded: () => buildPayment({ status: "refunded", completedAt: Date.now() }),
+};
diff --git a/src/test/factories/userFactory.ts b/src/test/factories/userFactory.ts
new file mode 100644
index 00000000..e0594028
--- /dev/null
+++ b/src/test/factories/userFactory.ts
@@ -0,0 +1,58 @@
+/**
+ * User (wallet-holder) test data factory. See src/test/factories/README.md.
+ */
+
+export interface TestUser {
+ id: string;
+ walletAddress: string;
+ displayName: string;
+ email: string | null;
+ isVerified: boolean;
+ createdAt: number;
+}
+
+let userSequence = 0;
+
+export function resetUserFactorySequence(): void {
+ userSequence = 0;
+}
+
+function nextUserSequence(): number {
+ userSequence += 1;
+ return userSequence;
+}
+
+function fakeStellarAddress(seed: number): string {
+ return `G${"USERFAC".padEnd(55, String(seed % 10))}`.slice(0, 56);
+}
+
+/**
+ * Builds a single user with sensible defaults, overridable per field.
+ *
+ * @example
+ * const user = buildUser({ isVerified: true, displayName: "Alice" });
+ */
+export function buildUser(overrides: Partial = {}): TestUser {
+ const n = nextUserSequence();
+
+ return {
+ id: `user-${n}`,
+ walletAddress: fakeStellarAddress(n),
+ displayName: `Test User ${n}`,
+ email: `test-user-${n}@example.test`,
+ isVerified: false,
+ createdAt: Date.now(),
+ ...overrides,
+ };
+}
+
+export function buildUserList(count: number, overrides: Partial = {}): TestUser[] {
+ return Array.from({ length: count }, () => buildUser(overrides));
+}
+
+/** Common fixtures for user states. */
+export const userFixtures = {
+ unverified: () => buildUser({ isVerified: false }),
+ verified: () => buildUser({ isVerified: true }),
+ anonymous: () => buildUser({ email: null, displayName: "Anonymous" }),
+};