···5252- [Testing](#testing)
5353 - [Unit tests](#unit-tests)
5454 - [Component accessibility tests](#component-accessibility-tests)
5555+ - [Lighthouse accessibility tests](#lighthouse-accessibility-tests)
5556 - [End to end tests](#end-to-end-tests)
5657 - [Test fixtures (mocking external APIs)](#test-fixtures-mocking-external-apis)
5758- [Submitting changes](#submitting-changes)
···111112pnpm test:unit # Unit tests only
112113pnpm test:nuxt # Nuxt component tests
113114pnpm test:browser # Playwright E2E tests
115115+pnpm test:a11y # Lighthouse accessibility audits
114116```
115117116118### Project structure
···598600> [!IMPORTANT]
599601> Just because axe-core doesn't find any obvious issues, it does not mean a component is accessible. Please do additional checks and use best practices.
600602603603+### Lighthouse accessibility tests
604604+605605+In addition to component-level axe audits, the project runs full-page accessibility audits using [Lighthouse CI](https://github.com/GoogleChrome/lighthouse-ci). These test the rendered pages in both light and dark mode against Lighthouse's accessibility category, requiring a perfect score.
606606+607607+#### How it works
608608+609609+1. The project is built in test mode (`pnpm build:test`), which activates server-side fixture mocking
610610+2. Lighthouse CI starts a preview server and audits three URLs: `/`, `/search?q=nuxt`, and `/package/nuxt`
611611+3. A Puppeteer setup script (`lighthouse-setup.cjs`) runs before each audit to set the color mode and intercept client-side API requests using the same fixtures as the E2E tests
612612+613613+#### Running locally
614614+615615+```bash
616616+# Build + run both light and dark audits
617617+pnpm test:a11y
618618+619619+# Or against an existing test build
620620+pnpm test:a11y:prebuilt
621621+622622+# Or run a single color mode manually
623623+pnpm build:test
624624+LIGHTHOUSE_COLOR_MODE=dark ./scripts/lighthouse-a11y.sh
625625+```
626626+627627+This requires Chrome or Chromium to be installed. The script will auto-detect common installation paths. Results are printed to the terminal and saved in `.lighthouseci/`.
628628+629629+#### Configuration
630630+631631+| File | Purpose |
632632+| ---------------------------- | --------------------------------------------------------- |
633633+| `.lighthouserc.cjs` | Lighthouse CI config (URLs, assertions, Chrome path) |
634634+| `lighthouse-setup.cjs` | Puppeteer script for color mode + client-side API mocking |
635635+| `scripts/lighthouse-a11y.sh` | Shell wrapper that runs the audit for a given color mode |
636636+601637### End to end tests
602638603639Write end-to-end tests using Playwright:
···619655- Serves pre-recorded fixture data from `test/fixtures/`
620656- Enabled via `NUXT_TEST_FIXTURES=true` or Nuxt test mode
621657622622-**Client-side mocking** (`test/e2e/test-utils.ts`):
658658+**Client-side mocking** (`test/fixtures/mock-routes.cjs`):
623659624624-- Uses Playwright's route interception to mock browser requests
625625-- All test files import from `./test-utils` instead of `@nuxt/test-utils/playwright`
660660+- Shared URL matching and response generation logic used by both Playwright E2E tests and Lighthouse CI
661661+- Playwright tests (`test/e2e/test-utils.ts`) use this via `page.route()` interception
662662+- Lighthouse tests (`lighthouse-setup.cjs`) use this via Puppeteer request interception
663663+- All E2E test files import from `./test-utils` instead of `@nuxt/test-utils/playwright`
626664- Throws a clear error if an unmocked external request is detected
627665628666#### Fixture files
···670708You need to either:
6717096727101. Add a fixture file for that package/endpoint
673673-2. Update the mock handlers in `test/e2e/test-utils.ts` (client) or `modules/runtime/server/cache.ts` (server)
711711+2. Update the mock handlers in `test/fixtures/mock-routes.cjs` (client) or `modules/runtime/server/cache.ts` (server)
674712675713## Submitting changes
676714
+1-1
knip.ts
···3232 '@vercel/kv',
3333 '@voidzero-dev/vite-plus-core',
3434 'vite-plus!',
3535- 'h3',
3535+ 'puppeteer',
3636 /** Needs to be explicitly installed, even though it is not imported, to avoid type errors. */
3737 'unplugin-vue-router',
3838 'vite-plugin-pwa',
+68-1
lighthouse-setup.cjs
···11/**
22 * Lighthouse CI puppeteer setup script.
33- * Sets the color mode (light/dark) before running accessibility audits.
33+ *
44+ * Sets the color mode (light/dark) before running accessibility audits
55+ * and intercepts client-side API requests using the same fixture data
66+ * as the Playwright E2E tests.
47 *
58 * The color mode is determined by the LIGHTHOUSE_COLOR_MODE environment variable.
69 * If not set, defaults to 'dark'.
1010+ *
1111+ * Request interception uses CDP (Chrome DevTools Protocol) Fetch domain
1212+ * at the browser level, which avoids conflicts with Lighthouse's own
1313+ * Puppeteer-level request interception.
714 */
8151616+const mockRoutes = require('./test/fixtures/mock-routes.cjs')
1717+918module.exports = async function setup(browser, { url }) {
1019 const colorMode = process.env.LIGHTHOUSE_COLOR_MODE || 'dark'
2020+2121+ // Set up browser-level request interception via CDP Fetch domain.
2222+ // This operates below Puppeteer's request interception layer so it
2323+ // doesn't conflict with Lighthouse's own setRequestInterception usage.
2424+ await setupCdpRequestInterception(browser)
2525+1126 const page = await browser.newPage()
12271328 // Set localStorage before navigating so @nuxtjs/color-mode picks it up
···2136 // Close the page - Lighthouse will open its own with localStorage already set
2237 await page.close()
2338}
3939+4040+/**
4141+ * Set up request interception using CDP's Fetch domain on the browser's
4242+ * default context. This intercepts requests at a lower level than Puppeteer's
4343+ * page.setRequestInterception(), avoiding "Request is already handled!" errors
4444+ * when Lighthouse sets up its own interception.
4545+ *
4646+ * @param {import('puppeteer').Browser} browser
4747+ */
4848+async function setupCdpRequestInterception(browser) {
4949+ // Build URL pattern list for CDP Fetch.enable from our route definitions
5050+ const cdpPatterns = mockRoutes.routes.map(route => ({
5151+ urlPattern: route.pattern.replace('/**', '/*'),
5252+ requestStage: 'Request',
5353+ }))
5454+5555+ // Listen for new targets so we can attach CDP interception to each page
5656+ browser.on('targetcreated', async target => {
5757+ if (target.type() !== 'page') return
5858+5959+ try {
6060+ const cdp = await target.createCDPSession()
6161+6262+ cdp.on('Fetch.requestPaused', async event => {
6363+ const requestUrl = event.request.url
6464+ const result = mockRoutes.matchRoute(requestUrl)
6565+6666+ if (result) {
6767+ const body = Buffer.from(result.response.body).toString('base64')
6868+ await cdp.send('Fetch.fulfillRequest', {
6969+ requestId: event.requestId,
7070+ responseCode: result.response.status,
7171+ responseHeaders: [
7272+ { name: 'Content-Type', value: result.response.contentType },
7373+ { name: 'Access-Control-Allow-Origin', value: '*' },
7474+ ],
7575+ body,
7676+ })
7777+ } else {
7878+ await cdp.send('Fetch.continueRequest', {
7979+ requestId: event.requestId,
8080+ })
8181+ }
8282+ })
8383+8484+ await cdp.send('Fetch.enable', { patterns: cdpPatterns })
8585+ } catch {
8686+ // Target may have been closed before we could attach.
8787+ // This is expected for transient targets like service workers.
8888+ }
8989+ })
9090+}