From 821422a3fc57ab932fe6716638425103ab35ebb1 Mon Sep 17 00:00:00 2001 From: luhrhenz Date: Sun, 18 Oct 2026 17:01:53 +0100 Subject: [PATCH 1/4] Add performance benchmarking and monitoring (#904) Collects Core Web Vitals (LCP, CLS, INP, FCP, TTFB) via PerformanceObserver, reports them to Sentry as measurements, flags budget regressions, and adds a Lighthouse CI workflow/config enforcing lab performance budgets on every PR. --- .github/workflows/performance.yml | 32 +++++ docs/PERFORMANCE.md | 59 +++++++++ instrumentation-client.ts | 18 +++ lighthouserc.json | 23 ++++ src/lib/performanceMonitoring.test.ts | 58 +++++++++ src/lib/performanceMonitoring.ts | 166 ++++++++++++++++++++++++++ 6 files changed, 356 insertions(+) create mode 100644 .github/workflows/performance.yml create mode 100644 docs/PERFORMANCE.md create mode 100644 lighthouserc.json create mode 100644 src/lib/performanceMonitoring.test.ts create mode 100644 src/lib/performanceMonitoring.ts 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/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/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/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 + } +} From 84bc1876272eb58d39f474309833712e46be9c72 Mon Sep 17 00:00:00 2001 From: luhrhenz Date: Sun, 18 Oct 2026 17:02:22 +0100 Subject: [PATCH 2/4] Add component-level a11y testing and manual review process (#905) Adds axe-core unit tests for shared components (StatusBadge, Skeleton) to complement the existing Playwright page-level WCAG gate, and documents the manual screen-reader/keyboard/contrast review process and findings workflow. --- docs/ACCESSIBILITY.md | 42 ++++++++++++++++++ src/__tests__/a11y/componentA11y.test.tsx | 52 +++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/__tests__/a11y/componentA11y.test.tsx 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/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); + }); +}); From fc45727e85db008ceb00bf7cfb3fb2f0814d575c Mon Sep 17 00:00:00 2001 From: luhrhenz Date: Sun, 18 Oct 2026 17:03:01 +0100 Subject: [PATCH 3/4] Add snapshot testing for component rendering (#906) Adds Vitest snapshot tests for StatusBadge and Skeleton/SkeletonCard, a unit-tests CI workflow running vitest --ci, and docs covering the snapshot review process and how to avoid snapshot sprawl. --- .github/workflows/unit-tests.yml | 24 ++++++++ docs/SNAPSHOT_TESTING.md | 58 +++++++++++++++++++ .../snapshots/Skeleton.snapshot.test.tsx | 15 +++++ .../snapshots/StatusBadge.snapshot.test.tsx | 26 +++++++++ 4 files changed, 123 insertions(+) create mode 100644 .github/workflows/unit-tests.yml create mode 100644 docs/SNAPSHOT_TESTING.md create mode 100644 src/__tests__/snapshots/Skeleton.snapshot.test.tsx create mode 100644 src/__tests__/snapshots/StatusBadge.snapshot.test.tsx 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/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/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(); + }); +}); From 5bc84507b2b782cfdbd809a16e3b5793852f6390 Mon Sep 17 00:00:00 2001 From: luhrhenz Date: Sun, 18 Oct 2026 17:04:09 +0100 Subject: [PATCH 4/4] Add test data factories and fixtures for consistent testing (#907) Adds reusable factory functions (buildInvoice/buildPayment/buildUser) with sequence-based unique ids, override support, list builders, and lifecycle fixtures, plus tests proving the factories and documentation of the pattern. --- src/test/factories/README.md | 81 +++++++++++++++++++ src/test/factories/factories.test.ts | 95 ++++++++++++++++++++++ src/test/factories/index.ts | 18 +++++ src/test/factories/invoiceFactory.ts | 115 +++++++++++++++++++++++++++ src/test/factories/paymentFactory.ts | 75 +++++++++++++++++ src/test/factories/userFactory.ts | 58 ++++++++++++++ 6 files changed, 442 insertions(+) create mode 100644 src/test/factories/README.md create mode 100644 src/test/factories/factories.test.ts create mode 100644 src/test/factories/index.ts create mode 100644 src/test/factories/invoiceFactory.ts create mode 100644 src/test/factories/paymentFactory.ts create mode 100644 src/test/factories/userFactory.ts 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" }), +};