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
70 changes: 70 additions & 0 deletions .github/workflows/frontend-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: Frontend CI

on:
push:
branches: [main, develop]
paths:
- 'app/frontend/**'
- '.github/workflows/frontend-ci.yml'
pull_request:
branches: [main, develop]
paths:
- 'app/frontend/**'
- '.github/workflows/frontend-ci.yml'

jobs:
test:
name: Frontend tests & accessibility audit
runs-on: ubuntu-latest
defaults:
run:
working-directory: app/frontend

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9

- name: Get pnpm store directory
id: pnpm-cache
run: echo "store-dir=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"

- name: Cache pnpm modules
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.store-dir }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Type check
run: pnpm type-check

- name: Lint
run: pnpm lint --max-warnings=0

- name: Run unit & jest-axe accessibility tests
# --ci ensures deterministic output and a clean exit code on test failures.
run: pnpm jest --ci --colors=false

- name: Upload test summary
if: always()
uses: actions/upload-artifact@v4
with:
name: frontend-test-summary
path: |
app/frontend/coverage
if-no-files-found: ignore
retention-days: 7
20 changes: 20 additions & 0 deletions app/frontend/LIGHTHOUSE_CI.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Lighthouse CI (optional, not gated in CI)

The `lighthouserc.json` in this directory configures [Lighthouse CI][lighthouse-ci]
to perform an accessibility audit of the production build.

## When does it run?

It is **not** executed by `.github/workflows/frontend-ci.yml` because
Lighthouse needs a live, fully-rendered page (the static `jest-axe` unit
tests already catch the same class of violations faster and with zero
infrastructure overhead).

Use this configuration:

- During code review, when changing navigation, theming, or layout.
- Locally before tagging a release: `pnpm dlx @lhci/cli autorun`.
- In a separate, opt-in workflow when accessibility is the focus of the
story being shipped.

[lighthouse-ci]: https://github.com/GoogleChrome/lighthouse-ci
6 changes: 6 additions & 0 deletions app/frontend/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ const config: Config = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
/**
* Load global setup modules AFTER the testing framework is installed so
* custom matchers (e.g. jest-axe's `toHaveNoViolations`) can be registered
* via `expect.extend(...)`.
*/
setupFilesAfterEnv: ['<rootDir>/jest.setup.a11y.ts'],
};

export default config;
16 changes: 16 additions & 0 deletions app/frontend/jest.setup.a11y.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Global Jest setup that registers jest-axe accessibility matchers so they
* are available in any *.a11y.test.* file across the project.
*
* The matchers are loaded lazily because jest-axe expects to be invoked from
* inside the Jest test environment (jsdom). Tests that exercise this setup
* are expected to opt into the jsdom environment via the
* `@jest-environment jsdom` pragma at the top of the test file.
*/
import { toHaveNoViolations } from 'jest-axe';

// `expect.extend` is parameterised by `ExpectExtendMap`, which jest's
// DefinitelyTyped types define as a record keyed by matcher name. The
// matcher shape jest-axe exports is function-like; the cast below keeps the
// runtime contract identical while satisfying strict TS.
expect.extend(toHaveNoViolations as unknown as Parameters<typeof expect.extend>[0]);
23 changes: 23 additions & 0 deletions app/frontend/lighthouserc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"ci": {
"collect": {
"startServerCommand": "pnpm start",
"url": [
"http://localhost:3000/"
],
"numberOfRuns": 1
},
"assert": {
"assertions": {
"categories:accessibility": [
"error",
{ "minScore": 0.9 }
],
"categories:best-practices": "warn",
"categories:performance": "off",
"categories:pwa": "off",
"categories:seo": "off"
}
}
}
}
2 changes: 2 additions & 0 deletions app/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,11 @@
"@types/papaparse": "^5.3.16",
"@types/react": "^19",
"@types/react-dom": "^19",
"axe-core": "^4.12.1",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.1",
"jest": "^30.3.0",
"jest-axe": "^10.0.0",
"jest-environment-jsdom": "^30.0.0",
"tailwindcss": "^4",
"ts-jest": "^29.4.6",
Expand Down
18 changes: 15 additions & 3 deletions app/frontend/src/components/ActivityCenter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,18 @@ export function ActivityCenter() {
>
<Bell size={20} />
{pendingCount > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center">
<span
aria-hidden="true"
className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center"
>
{pendingCount}
</span>
)}
<span className="sr-only">
{pendingCount > 0
? `${pendingCount} pending activity notifications`
: 'No pending activity'}
</span>
</button>

{isOpen && (
Expand All @@ -81,10 +89,12 @@ export function ActivityCenter() {
</button>
)}
<button
type="button"
onClick={() => setIsOpen(false)}
aria-label="Close activity center"
className="p-1 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700"
>
<X size={16} />
<X size={16} aria-hidden="true" focusable="false" />
</button>
</div>
</div>
Expand Down Expand Up @@ -134,11 +144,13 @@ export function ActivityCenter() {
)}
</div>
<button
type="button"
onClick={() => removeActivity(activity.id)}
className="p-1 rounded-full hover:bg-slate-200 dark:hover:bg-slate-600 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={`Remove activity: ${activity.title}`}
title={t('activity.remove')}
>
<X size={14} />
<X size={14} aria-hidden="true" focusable="false" />
</button>
</div>

Expand Down
8 changes: 6 additions & 2 deletions app/frontend/src/components/ErrorInline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,12 @@ export function ErrorInline({
)}
{onClose && (
<button
type="button"
onClick={onClose}
aria-label={`Dismiss ${metadata.title} banner`}
className="p-1 opacity-50 transition-opacity hover:opacity-100"
>
<XCircle size={16} />
<XCircle size={16} aria-hidden="true" focusable="false" />
</button>
)}
</div>
Expand All @@ -118,10 +120,12 @@ export function ErrorInline({

{onClose && (
<button
type="button"
onClick={onClose}
aria-label={`Dismiss ${metadata.title} error`}
className="text-slate-500 hover:text-slate-300 transition-colors"
>
<XCircle size={18} />
<XCircle size={18} aria-hidden="true" focusable="false" />
</button>
)}
</div>
Expand Down
2 changes: 1 addition & 1 deletion app/frontend/src/components/EvidenceArtifactViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ export const EvidenceArtifactViewer: React.FC<EvidenceArtifactViewerProps> = ({
<div className="border-b border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<h3 className="text-lg font-semibold">{artifact.metadata.filename}</h3>
<h2 className="text-lg font-semibold">{artifact.metadata.filename}</h2>
<span className="text-sm text-gray-500">
{artifact.metadata.type} • {(artifact.metadata.size / 1024 / 1024).toFixed(2)} MB
</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Shared helpers for accessibility (jest-axe) tests.
*
* Importing this module pulls in jest-axe (registered globally via
* jest.setup.a11y.ts) and exposes `renderAndCheckA11y` so per-component
* tests can simply describe their rendered tree and run axe over it.
*
* The `rendered` parameter must be a promise (React.render is async-aware
* with hooks, even when not awaiting Suspense boundaries) — testers can
* pass a sync render call and jest-axe will still scan the resulting DOM
* via the synchronous fallback.
*/
import { render, type RenderOptions, type RenderResult } from '@testing-library/react';
import { axe, type Result as AxeResult } from 'jest-axe';
import type { ReactElement } from 'react';

/** Options accepted by `renderAndCheckA11y`. */
export interface A11yOptions extends RenderOptions {
/**
* Optional axe.run context object. Defaults to scanning the whole
* document. Useful for restricting the scan to a specific region.
*/
axeContext?: Parameters<typeof axe>[1];
}

/** Convenience wrapper that returns the rendered tree and the axe report. */
export async function renderAndCheckA11y(
ui: ReactElement,
options: A11yOptions = {},
): Promise<{ result: RenderResult; axeReport: { violations: AxeResult['violations'] } }> {
const result = render(ui, options);
const axeReport = await axe(result.container, options.axeContext ?? {});
return { result, axeReport };
}

/**
* Pretty-print the severity-impacted rules so failing assertions in CI logs
* are easy to triage. Returned as a multi-line string for snapshot or
* message interpolation.
*/
export function summariseViolations(violations: AxeResult['violations']): string {
if (violations.length === 0) return 'No accessibility violations detected.';
return violations
.map((v) => {
const targets = v.nodes
.map((n) => n.target.join(' >> '))
.slice(0, 3)
.join('\n - ');
return `[${v.impact?.toUpperCase() ?? 'UNKNOWN'}] ${v.id}\n ${v.help}\n Targets:\n - ${targets}`;
})
.join('\n\n');
}
Loading
Loading