Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ jobs:
run: pnpm build:test

- name: ♿ Accessibility audit (Lighthouse - ${{ matrix.mode }} mode)
run: ./scripts/lighthouse-a11y.sh
run: pnpm test:a11y:prebuilt
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
LIGHTHOUSE_COLOR_MODE: ${{ matrix.mode }}
Expand Down
46 changes: 42 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ This focus helps guide our project decisions as a community and what we choose t
- [Testing](#testing)
- [Unit tests](#unit-tests)
- [Component accessibility tests](#component-accessibility-tests)
- [Lighthouse accessibility tests](#lighthouse-accessibility-tests)
- [End to end tests](#end-to-end-tests)
- [Test fixtures (mocking external APIs)](#test-fixtures-mocking-external-apis)
- [Submitting changes](#submitting-changes)
Expand Down Expand Up @@ -111,6 +112,7 @@ pnpm test # Run all Vitest tests
pnpm test:unit # Unit tests only
pnpm test:nuxt # Nuxt component tests
pnpm test:browser # Playwright E2E tests
pnpm test:a11y # Lighthouse accessibility audits
```

### Project structure
Expand Down Expand Up @@ -598,6 +600,40 @@ A coverage test in `test/unit/a11y-component-coverage.spec.ts` ensures all compo
> [!IMPORTANT]
> 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.

### Lighthouse accessibility tests

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.

#### How it works

1. The project is built in test mode (`pnpm build:test`), which activates server-side fixture mocking
2. Lighthouse CI starts a preview server and audits three URLs: `/`, `/search?q=nuxt`, and `/package/nuxt`
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

#### Running locally

```bash
# Build + run both light and dark audits
pnpm test:a11y

# Or against an existing test build
pnpm test:a11y:prebuilt

# Or run a single color mode manually
pnpm build:test
LIGHTHOUSE_COLOR_MODE=dark ./scripts/lighthouse-a11y.sh
```

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/`.

#### Configuration

| File | Purpose |
| ---------------------------- | --------------------------------------------------------- |
| `.lighthouserc.cjs` | Lighthouse CI config (URLs, assertions, Chrome path) |
| `lighthouse-setup.cjs` | Puppeteer script for color mode + client-side API mocking |
| `scripts/lighthouse-a11y.sh` | Shell wrapper that runs the audit for a given color mode |

### End to end tests

Write end-to-end tests using Playwright:
Expand All @@ -619,10 +655,12 @@ E2E tests use a fixture system to mock external API requests, ensuring tests are
- Serves pre-recorded fixture data from `test/fixtures/`
- Enabled via `NUXT_TEST_FIXTURES=true` or Nuxt test mode

**Client-side mocking** (`test/e2e/test-utils.ts`):
**Client-side mocking** (`test/fixtures/mock-routes.cjs`):

- Uses Playwright's route interception to mock browser requests
- All test files import from `./test-utils` instead of `@nuxt/test-utils/playwright`
- Shared URL matching and response generation logic used by both Playwright E2E tests and Lighthouse CI
- Playwright tests (`test/e2e/test-utils.ts`) use this via `page.route()` interception
- Lighthouse tests (`lighthouse-setup.cjs`) use this via Puppeteer request interception
- All E2E test files import from `./test-utils` instead of `@nuxt/test-utils/playwright`
- Throws a clear error if an unmocked external request is detected

#### Fixture files
Expand Down Expand Up @@ -670,7 +708,7 @@ URL: https://registry.npmjs.org/some-package
You need to either:

1. Add a fixture file for that package/endpoint
2. Update the mock handlers in `test/e2e/test-utils.ts` (client) or `modules/runtime/server/cache.ts` (server)
2. Update the mock handlers in `test/fixtures/mock-routes.cjs` (client) or `modules/runtime/server/cache.ts` (server)

## Submitting changes

Expand Down
52 changes: 51 additions & 1 deletion lighthouse-setup.cjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
/**
* Lighthouse CI puppeteer setup script.
* Sets the color mode (light/dark) before running accessibility audits.
*
* Sets the color mode (light/dark) before running accessibility audits
* and intercepts client-side API requests using the same fixture data
* as the Playwright E2E tests.
*
* The color mode is determined by the LIGHTHOUSE_COLOR_MODE environment variable.
* If not set, defaults to 'dark'.
*
* Request interception uses CDP (Chrome DevTools Protocol) at the browser level
* so it applies to all pages Lighthouse opens, not just the setup page.
*/

const mockRoutes = require('./test/fixtures/mock-routes.cjs')

module.exports = async function setup(browser, { url }) {
const colorMode = process.env.LIGHTHOUSE_COLOR_MODE || 'dark'

// Set up browser-level request interception via CDP.
// This ensures mocking applies to pages Lighthouse creates after setup.
setupBrowserRequestInterception(browser)

const page = await browser.newPage()

// Set localStorage before navigating so @nuxtjs/color-mode picks it up
Expand All @@ -21,3 +34,40 @@ module.exports = async function setup(browser, { url }) {
// Close the page - Lighthouse will open its own with localStorage already set
await page.close()
}

/**
* Set up request interception on every new page target the browser creates.
* Uses Puppeteer's page-level request interception, applied automatically
* to each new page via the 'targetcreated' event.
*
* @param {import('puppeteer').Browser} browser
*/
function setupBrowserRequestInterception(browser) {
browser.on('targetcreated', async target => {
if (target.type() !== 'page') return

try {
const page = await target.page()
if (!page) return

await page.setRequestInterception(true)
page.on('request', request => {
const requestUrl = request.url()
const result = mockRoutes.matchRoute(requestUrl)

if (result) {
request.respond({
status: result.response.status,
contentType: result.response.contentType,
body: result.response.body,
})
} else {
request.continue()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd -t f lighthouse-setup.cjs

Repository: npmx-dev/npmx.dev

Length of output: 81


🏁 Script executed:

git ls-files | grep -E "(lighthouse|mock)" | head -20

Repository: npmx-dev/npmx.dev

Length of output: 156


🏁 Script executed:

rg -t js "mockRoutes\|getExternalApiName" --max-count=20

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

cat -n test/fixtures/mock-routes.cjs

Repository: npmx-dev/npmx.dev

Length of output: 15791


🏁 Script executed:

wc -l lighthouse-setup.cjs

Repository: npmx-dev/npmx.dev

Length of output: 84


🏁 Script executed:

cat -n lighthouse-setup.cjs

Repository: npmx-dev/npmx.dev

Length of output: 3017


Block unmocked external API requests during audits to preserve determinism.

Currently, when a request URL matches a known external API domain (e.g., npm registry, GitHub API) but no fixture exists, request.continue() allows the real API call through. This can cause non-deterministic audit results. Using getExternalApiName() to detect these cases and respond with an error keeps audits isolated and reproducible.

Suggested implementation
      page.on('request', request => {
        const requestUrl = request.url()
        const result = mockRoutes.matchRoute(requestUrl)
+       const apiName = mockRoutes.getExternalApiName(requestUrl)
 
        if (result) {
          request.respond({
            status: result.response.status,
            contentType: result.response.contentType,
            body: result.response.body,
          })
+       } else if (apiName) {
+         request.respond({
+           status: 500,
+           contentType: 'text/plain',
+           body: `Unmocked external request: ${apiName}`,
+         })
        } else {
          request.continue()
        }
      })

Verify the response behaviour works as expected with your Puppeteer and Lighthouse versions.

})
Comment on lines +62 to +82
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add error handling within the Fetch.requestPaused handler.

The cdp.send() calls inside the event handler are not wrapped in a try/catch. If the target closes whilst a request is paused (before fulfillRequest or continueRequest completes), this will throw an unhandled promise rejection.

🛡️ Proposed fix to add error handling
       cdp.on('Fetch.requestPaused', async event => {
+        try {
           const requestUrl = event.request.url
           const result = mockRoutes.matchRoute(requestUrl)
 
           if (result) {
             const body = Buffer.from(result.response.body).toString('base64')
             await cdp.send('Fetch.fulfillRequest', {
               requestId: event.requestId,
               responseCode: result.response.status,
               responseHeaders: [
                 { name: 'Content-Type', value: result.response.contentType },
                 { name: 'Access-Control-Allow-Origin', value: '*' },
               ],
               body,
             })
           } else {
             await cdp.send('Fetch.continueRequest', {
               requestId: event.requestId,
             })
           }
+        } catch {
+          // Target may have closed mid-request; safe to ignore.
+        }
       })

} catch {
// Target may have been closed before we could set up interception.
// This is expected for transient targets like service workers.
}
})
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
"generate:fixtures": "node scripts/generate-fixtures.ts",
"generate:lexicons": "lex build --lexicons lexicons --out shared/types/lexicons --clear",
"test": "vite test",
"test:a11y": "pnpm build:test && pnpm test:a11y:prebuilt",
"test:a11y:prebuilt": "LIGHTHOUSE_COLOR_MODE=dark ./scripts/lighthouse-a11y.sh && LIGHTHOUSE_COLOR_MODE=light ./scripts/lighthouse-a11y.sh",
"test:browser": "pnpm build:test && pnpm test:browser:prebuilt",
"test:browser:prebuilt": "playwright test",
"test:browser:ui": "pnpm build:test && pnpm test:browser:prebuilt --ui",
Expand Down
Loading
Loading