Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/performance.yml
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions docs/ACCESSIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
59 changes: 59 additions & 0 deletions docs/PERFORMANCE.md
Original file line number Diff line number Diff line change
@@ -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.
58 changes: 58 additions & 0 deletions docs/SNAPSHOT_TESTING.md
Original file line number Diff line number Diff line change
@@ -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`.
18 changes: 18 additions & 0 deletions instrumentation-client.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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;
23 changes: 23 additions & 0 deletions lighthouserc.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
52 changes: 52 additions & 0 deletions src/__tests__/a11y/componentA11y.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<StatusBadge status="Active" />);
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(<StatusBadge status={status} />);
await expectNoSeriousOrCriticalViolations(container);
unmount();
}
});

it("Skeleton primitive has no a11y violations", async () => {
const { container } = render(<Skeleton className="h-4 w-24" />);
await expectNoSeriousOrCriticalViolations(container);
});

it("SkeletonCard has no a11y violations", async () => {
const { container } = render(<SkeletonCard />);
await expectNoSeriousOrCriticalViolations(container);
});
});
15 changes: 15 additions & 0 deletions src/__tests__/snapshots/Skeleton.snapshot.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Skeleton className="h-4 w-24" />);
expect(container).toMatchSnapshot();
});

it("renders SkeletonCard", () => {
const { container } = render(<SkeletonCard />);
expect(container).toMatchSnapshot();
});
});
26 changes: 26 additions & 0 deletions src/__tests__/snapshots/StatusBadge.snapshot.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<StatusBadge status="Active" />);
expect(container).toMatchSnapshot();
});

it("renders the sm size", () => {
const { container } = render(<StatusBadge status="Pending" size="sm" />);
expect(container).toMatchSnapshot();
});

it("renders the lg size with a custom description", () => {
const { container } = render(
<StatusBadge status="Disputed" size="lg" description="Under active dispute review" />
);
expect(container).toMatchSnapshot();
});
});
Loading