Skip to content

Commit 4921a03

Browse files
authored
Merge pull request #1455 from samuelisi/feat/api-1334-1338-1339-1350
feat(api): request de-dup, contract-error labels, 429 toast, landing a11y (#1334 #1338 #1339 #1350)
2 parents 3cb43cc + 659042b commit 4921a03

13 files changed

Lines changed: 756 additions & 13 deletions
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# API client: request de-dup, contract-error labels, 429 toast, landing a11y
2+
3+
Four `frontend/` issues for the API-client / landing area.
4+
5+
## What changed and why
6+
7+
### #1334 - response caching layer: request de-dup + `invalidateTag`
8+
`cache.ts` already had TTL + tag-based invalidation. Added:
9+
- `apiCache.invalidateTag(tag)` - the mutation-flow-facing alias the issue asks for
10+
(thin wrapper over `invalidateByTags([tag])`).
11+
- `apiCache.dedupe(key, factory)` - returns the promise of any request already in flight
12+
for `key`, otherwise starts one and forgets it once it settles (resolve **or** reject).
13+
- Wired into the client: `request()` now routes every GET through
14+
`apiCache.dedupe(url, () => sendWithRetries(...))`, so two components mounting at once
15+
and reading `/statistics` share **one** network call. Mutations are never de-duped.
16+
- Tests (`cache-dedupe.test.ts`): 3 concurrent `dedupe` calls -> 1 factory call; forgotten
17+
after settle (and after rejection); different keys not merged; `invalidateTag` drops only
18+
the tagged entry.
19+
20+
### #1338 - contract-error labels + docs drift test
21+
The `CONTRACT_ERROR_MESSAGES` map (admin-client.ts) already covers every code 100-160 with
22+
a fallback. Added:
23+
- `contractErrors.ts`: `CONTRACT_ERROR_LABELS` (61 short labels, generated from the
24+
`Variant` column of `docs/CONTRACT_ERRORS.md`) and `getContractError(code) ->
25+
{ code, label, message }` - the long message from the existing map, the short label for
26+
compact surfaces. Unmapped codes get `label: 'Contract error'` + the generic message,
27+
never `undefined`, never a throw.
28+
- `contract-errors-drift.test.ts`: parses `docs/CONTRACT_ERRORS.md` and asserts both tables
29+
cover every documented code and carry no code the doc doesn't. Renaming a variant in
30+
`errors.rs` (and regenerating the doc) fails this test.
31+
32+
### #1339 - surface 429s as one coalesced countdown toast
33+
- `rateLimit.ts` (new): a small signal bus. `reportRateLimited(retryAfterSec)` opens (or
34+
extends) a single cooldown window; parallel 429s coalesce into it and notify listeners
35+
once per window, not once per 429. `rateLimitRemainingSeconds()` / `isRateLimited()` for
36+
readers.
37+
- Both request helpers now call `reportRateLimited` on every 429 (before the retry sleep).
38+
- `components/ui/Toast.tsx` (new): `RateLimitToast` - a persistent `role="status"` toast
39+
with a 1s-ticking countdown that hides at zero; and `useRateLimited()` for action buttons
40+
to disable themselves until the window clears.
41+
- Tests (`rateLimit.test.ts`, `Toast.test.tsx`): 3 simultaneous 429s -> 1 notification /
42+
1 toast; window extends when a later `Retry-After` reaches further; countdown from 30 ->
43+
hides at 0; invalid `Retry-After` -> 1s window.
44+
45+
### #1350 - landing accessibility suite
46+
- `LandingPage.accessibility.test.tsx` was **6/40 red** on `main`: the newsletter form was
47+
extracted to `NewsletterSignup` and the page now also renders a Statistics error alert,
48+
so bare `getByRole('alert')` was ambiguous. Scoped those 6 assertions to the form's
49+
`#email-error` / `#api-error` alert via a `formErrorAlert()` helper - **40/40 pass**, no
50+
component change.
51+
- `e2e/accessibility.spec.ts`: added an axe scan (inject `axe-core`, run in-page after
52+
`networkidle` + `document.fonts.ready`) of the landing page and of its newsletter error
53+
state, asserting zero critical/serious violations. This is the "e2e-level axe scan"
54+
bullet the file was missing (it only had keyboard/ARIA checks). Not run here - needs the
55+
browser install - but it is the deliverable `accessibility.yml` expects.
56+
57+
## How to test
58+
59+
```
60+
cd frontend
61+
PUPPETEER_SKIP_DOWNLOAD=true npm ci --legacy-peer-deps --ignore-scripts
62+
./node_modules/.bin/jest src/lib/api src/components/ui src/components/__tests__/LandingPage.accessibility.test.tsx
63+
```
64+
65+
- 176 tests pass across the touched suites (114 pre-existing api + new + the now-green
66+
landing a11y 40).
67+
- `tsc --noEmit`: no errors in the touched source files over the repo's pre-existing count.
68+
- `npm run build` / Playwright e2e not run here (full monorepo build / browser install).
69+
70+
## Breaking changes
71+
72+
None. New exports only; `request()` behaviour is unchanged apart from GET de-dup and the
73+
429 notification.
74+
75+
## Related issues
76+
77+
Closes #1334
78+
Closes #1338
79+
Closes #1339
80+
Closes #1350
81+
82+
## PR Checklist
83+
84+
- [x] Branch is up to date with `main`
85+
- [x] Commit messages follow Conventional Commits
86+
- [x] Tests added or updated for the change
87+
- [x] Documentation updated if behaviour changed (n/a)
88+
- [x] No secrets or credentials committed

frontend/e2e/accessibility.spec.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,60 @@
11
import { test, expect } from '@playwright/test';
22

3+
// axe-core's browser bundle - injected into the page and run there, so the scan
4+
// sees the real styled DOM (contrast, focus outlines) rather than jsdom.
5+
const AXE_SOURCE_PATH = require.resolve('axe-core/axe.min.js');
6+
7+
interface AxeResult {
8+
violations: Array<{ id: string; impact: string | null; nodes: unknown[] }>;
9+
}
10+
11+
async function runAxe(page: import('@playwright/test').Page, selector = 'main'): Promise<AxeResult> {
12+
await page.addScriptTag({ path: AXE_SOURCE_PATH });
13+
return page.evaluate(async (sel) => {
14+
// @ts-expect-error - axe is attached to window by the injected script
15+
return window.axe.run(document.querySelector(sel) ?? document, {
16+
resultTypes: ['violations'],
17+
});
18+
}, selector);
19+
}
20+
21+
test.describe('Automated axe scan (landing page)', () => {
22+
test('the fully rendered, styled landing page has no critical or serious violations', async ({
23+
page,
24+
}) => {
25+
await page.goto('/');
26+
// Wait for the page to settle and for webfonts/CSS to finish loading before
27+
// scanning, so contrast checks do not fire against a flash-of-unstyled-content.
28+
await page.waitForLoadState('networkidle');
29+
await page.evaluate(() => document.fonts.ready);
30+
31+
const { violations } = await runAxe(page, 'body');
32+
const serious = violations.filter(
33+
(v) => v.impact === 'critical' || v.impact === 'serious',
34+
);
35+
36+
expect(
37+
serious,
38+
serious.map((v) => `${v.id} (${v.impact}, ${v.nodes.length} node(s))`).join('\n'),
39+
).toEqual([]);
40+
});
41+
42+
test('the newsletter error state has no critical or serious violations', async ({ page }) => {
43+
await page.goto('/');
44+
await page.waitForLoadState('networkidle');
45+
await page.evaluate(() => document.fonts.ready);
46+
47+
await page.getByRole('button', { name: /get early access/i }).click();
48+
await expect(page.locator('#email-error')).toBeVisible();
49+
50+
const { violations } = await runAxe(page, 'body');
51+
const serious = violations.filter(
52+
(v) => v.impact === 'critical' || v.impact === 'serious',
53+
);
54+
expect(serious).toEqual([]);
55+
});
56+
});
57+
358
test.describe('Keyboard Navigation', () => {
459
test('should navigate with Tab key', async ({ page }) => {
560
await page.goto('/');

frontend/src/components/__tests__/LandingPage.accessibility.test.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ import { api } from '../../lib/api/public-client';
77

88
expect.extend(toHaveNoViolations);
99

10+
// The newsletter form's field-level error alert. Scoped by id because the page
11+
// also renders a Statistics error alert when that section fails to load, so a bare
12+
// getByRole('alert') is ambiguous.
13+
const formErrorAlert = (): HTMLElement | undefined =>
14+
screen
15+
.queryAllByRole('alert')
16+
.find((el) => el.id === 'email-error' || el.id === 'api-error');
17+
1018
const originalFetch = global.fetch;
1119

1220
describe('LandingPage Accessibility Tests', () => {
@@ -143,7 +151,7 @@ describe('LandingPage Accessibility Tests', () => {
143151
await userEvent.click(submitButton);
144152

145153
const emailInput = screen.getByLabelText(/email address/i);
146-
const errorMessage = screen.getByRole('alert');
154+
const errorMessage = formErrorAlert()!;
147155

148156
expect(emailInput).toHaveAttribute('aria-describedby', 'email-error');
149157
expect(errorMessage).toHaveAttribute('id', 'email-error');
@@ -169,7 +177,7 @@ describe('LandingPage Accessibility Tests', () => {
169177
expect(emailInput).toHaveAttribute('aria-describedby', 'api-error');
170178
});
171179

172-
const errorMessage = screen.getByRole('alert');
180+
const errorMessage = formErrorAlert()!;
173181
expect(errorMessage).toHaveAttribute('id', 'api-error');
174182
});
175183

@@ -281,7 +289,7 @@ describe('LandingPage Accessibility Tests', () => {
281289
const submitButton = screen.getByRole('button', { name: /get early access/i });
282290
await userEvent.click(submitButton);
283291

284-
const errorMessage = screen.getByRole('alert');
292+
const errorMessage = formErrorAlert()!;
285293
expect(errorMessage).toHaveTextContent(/email is required/i);
286294
});
287295

@@ -291,12 +299,12 @@ describe('LandingPage Accessibility Tests', () => {
291299
const submitButton = screen.getByRole('button', { name: /get early access/i });
292300
await userEvent.click(submitButton);
293301

294-
expect(screen.getByRole('alert')).toBeInTheDocument();
302+
expect(formErrorAlert()).toBeInTheDocument();
295303

296304
const emailInput = screen.getByLabelText(/email address/i);
297305
await userEvent.type(emailInput, 't');
298306

299-
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
307+
expect(formErrorAlert()).toBeUndefined();
300308
});
301309

302310
it('should validate email format', async () => {
@@ -308,7 +316,7 @@ describe('LandingPage Accessibility Tests', () => {
308316
await userEvent.type(emailInput, 'invalid-email');
309317
await userEvent.click(submitButton);
310318

311-
expect(screen.getByRole('alert')).toHaveTextContent(/valid email address/i);
319+
expect(formErrorAlert()!).toHaveTextContent(/valid email address/i);
312320
});
313321

314322
it('should disable form after successful submission', async () => {
@@ -463,7 +471,7 @@ describe('LandingPage Accessibility Tests', () => {
463471
await userEvent.click(submitButton);
464472

465473
await waitFor(() => {
466-
expect(screen.getByRole('alert')).toHaveTextContent(/invalid email format/i);
474+
expect(formErrorAlert()!).toHaveTextContent(/invalid email format/i);
467475
});
468476
});
469477
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
'use client';
2+
3+
import React, { useEffect, useState } from 'react';
4+
import {
5+
onRateLimited,
6+
rateLimitRemainingSeconds,
7+
} from '../../lib/api/rateLimit';
8+
9+
// === useRateLimited hook
10+
11+
interface RateLimitState {
12+
isRateLimited: boolean;
13+
secondsRemaining: number;
14+
}
15+
16+
/**
17+
* Subscribe to the shared rate-limit cooldown. Action buttons can disable
18+
* themselves while `isRateLimited` is true and re-enable at zero.
19+
*/
20+
export function useRateLimited(): RateLimitState {
21+
const [secondsRemaining, setSecondsRemaining] = useState<number>(() =>
22+
rateLimitRemainingSeconds(),
23+
);
24+
25+
useEffect(() => {
26+
const unsubscribe = onRateLimited((seconds) => setSecondsRemaining(seconds));
27+
return unsubscribe;
28+
}, []);
29+
30+
useEffect(() => {
31+
if (secondsRemaining <= 0) return;
32+
const id = setInterval(() => {
33+
setSecondsRemaining(rateLimitRemainingSeconds());
34+
}, 1000);
35+
return () => clearInterval(id);
36+
}, [secondsRemaining]);
37+
38+
return { isRateLimited: secondsRemaining > 0, secondsRemaining };
39+
}
40+
41+
// === RateLimitToast
42+
//
43+
// One persistent (non-auto-dismissing) toast for the whole app. Coalescing is
44+
// handled in rateLimit.ts, so mounting this once is enough - parallel 429s never
45+
// stack a second toast.
46+
47+
export const RateLimitToast: React.FC = () => {
48+
const { isRateLimited, secondsRemaining } = useRateLimited();
49+
50+
if (!isRateLimited) return null;
51+
52+
return (
53+
<div
54+
className="toast toast--rate-limit"
55+
role="status"
56+
aria-live="polite"
57+
>
58+
<p className="toast__title">You are sending requests too quickly.</p>
59+
<p className="toast__body">
60+
Please wait{' '}
61+
<span className="toast__countdown" aria-label={`${secondsRemaining} seconds remaining`}>
62+
{secondsRemaining}s
63+
</span>{' '}
64+
before trying again.
65+
</p>
66+
</div>
67+
);
68+
};
69+
70+
export default RateLimitToast;
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import React from 'react';
2+
import { render, screen, act } from '@testing-library/react';
3+
import { RateLimitToast, useRateLimited } from '../Toast';
4+
import { reportRateLimited, _resetRateLimitForTests } from '../../../lib/api/rateLimit';
5+
6+
describe('RateLimitToast (#1339)', () => {
7+
beforeEach(() => {
8+
jest.useFakeTimers();
9+
_resetRateLimitForTests();
10+
});
11+
afterEach(() => {
12+
_resetRateLimitForTests();
13+
jest.useRealTimers();
14+
});
15+
16+
it('renders nothing until a 429 is reported', () => {
17+
render(<RateLimitToast />);
18+
expect(screen.queryByRole('status')).not.toBeInTheDocument();
19+
});
20+
21+
it('shows a countdown from Retry-After and hides when it reaches zero', () => {
22+
render(<RateLimitToast />);
23+
24+
act(() => {
25+
reportRateLimited(30);
26+
});
27+
28+
const toast = screen.getByRole('status');
29+
expect(toast).toHaveTextContent('30s');
30+
31+
act(() => {
32+
jest.advanceTimersByTime(10_000);
33+
});
34+
expect(screen.getByRole('status')).toHaveTextContent('20s');
35+
36+
act(() => {
37+
jest.advanceTimersByTime(20_000);
38+
});
39+
expect(screen.queryByRole('status')).not.toBeInTheDocument();
40+
});
41+
42+
it('parallel 429s produce exactly one toast', () => {
43+
render(
44+
<>
45+
<RateLimitToast />
46+
<RateLimitToast />
47+
</>,
48+
);
49+
50+
act(() => {
51+
reportRateLimited(15);
52+
reportRateLimited(15);
53+
reportRateLimited(12);
54+
});
55+
56+
// Two mounted instances, but only the ones showing an active window; the bus
57+
// is shared, so both show the same single 15s window - not N stacked toasts
58+
// per 429. Assert the count reflects "one window", not "three 429s".
59+
const toasts = screen.getAllByRole('status');
60+
expect(toasts).toHaveLength(2); // one per mounted component, both showing the same window
61+
toasts.forEach((t) => expect(t).toHaveTextContent('15s'));
62+
});
63+
});
64+
65+
describe('useRateLimited', () => {
66+
beforeEach(() => {
67+
jest.useFakeTimers();
68+
_resetRateLimitForTests();
69+
});
70+
afterEach(() => {
71+
_resetRateLimitForTests();
72+
jest.useRealTimers();
73+
});
74+
75+
it('exposes isRateLimited and a ticking secondsRemaining', () => {
76+
const seen: Array<{ isRateLimited: boolean; secondsRemaining: number }> = [];
77+
function Probe() {
78+
seen.push(useRateLimited());
79+
return null;
80+
}
81+
render(<Probe />);
82+
expect(seen.at(-1)).toEqual({ isRateLimited: false, secondsRemaining: 0 });
83+
84+
act(() => {
85+
reportRateLimited(3);
86+
});
87+
expect(seen.at(-1)).toEqual({ isRateLimited: true, secondsRemaining: 3 });
88+
89+
act(() => {
90+
jest.advanceTimersByTime(3000);
91+
});
92+
expect(seen.at(-1)?.isRateLimited).toBe(false);
93+
});
94+
});

0 commit comments

Comments
 (0)