Files
redefined-designs/frontend/tests/e2e/error-boundary.spec.ts
T
bermudalambandClaude Opus 5 f156cecd87 feat(frontend): mount error boundaries at the root, the item grid and the modals (#62)
Three mount points, so a render error costs the smallest part of the page it can.

The catalogue boundary is the one that earns its keep. The likeliest throw in this app is a component rendering data from the API, and the item grid renders the most of it per page — contained there, the header, cart badge, filters and footer all survive, so a customer can still navigate instead of being handed one dead page.

The modal boundary exists because the modal-route arrangement couples two independent trees. /account, /login and the rest render as modals over the storefront as a backdrop, so without a boundary between them a throw in Account blanks the storefront behind it and a throw in the storefront takes the open modal with it. One boundary separates them in both directions.

Every escape action is a hard navigation rather than a Link. This is worth stating because the obvious implementation is wrong: a boundary does not reset when the route changes, so a Link would change the URL and go on rendering the fallback, which reads as the app being permanently broken.

ErrorFallback changed too, outside this change's original scope and for a reason worth recording. antd's Result renders its title as a plain div with no heading semantics, so a page whose entire content is an error message offered a screen-reader user navigating by headings nothing at all to find. The title is now wrapped in Typography.Title. The tests assert a heading role and were right to; the component was what needed fixing, not the assertion.

DevThrow throws on ?boom=<scope> and is mounted only behind import.meta.env.DEV, so Rollup drops it from a production build. Checked in both directions rather than trusted: the dev server serves it, and a production bundle greps to zero occurrences of its marker. A gate that is silently always-off looks identical to one that works.

Verified: 87 end-to-end tests pass, 4 of them new — each boundary catches rather than blanking, the header survives a catalogue throw, the storefront survives a modal throw, and the report is observed reaching /api/client-errors on the wire rather than assumed. Build clean, lint 0 errors and 31 warnings.

Refs #62
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:32:27 -05:00

57 lines
2.5 KiB
TypeScript

import { test, expect } from './fixtures';
// The ?boom= trigger only exists on the dev server, which is what Playwright
// runs against. A production build drops it entirely — verified separately by
// grepping dist for the marker.
test.describe('Error boundaries', () => {
test('a throw below the root shows a message rather than a blank page', async ({ page }) => {
await page.goto('/?boom=page');
await expect(page.getByRole('heading', { name: 'Something went wrong' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Reload' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Back to the shop' })).toBeVisible();
});
test('a throw in the item grid leaves the header and theme switch usable', async ({ page }) => {
await page.goto('/?boom=catalogue');
await expect(page.getByRole('heading', { name: "The item list didn't load" })).toBeVisible();
// The claim this boundary exists to make: a bad item no longer takes
// navigation down with it.
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible();
await expect(page.getByRole('switch')).toBeVisible();
// And the root boundary did not also fire — only the nearest one should.
await expect(page.getByRole('heading', { name: 'Something went wrong' })).toHaveCount(0);
});
test('a throw in the modal block leaves the storefront behind it intact', async ({ page }) => {
await page.goto('/?boom=modal');
await expect(page.getByRole('heading', { name: "Couldn't open that" })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible();
});
test('a caught error is reported to the server', async ({ page }) => {
const reports: string[] = [];
page.on('request', (request) => {
if (request.url().includes('/api/client-errors')) {
reports.push(request.postData() ?? '');
}
});
await page.goto('/?boom=catalogue');
await expect(page.getByRole('heading', { name: "The item list didn't load" })).toBeVisible();
// Observed on the wire rather than trusting that the reporter was called.
//
// Greater-than-zero, not exactly one: StrictMode double-invokes render in
// development, so the dev server this runs against may report twice where
// production reports once. Asserting an exact count would make the test
// fail for a reason that has nothing to do with the boundary.
await expect.poll(() => reports.length).toBeGreaterThan(0);
expect(reports[0]).toContain('"context":"catalogue"');
});
});