Skip to content

Commit ecdeba0

Browse files
authored
Merge pull request Pulsefy#993 from zhero-o/feat/766-frontend-a11y-ci-checks
feat(frontend): add automated a11y checks to CI (Pulsefy#766)
2 parents dfef839 + f5b2fca commit ecdeba0

13 files changed

Lines changed: 978 additions & 8 deletions

File tree

.github/workflows/frontend-ci.yml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,17 @@ jobs:
5252
- name: Checkout code
5353
uses: actions/checkout@v4
5454

55+
# Node 22, not 20: jsdom@30 bundles undici@8, whose CacheStorage
56+
# constructor calls the node:worker_threads `markAsUncloneable` builtin
57+
# unconditionally (no feature-detection/fallback). That builtin only
58+
# landed in Node 20.13.0/21.7.0, and CI's Node 20 install predates it,
59+
# so any jsdom-environment test (e.g. the a11y suite below) crashed
60+
# with "webidl.util.markAsUncloneable is not a function" the moment
61+
# jsdom instantiated its Cache API. Node 22 carries it.
5562
- name: Setup Node.js
5663
uses: actions/setup-node@v4
5764
with:
58-
node-version: '20'
65+
node-version: '22'
5966
cache: 'npm'
6067
cache-dependency-path: app/frontend/package-lock.json
6168

@@ -93,6 +100,14 @@ jobs:
93100
working-directory: ./app/frontend
94101
run: npm test || npx vitest run
95102

103+
# #766: automated axe-core a11y audit of the QR/payment components
104+
# (generator, pay page, payment states, QR preview). Run as its own
105+
# step, like the i18n parity check above, so it isn't silently skipped
106+
# by the wider unit test suite.
107+
- name: Run accessibility (a11y) checks
108+
working-directory: ./app/frontend
109+
run: npx vitest run __tests__/a11y-payment-flow.test.tsx
110+
96111
build:
97112
name: Build Frontend
98113
runs-on: ubuntu-latest

.github/workflows/frontend-e2e.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ jobs:
5353
working-directory: ./e2e
5454
run: npx playwright install --with-deps chromium
5555

56+
# Runs every spec under e2e/tests, including a11y-pay-flow.spec.ts
57+
# (#766: axe-core audit of the generator and pay pages).
5658
- name: Run pay → receipt E2E
5759
if: steps.preview.outputs.configured == 'true'
5860
working-directory: ./e2e

CONTRIBUTING.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ If you prefer to set up the environment manually on your host machine:
6666
- Follow the [Conventional Commits](https://www.conventionalcommits.org/) style.
6767
- Add/Update documentation as needed.
6868

69+
## Accessibility
70+
71+
The frontend is checked in CI with `eslint-plugin-jsx-a11y` (errors on the
72+
QR/payment flow, warnings elsewhere) and `jest-axe`/`axe-core` audits of the
73+
payment link generator, pay page, and payment-state components — see
74+
[app/frontend/CONTRIBUTING.md](app/frontend/CONTRIBUTING.md#accessibility)
75+
for the checklist to follow when touching UI.
76+
6977
## 8-Week MVP Roadmap & Feature Prioritization
7078

7179
See [docs/MVP-ROADMAP.md](docs/MVP-ROADMAP.md) for the full roadmap and priorities.

app/frontend/CONTRIBUTING.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,37 @@ Thank you for your interest in contributing to the QuickEx frontend!
2323
- Follow the **vibrant dark theme** (neutral-950 background, indigo-500 accents).
2424
- Ensure all interactive elements have hover and focus states.
2525
- Maintain a **premium, clean aesthetic** with consistent spacing and typography.
26+
27+
## Accessibility
28+
29+
Payments and QR flows are how money moves, so a user relying on a screen
30+
reader or keyboard must be able to complete them. When you touch UI code:
31+
32+
- Every interactive element (buttons, links, form controls) needs an
33+
accessible name — visible text, `aria-label`, or a `<label>` properly
34+
associated with its control (`htmlFor`/`id`, or nesting where the control
35+
is directly inside the `<label>`).
36+
- Decorative icons/SVGs get `aria-hidden="true"`; icons that convey meaning
37+
on their own need an accessible label.
38+
- Custom interactive elements (a `<div>` with `onClick`) need a real
39+
`role`, `tabIndex`, and keyboard handlers, or — preferably — should just
40+
be a `<button>`.
41+
- Don't rely on color alone to convey state (error/success/expired); pair
42+
it with text or an icon.
43+
- Preserve `focus-visible` ring styles on interactive elements; don't
44+
suppress the browser's default focus outline without replacing it.
45+
46+
**Enforcement**: `eslint-plugin-jsx-a11y` runs in CI. It's a hard error
47+
(`--max-warnings 0`) for the QR/payment flow — `src/app/generator/**`,
48+
`src/app/pay/**`, `src/components/payment-states/**`,
49+
`src/components/QRPreview.tsx`, `src/components/SigningSummary.tsx` — see
50+
`eslint.config.mjs`. It's a warning elsewhere while the rest of the app
51+
catches up; please still fix what you touch. `__tests__/a11y-payment-flow.test.tsx`
52+
runs a `jest-axe` audit over the same components, and
53+
`e2e/tests/a11y-pay-flow.spec.ts` runs an `axe-core` audit against the live
54+
generator and pay pages (gated on `PREVIEW_BASE_URL`, same as the
55+
pay-to-receipt E2E). Run them locally with:
56+
57+
```bash
58+
npx vitest run __tests__/a11y-payment-flow.test.tsx
59+
```
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// @vitest-environment jsdom
2+
//
3+
// Automated accessibility checks for the QR/payment flow (#766). These
4+
// components render the pay-to-receipt path exercised by
5+
// e2e/tests/pay-to-receipt.spec.ts, so an axe violation here means a real
6+
// user relying on a screen reader or keyboard could get stuck paying or
7+
// receiving a payment.
8+
import { describe, expect, it } from "vitest";
9+
import { render } from "@testing-library/react";
10+
import { axe, toHaveNoViolations } from "jest-axe";
11+
import { QRPreview } from "@/components/QRPreview";
12+
import { SigningSummary } from "@/components/SigningSummary";
13+
import { ActivePaymentState } from "@/components/payment-states/ActivePaymentState";
14+
import { PaidPaymentState } from "@/components/payment-states/PaidPaymentState";
15+
import { ExpiredPaymentState } from "@/components/payment-states/ExpiredPaymentState";
16+
import { RefundedPaymentState } from "@/components/payment-states/RefundedPaymentState";
17+
18+
expect.extend(toHaveNoViolations);
19+
20+
const activeStatus = {
21+
username: "alice",
22+
amount: "100",
23+
asset: "USDC",
24+
memo: null,
25+
destinationPublicKey: "GABC1234567890",
26+
expiresAt: null,
27+
swapOptions: null,
28+
acceptsMultipleAssets: false,
29+
acceptedAssets: null,
30+
userMessage: "Complete this payment to alice.",
31+
availableActions: ["pay"],
32+
};
33+
34+
const paidStatus = {
35+
username: "alice",
36+
amount: "100",
37+
asset: "USDC",
38+
memo: null,
39+
transactionHash: "abc123",
40+
paidAt: new Date().toISOString(),
41+
userMessage: "Payment received.",
42+
};
43+
44+
const expiredStatus = {
45+
username: "alice",
46+
amount: "100",
47+
asset: "USDC",
48+
memo: null,
49+
expiresAt: new Date().toISOString(),
50+
userMessage: "This link has expired.",
51+
};
52+
53+
describe("QR/payment flow accessibility", () => {
54+
it("QRPreview with a value has no axe violations", async () => {
55+
const { container } = render(<QRPreview value="stellar:GABC?amount=100" />);
56+
expect(await axe(container)).toHaveNoViolations();
57+
});
58+
59+
it("QRPreview placeholder (no value) has no axe violations", async () => {
60+
const { container } = render(<QRPreview />);
61+
expect(await axe(container)).toHaveNoViolations();
62+
});
63+
64+
it("SigningSummary has no axe violations", async () => {
65+
const { container } = render(
66+
<SigningSummary
67+
action="purchase"
68+
amount={{ value: 100, asset: "USDC" }}
69+
details={[{ label: "Recipient", value: "alice" }]}
70+
expiry={new Date(Date.now() + 60_000)}
71+
fee={{ value: 1, asset: "USDC", percentage: 1 }}
72+
/>,
73+
);
74+
expect(await axe(container)).toHaveNoViolations();
75+
});
76+
77+
it("ActivePaymentState has no axe violations", async () => {
78+
const { container } = render(
79+
<ActivePaymentState
80+
status={activeStatus}
81+
onPaymentInitiated={() => {}}
82+
onPaymentCompleted={() => {}}
83+
/>,
84+
);
85+
expect(await axe(container)).toHaveNoViolations();
86+
});
87+
88+
it("PaidPaymentState has no axe violations", async () => {
89+
const { container } = render(<PaidPaymentState status={paidStatus} />);
90+
expect(await axe(container)).toHaveNoViolations();
91+
});
92+
93+
it("ExpiredPaymentState has no axe violations", async () => {
94+
const { container } = render(<ExpiredPaymentState status={expiredStatus} />);
95+
expect(await axe(container)).toHaveNoViolations();
96+
});
97+
98+
it("RefundedPaymentState has no axe violations", async () => {
99+
const { container } = render(<RefundedPaymentState status={paidStatus} />);
100+
expect(await axe(container)).toHaveNoViolations();
101+
});
102+
});

app/frontend/eslint.config.mjs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { dirname } from "path";
22
import { fileURLToPath } from "url";
33
import { FlatCompat } from "@eslint/eslintrc";
4+
import jsxA11y from "eslint-plugin-jsx-a11y";
45

56
const __filename = fileURLToPath(import.meta.url);
67
const __dirname = dirname(__filename);
@@ -9,8 +10,27 @@ const compat = new FlatCompat({
910
baseDirectory: __dirname,
1011
});
1112

13+
// Payment/QR flows carry real deployment and money-movement risk (#766), so
14+
// a11y rules are enforced there first as CI-breaking errors. The rest of the
15+
// app isn't covered yet — enabling jsx-a11y repo-wide under the existing
16+
// `--max-warnings 0` gate would fail CI on unrelated pre-existing findings;
17+
// widen this list as those get addressed.
18+
const paymentFlowGlobs = [
19+
"src/app/generator/**/*.{ts,tsx}",
20+
"src/app/pay/**/*.{ts,tsx}",
21+
"src/components/payment-states/**/*.{ts,tsx}",
22+
"src/components/QRPreview.tsx",
23+
"src/components/SigningSummary.tsx",
24+
];
25+
1226
const eslintConfig = [
1327
...compat.extends("next/core-web-vitals", "next/typescript"),
28+
{
29+
// next/core-web-vitals already registers the jsx-a11y plugin, so this
30+
// override only sets rule severities and must not redeclare `plugins`.
31+
files: paymentFlowGlobs,
32+
rules: jsxA11y.flatConfigs.recommended.rules,
33+
},
1434
{
1535
ignores: [
1636
"node_modules/**",

0 commit comments

Comments
 (0)