From 4e889c2e2ad847f37a6e15e1bf2e0c9e4ccfa036 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 7 Feb 2026 19:19:28 +0000 Subject: [PATCH 1/3] test: mock client side requests in lighthouse --- .github/workflows/ci.yml | 2 +- CONTRIBUTING.md | 46 +++- lighthouse-setup.cjs | 52 +++- package.json | 2 + test/e2e/test-utils.ts | 463 ++------------------------------- test/fixtures/mock-routes.cjs | 477 ++++++++++++++++++++++++++++++++++ 6 files changed, 593 insertions(+), 449 deletions(-) create mode 100644 test/fixtures/mock-routes.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d85a88453..62602a84c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f60611433..57640aafb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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) @@ -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 @@ -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: @@ -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 @@ -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 diff --git a/lighthouse-setup.cjs b/lighthouse-setup.cjs index f110651b5..3dd16afac 100644 --- a/lighthouse-setup.cjs +++ b/lighthouse-setup.cjs @@ -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 @@ -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() + } + }) + } catch { + // Target may have been closed before we could set up interception. + // This is expected for transient targets like service workers. + } + }) +} diff --git a/package.json b/package.json index dda511dfd..d935fffc7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/e2e/test-utils.ts b/test/e2e/test-utils.ts index e454b974d..3901cba8c 100644 --- a/test/e2e/test-utils.ts +++ b/test/e2e/test-utils.ts @@ -1,388 +1,9 @@ -import { test as base } from '@nuxt/test-utils/playwright' import type { Page, Route } from '@playwright/test' -import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' - -const FIXTURES_DIR = join(process.cwd(), 'test/fixtures') - -function readFixture(relativePath: string): unknown | null { - const fullPath = join(FIXTURES_DIR, relativePath) - if (!existsSync(fullPath)) { - return null - } - try { - return JSON.parse(readFileSync(fullPath, 'utf-8')) - } catch { - return null - } -} - -/** - * Parse a scoped package name into its components. - * Handles formats like: @scope/name, @scope/name@version, name, name@version - */ -function parseScopedPackage(input: string): { name: string; version?: string } { - if (input.startsWith('@')) { - // Scoped package: @scope/name or @scope/name@version - const slashIndex = input.indexOf('/') - if (slashIndex === -1) { - // Invalid format like just "@scope" - return { name: input } - } - const afterSlash = input.slice(slashIndex + 1) - const atIndex = afterSlash.indexOf('@') - if (atIndex === -1) { - // @scope/name (no version) - return { name: input } - } - // @scope/name@version - return { - name: input.slice(0, slashIndex + 1 + atIndex), - version: afterSlash.slice(atIndex + 1), - } - } - - // Unscoped package: name or name@version - const atIndex = input.indexOf('@') - if (atIndex === -1) { - return { name: input } - } - return { - name: input.slice(0, atIndex), - version: input.slice(atIndex + 1), - } -} - -function packageToFixturePath(packageName: string): string { - if (packageName.startsWith('@')) { - const [scope, name] = packageName.slice(1).split('/') - if (!name) { - // Guard against invalid scoped package format like just "@scope" - return `npm-registry/packuments/${packageName}.json` - } - return `npm-registry/packuments/@${scope}/${name}.json` - } - return `npm-registry/packuments/${packageName}.json` -} - -async function handleNpmRegistry(route: Route): Promise { - const url = new URL(route.request().url()) - const pathname = decodeURIComponent(url.pathname) - - // Search endpoint - if (pathname === '/-/v1/search') { - const query = url.searchParams.get('text') - if (query) { - const maintainerMatch = query.match(/^maintainer:(.+)$/) - if (maintainerMatch?.[1]) { - const fixture = readFixture(`users/${maintainerMatch[1]}.json`) - await route.fulfill({ - json: fixture || { objects: [], total: 0, time: new Date().toISOString() }, - }) - return true - } - - const searchName = query.replace(/:/g, '-') - const fixture = readFixture(`npm-registry/search/${searchName}.json`) - await route.fulfill({ - json: fixture || { objects: [], total: 0, time: new Date().toISOString() }, - }) - return true - } - } - - // Org packages - const orgMatch = pathname.match(/^\/-\/org\/([^/]+)\/package$/) - if (orgMatch?.[1]) { - const fixture = readFixture(`npm-registry/orgs/${orgMatch[1]}.json`) - if (fixture) { - await route.fulfill({ json: fixture }) - return true - } - await route.fulfill({ status: 404, json: { error: 'Not found' } }) - return true - } - - // Packument - if (!pathname.startsWith('/-/')) { - let packageName = pathname.slice(1) - - if (packageName.startsWith('@')) { - const parts = packageName.split('/') - if (parts.length > 2) { - packageName = `${parts[0]}/${parts[1]}` - } - } else { - const slashIndex = packageName.indexOf('/') - if (slashIndex !== -1) { - packageName = packageName.slice(0, slashIndex) - } - } - - const fixture = readFixture(packageToFixturePath(packageName)) - if (fixture) { - await route.fulfill({ json: fixture }) - return true - } - await route.fulfill({ status: 404, json: { error: 'Not found' } }) - return true - } - - return false -} - -async function handleNpmApi(route: Route): Promise { - const url = new URL(route.request().url()) - const pathname = decodeURIComponent(url.pathname) - - // Downloads point - const pointMatch = pathname.match(/^\/downloads\/point\/[^/]+\/(.+)$/) - if (pointMatch?.[1]) { - const packageName = pointMatch[1] - const fixture = readFixture(`npm-api/downloads/${packageName}.json`) - await route.fulfill({ - json: fixture || { - downloads: 0, - start: '2025-01-01', - end: '2025-01-31', - package: packageName, - }, - }) - return true - } - - // Downloads range - const rangeMatch = pathname.match(/^\/downloads\/range\/[^/]+\/(.+)$/) - if (rangeMatch?.[1]) { - const packageName = rangeMatch[1] - await route.fulfill({ - json: { downloads: [], start: '2025-01-01', end: '2025-01-31', package: packageName }, - }) - return true - } - - return false -} - -async function handleOsvApi(route: Route): Promise { - const url = new URL(route.request().url()) - - if (url.pathname === '/v1/querybatch') { - await route.fulfill({ json: { results: [] } }) - return true - } - - if (url.pathname.startsWith('/v1/query')) { - await route.fulfill({ json: { vulns: [] } }) - return true - } - - return false -} - -async function handleFastNpmMeta(route: Route): Promise { - const url = new URL(route.request().url()) - let packageName = decodeURIComponent(url.pathname.slice(1)) - - if (!packageName) return false - - let specifier = 'latest' - if (packageName.startsWith('@')) { - const atIndex = packageName.indexOf('@', 1) - if (atIndex !== -1) { - specifier = packageName.slice(atIndex + 1) - packageName = packageName.slice(0, atIndex) - } - } else { - const atIndex = packageName.indexOf('@') - if (atIndex !== -1) { - specifier = packageName.slice(atIndex + 1) - packageName = packageName.slice(0, atIndex) - } - } - - const packument = readFixture(packageToFixturePath(packageName)) as Record | null - if (!packument) return false - - const distTags = packument['dist-tags'] as Record | undefined - const versions = packument.versions as Record | undefined - const time = packument.time as Record | undefined - - let version: string | undefined - if (specifier === 'latest' || !specifier) { - version = distTags?.latest - } else if (distTags?.[specifier]) { - version = distTags[specifier] - } else if (versions?.[specifier]) { - version = specifier - } else { - version = distTags?.latest - } - - if (!version) return false - - await route.fulfill({ - json: { - name: packageName, - specifier, - version, - publishedAt: time?.[version] || new Date().toISOString(), - lastSynced: Date.now(), - }, - }) - return true -} - -async function handleJsrRegistry(route: Route): Promise { - const url = new URL(route.request().url()) - - if (url.pathname.endsWith('/meta.json')) { - await route.fulfill({ json: null }) - return true - } - - return false -} - -/** - * Handle Bundlephobia API requests for package size info. - * Returns mock size data for any package. - */ -async function handleBundlephobiaApi(route: Route): Promise { - const url = new URL(route.request().url()) - - if (url.pathname === '/api/size') { - const packageSpec = url.searchParams.get('package') - if (packageSpec) { - // Return mock size data - await route.fulfill({ - json: { - name: packageSpec.split('@')[0], - size: 12345, - gzip: 4567, - dependencyCount: 3, - }, - }) - return true - } - } - - return false -} - -/** - * Handle npms.io API requests for package score/quality metrics. - * Returns mock score data for any package. - */ -async function handleNpmsApi(route: Route): Promise { - const url = new URL(route.request().url()) - const pathname = decodeURIComponent(url.pathname) - - // Package score endpoint: /v2/package/{packageName} - const packageMatch = pathname.match(/^\/v2\/package\/(.+)$/) - if (packageMatch?.[1]) { - const packageName = packageMatch[1] - await route.fulfill({ - json: { - analyzedAt: new Date().toISOString(), - collected: { - metadata: { name: packageName }, - }, - score: { - final: 0.75, - detail: { - quality: 0.8, - popularity: 0.7, - maintenance: 0.75, - }, - }, - }, - }) - return true - } - - return false -} - -/** - * Handle jsdelivr CDN requests for package files (README, etc.). - * Returns 404 for most requests since we don't need actual README content for most tests. - */ -async function handleJsdelivrCdn(route: Route): Promise { - const url = new URL(route.request().url()) - const pathname = decodeURIComponent(url.pathname) - - // README file requests - return 404 (package pages work fine without README) - if (/readme/i.test(pathname)) { - await route.fulfill({ status: 404, body: 'Not found' }) - return true - } - - // Other file requests (package.json, etc.) - return 404 - await route.fulfill({ status: 404, body: 'Not found' }) - return true -} - -/** - * Handle jsdelivr data API requests for package file listings. - * Returns mock file tree data. - */ -async function handleJsdelivrDataApi(route: Route): Promise { - const url = new URL(route.request().url()) - const pathname = decodeURIComponent(url.pathname) - - // Package file listing: /v1/packages/npm/{package}@{version} - const packageMatch = pathname.match(/^\/v1\/packages\/npm\/(.+)$/) - if (packageMatch?.[1]) { - const parsed = parseScopedPackage(packageMatch[1]) - // Return a minimal file tree - await route.fulfill({ - json: { - type: 'npm', - name: parsed.name, - version: parsed.version || 'latest', - files: [ - { name: 'package.json', hash: 'abc123', size: 1000 }, - { name: 'index.js', hash: 'def456', size: 500 }, - { name: 'README.md', hash: 'ghi789', size: 2000 }, - ], - }, - }) - return true - } - - return false -} - -/** - * Handle Gravatar API requests for user avatars. - * Returns 404 since we don't need actual avatars in tests. - */ -async function handleGravatarApi(route: Route): Promise { - await route.fulfill({ status: 404, body: 'Not found' }) - return true -} - -/** - * Handle GitHub API requests. - * Returns mock contributor data from fixtures for the contributors endpoint. - */ -async function handleGitHubApi(route: Route): Promise { - const url = new URL(route.request().url()) - const pathname = url.pathname - - // Contributors endpoint: /repos/{owner}/{repo}/contributors - const contributorsMatch = pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/contributors$/) - if (contributorsMatch) { - const fixture = readFixture('github/contributors.json') - await route.fulfill({ - json: fixture || [], - }) - return true - } +import { test as base } from '@nuxt/test-utils/playwright' +import { createRequire } from 'node:module' - return false -} +const require = createRequire(import.meta.url) +const mockRoutes = require('../fixtures/mock-routes.cjs') /** * Fail the test with a clear error message when an external API request isn't mocked. @@ -402,7 +23,7 @@ function failUnmockedRequest(route: Route, apiName: string): never { `\n` + `To fix this, either:\n` + ` 1. Add a fixture file for this request in test/fixtures/\n` + - ` 2. Add handling for this URL pattern in test/e2e/test-utils.ts\n` + + ` 2. Add handling for this URL pattern in test/fixtures/mock-routes.cjs\n` + `\n` + `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`, ) @@ -410,66 +31,22 @@ function failUnmockedRequest(route: Route, apiName: string): never { } async function setupRouteMocking(page: Page): Promise { - await page.route('https://registry.npmjs.org/**', async route => { - const handled = await handleNpmRegistry(route) - if (!handled) failUnmockedRequest(route, 'npm registry') - }) - - await page.route('https://api.npmjs.org/**', async route => { - const handled = await handleNpmApi(route) - if (!handled) failUnmockedRequest(route, 'npm API') - }) - - await page.route('https://api.osv.dev/**', async route => { - const handled = await handleOsvApi(route) - if (!handled) failUnmockedRequest(route, 'OSV API') - }) - - await page.route('https://npm.antfu.dev/**', async route => { - const handled = await handleFastNpmMeta(route) - if (!handled) failUnmockedRequest(route, 'fast-npm-meta') - }) - - await page.route('https://jsr.io/**', async route => { - const handled = await handleJsrRegistry(route) - if (!handled) failUnmockedRequest(route, 'JSR registry') - }) - - // Bundlephobia API for package size info (used by size badges) - await page.route('https://bundlephobia.com/**', async route => { - const handled = await handleBundlephobiaApi(route) - if (!handled) failUnmockedRequest(route, 'Bundlephobia API') - }) - - // npms.io API for package scores (used by quality/popularity/maintenance badges) - await page.route('https://api.npms.io/**', async route => { - const handled = await handleNpmsApi(route) - if (!handled) failUnmockedRequest(route, 'npms.io API') - }) - - // jsdelivr CDN for package files (README, etc.) - await page.route('https://cdn.jsdelivr.net/**', async route => { - const handled = await handleJsdelivrCdn(route) - if (!handled) failUnmockedRequest(route, 'jsdelivr CDN') - }) + for (const routeDef of mockRoutes.routes) { + await page.route(routeDef.pattern, async (route: Route) => { + const url = route.request().url() + const result = mockRoutes.matchRoute(url) - // jsdelivr data API for file listings - await page.route('https://data.jsdelivr.com/**', async route => { - const handled = await handleJsdelivrDataApi(route) - if (!handled) failUnmockedRequest(route, 'jsdelivr Data API') - }) - - // Gravatar API for user avatars - await page.route('https://www.gravatar.com/**', async route => { - const handled = await handleGravatarApi(route) - if (!handled) failUnmockedRequest(route, 'Gravatar API') - }) - - // GitHub API for contributors, etc. - await page.route('https://api.github.com/**', async route => { - const handled = await handleGitHubApi(route) - if (!handled) failUnmockedRequest(route, 'GitHub API') - }) + if (result) { + await route.fulfill({ + status: result.response.status, + contentType: result.response.contentType, + body: result.response.body, + }) + } else { + failUnmockedRequest(route, routeDef.name) + } + }) + } } /** diff --git a/test/fixtures/mock-routes.cjs b/test/fixtures/mock-routes.cjs new file mode 100644 index 000000000..10c1886ff --- /dev/null +++ b/test/fixtures/mock-routes.cjs @@ -0,0 +1,477 @@ +/** + * Shared route mock handlers for external API requests. + * + * This module contains the URL matching and response generation logic used by both: + * - Playwright E2E tests (test/e2e/test-utils.ts) + * - Lighthouse CI puppeteer setup (lighthouse-setup.cjs) + * + * It is intentionally written as CJS so it can be required from the CJS lighthouse + * setup script and imported from ESM test utilities. + */ + +'use strict' + +const { existsSync, readFileSync } = require('node:fs') +const { join } = require('node:path') + +const FIXTURES_DIR = join(__dirname) + +/** + * @param {string} relativePath + * @returns {unknown | null} + */ +function readFixture(relativePath) { + const fullPath = join(FIXTURES_DIR, relativePath) + if (!existsSync(fullPath)) { + return null + } + try { + return JSON.parse(readFileSync(fullPath, 'utf-8')) + } catch { + return null + } +} + +/** + * Parse a scoped package name into its components. + * Handles formats like: @scope/name, @scope/name@version, name, name@version + * + * @param {string} input + * @returns {{ name: string; version?: string }} + */ +function parseScopedPackage(input) { + if (input.startsWith('@')) { + const slashIndex = input.indexOf('/') + if (slashIndex === -1) { + return { name: input } + } + const afterSlash = input.slice(slashIndex + 1) + const atIndex = afterSlash.indexOf('@') + if (atIndex === -1) { + return { name: input } + } + return { + name: input.slice(0, slashIndex + 1 + atIndex), + version: afterSlash.slice(atIndex + 1), + } + } + + const atIndex = input.indexOf('@') + if (atIndex === -1) { + return { name: input } + } + return { + name: input.slice(0, atIndex), + version: input.slice(atIndex + 1), + } +} + +/** + * @param {string} packageName + * @returns {string} + */ +function packageToFixturePath(packageName) { + if (packageName.startsWith('@')) { + const [scope, name] = packageName.slice(1).split('/') + if (!name) { + return `npm-registry/packuments/${packageName}.json` + } + return `npm-registry/packuments/@${scope}/${name}.json` + } + return `npm-registry/packuments/${packageName}.json` +} + +/** + * @typedef {Object} MockResponse + * @property {number} status + * @property {string} contentType + * @property {string} body + */ + +/** + * Determine the mock response for an npm registry request. + * + * @param {string} urlString + * @returns {MockResponse | null} + */ +function matchNpmRegistry(urlString) { + const url = new URL(urlString) + const pathname = decodeURIComponent(url.pathname) + + // Search endpoint + if (pathname === '/-/v1/search') { + const query = url.searchParams.get('text') + if (query) { + const maintainerMatch = query.match(/^maintainer:(.+)$/) + if (maintainerMatch && maintainerMatch[1]) { + const fixture = readFixture(`users/${maintainerMatch[1]}.json`) + return json(fixture || { objects: [], total: 0, time: new Date().toISOString() }) + } + + const searchName = query.replace(/:/g, '-') + const fixture = readFixture(`npm-registry/search/${searchName}.json`) + return json(fixture || { objects: [], total: 0, time: new Date().toISOString() }) + } + } + + // Org packages + const orgMatch = pathname.match(/^\/-\/org\/([^/]+)\/package$/) + if (orgMatch && orgMatch[1]) { + const fixture = readFixture(`npm-registry/orgs/${orgMatch[1]}.json`) + if (fixture) { + return json(fixture) + } + return json({ error: 'Not found' }, 404) + } + + // Packument + if (!pathname.startsWith('/-/')) { + let packageName = pathname.slice(1) + + if (packageName.startsWith('@')) { + const parts = packageName.split('/') + if (parts.length > 2) { + packageName = `${parts[0]}/${parts[1]}` + } + } else { + const slashIndex = packageName.indexOf('/') + if (slashIndex !== -1) { + packageName = packageName.slice(0, slashIndex) + } + } + + const fixture = readFixture(packageToFixturePath(packageName)) + if (fixture) { + return json(fixture) + } + return json({ error: 'Not found' }, 404) + } + + return null +} + +/** + * Determine the mock response for an npm API (downloads) request. + * + * @param {string} urlString + * @returns {MockResponse | null} + */ +function matchNpmApi(urlString) { + const url = new URL(urlString) + const pathname = decodeURIComponent(url.pathname) + + // Downloads point + const pointMatch = pathname.match(/^\/downloads\/point\/[^/]+\/(.+)$/) + if (pointMatch && pointMatch[1]) { + const packageName = pointMatch[1] + const fixture = readFixture(`npm-api/downloads/${packageName}.json`) + return json( + fixture || { + downloads: 0, + start: '2025-01-01', + end: '2025-01-31', + package: packageName, + }, + ) + } + + // Downloads range + const rangeMatch = pathname.match(/^\/downloads\/range\/[^/]+\/(.+)$/) + if (rangeMatch && rangeMatch[1]) { + const packageName = rangeMatch[1] + return json({ downloads: [], start: '2025-01-01', end: '2025-01-31', package: packageName }) + } + + return null +} + +/** + * @param {string} urlString + * @returns {MockResponse | null} + */ +function matchOsvApi(urlString) { + const url = new URL(urlString) + + if (url.pathname === '/v1/querybatch') { + return json({ results: [] }) + } + + if (url.pathname.startsWith('/v1/query')) { + return json({ vulns: [] }) + } + + return null +} + +/** + * @param {string} urlString + * @returns {MockResponse | null} + */ +function matchFastNpmMeta(urlString) { + const url = new URL(urlString) + let packageName = decodeURIComponent(url.pathname.slice(1)) + + if (!packageName) return null + + let specifier = 'latest' + if (packageName.startsWith('@')) { + const atIndex = packageName.indexOf('@', 1) + if (atIndex !== -1) { + specifier = packageName.slice(atIndex + 1) + packageName = packageName.slice(0, atIndex) + } + } else { + const atIndex = packageName.indexOf('@') + if (atIndex !== -1) { + specifier = packageName.slice(atIndex + 1) + packageName = packageName.slice(0, atIndex) + } + } + + const packument = readFixture(packageToFixturePath(packageName)) + if (!packument) return null + + const distTags = packument['dist-tags'] + const versions = packument.versions + const time = packument.time + + let version + if (specifier === 'latest' || !specifier) { + version = distTags && distTags.latest + } else if (distTags && distTags[specifier]) { + version = distTags[specifier] + } else if (versions && versions[specifier]) { + version = specifier + } else { + version = distTags && distTags.latest + } + + if (!version) return null + + return json({ + name: packageName, + specifier, + version, + publishedAt: (time && time[version]) || new Date().toISOString(), + lastSynced: Date.now(), + }) +} + +/** + * @param {string} urlString + * @returns {MockResponse | null} + */ +function matchJsrRegistry(urlString) { + const url = new URL(urlString) + + if (url.pathname.endsWith('/meta.json')) { + return json(null) + } + + return null +} + +/** + * @param {string} urlString + * @returns {MockResponse | null} + */ +function matchBundlephobiaApi(urlString) { + const url = new URL(urlString) + + if (url.pathname === '/api/size') { + const packageSpec = url.searchParams.get('package') + if (packageSpec) { + return json({ + name: packageSpec.split('@')[0], + size: 12345, + gzip: 4567, + dependencyCount: 3, + }) + } + } + + return null +} + +/** + * @param {string} urlString + * @returns {MockResponse | null} + */ +function matchNpmsApi(urlString) { + const url = new URL(urlString) + const pathname = decodeURIComponent(url.pathname) + + const packageMatch = pathname.match(/^\/v2\/package\/(.+)$/) + if (packageMatch && packageMatch[1]) { + const packageName = packageMatch[1] + return json({ + analyzedAt: new Date().toISOString(), + collected: { + metadata: { name: packageName }, + }, + score: { + final: 0.75, + detail: { + quality: 0.8, + popularity: 0.7, + maintenance: 0.75, + }, + }, + }) + } + + return null +} + +/** + * @param {string} _urlString + * @returns {MockResponse | null} + */ +function matchJsdelivrCdn(_urlString) { + return { status: 404, contentType: 'text/plain', body: 'Not found' } +} + +/** + * @param {string} urlString + * @returns {MockResponse | null} + */ +function matchJsdelivrDataApi(urlString) { + const url = new URL(urlString) + const pathname = decodeURIComponent(url.pathname) + + const packageMatch = pathname.match(/^\/v1\/packages\/npm\/(.+)$/) + if (packageMatch && packageMatch[1]) { + const parsed = parseScopedPackage(packageMatch[1]) + return json({ + type: 'npm', + name: parsed.name, + version: parsed.version || 'latest', + files: [ + { name: 'package.json', hash: 'abc123', size: 1000 }, + { name: 'index.js', hash: 'def456', size: 500 }, + { name: 'README.md', hash: 'ghi789', size: 2000 }, + ], + }) + } + + return null +} + +/** + * @param {string} _urlString + * @returns {MockResponse} + */ +function matchGravatarApi(_urlString) { + return { status: 404, contentType: 'text/plain', body: 'Not found' } +} + +/** + * @param {string} urlString + * @returns {MockResponse | null} + */ +function matchGitHubApi(urlString) { + const url = new URL(urlString) + const pathname = url.pathname + + const contributorsMatch = pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/contributors$/) + if (contributorsMatch) { + const fixture = readFixture('github/contributors.json') + return json(fixture || []) + } + + return null +} + +/** + * Route definitions mapping URL patterns to their matchers. + * Each entry has a pattern (for Playwright's page.route) and a match function + * that returns a MockResponse or null. + * + * @type {Array<{ name: string; pattern: string; match: (url: string) => MockResponse | null }>} + */ +const routes = [ + { name: 'npm registry', pattern: 'https://registry.npmjs.org/**', match: matchNpmRegistry }, + { name: 'npm API', pattern: 'https://api.npmjs.org/**', match: matchNpmApi }, + { name: 'OSV API', pattern: 'https://api.osv.dev/**', match: matchOsvApi }, + { name: 'fast-npm-meta', pattern: 'https://npm.antfu.dev/**', match: matchFastNpmMeta }, + { name: 'JSR registry', pattern: 'https://jsr.io/**', match: matchJsrRegistry }, + { name: 'Bundlephobia API', pattern: 'https://bundlephobia.com/**', match: matchBundlephobiaApi }, + { name: 'npms.io API', pattern: 'https://api.npms.io/**', match: matchNpmsApi }, + { name: 'jsdelivr CDN', pattern: 'https://cdn.jsdelivr.net/**', match: matchJsdelivrCdn }, + { + name: 'jsdelivr Data API', + pattern: 'https://data.jsdelivr.com/**', + match: matchJsdelivrDataApi, + }, + { name: 'Gravatar API', pattern: 'https://www.gravatar.com/**', match: matchGravatarApi }, + { name: 'GitHub API', pattern: 'https://api.github.com/**', match: matchGitHubApi }, +] + +/** + * Try to match a URL against all known API routes and return a mock response. + * + * @param {string} url - The full request URL + * @returns {{ name: string; response: MockResponse } | null} + */ +function matchRoute(url) { + for (const route of routes) { + if (urlMatchesPattern(url, route.pattern)) { + const response = route.match(url) + if (response) { + return { name: route.name, response } + } + // URL matches the domain pattern but handler returned null => unmocked + return null + } + } + return null +} + +/** + * Check if a URL matches a simple glob pattern like "https://example.com/**". + * + * @param {string} url + * @param {string} pattern + * @returns {boolean} + */ +function urlMatchesPattern(url, pattern) { + // Convert "https://example.com/**" to a prefix check + if (pattern.endsWith('/**')) { + const prefix = pattern.slice(0, -2) + return url.startsWith(prefix) + } + return url === pattern +} + +/** + * Check if a URL belongs to any of the known external API domains. + * + * @param {string} url + * @returns {string | null} The API name if matched, null otherwise + */ +function getExternalApiName(url) { + for (const route of routes) { + if (urlMatchesPattern(url, route.pattern)) { + return route.name + } + } + return null +} + +// Helper to build a JSON MockResponse +function json(data, status = 200) { + return { + status, + contentType: 'application/json', + body: JSON.stringify(data), + } +} + +module.exports = { + routes, + matchRoute, + getExternalApiName, + readFixture, + parseScopedPackage, + packageToFixturePath, +} From d946ab355c0dfb4b989becd09d75a46181f05575 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 7 Feb 2026 19:43:53 +0000 Subject: [PATCH 2/3] fix: use cdp fetch --- lighthouse-setup.cjs | 57 ++++++++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/lighthouse-setup.cjs b/lighthouse-setup.cjs index 3dd16afac..01ea86bae 100644 --- a/lighthouse-setup.cjs +++ b/lighthouse-setup.cjs @@ -8,8 +8,9 @@ * 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. + * Request interception uses CDP (Chrome DevTools Protocol) Fetch domain + * at the browser level, which avoids conflicts with Lighthouse's own + * Puppeteer-level request interception. */ const mockRoutes = require('./test/fixtures/mock-routes.cjs') @@ -17,9 +18,10 @@ 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) + // Set up browser-level request interception via CDP Fetch domain. + // This operates below Puppeteer's request interception layer so it + // doesn't conflict with Lighthouse's own setRequestInterception usage. + await setupCdpRequestInterception(browser) const page = await browser.newPage() @@ -36,37 +38,52 @@ module.exports = async function setup(browser, { url }) { } /** - * 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. + * Set up request interception using CDP's Fetch domain on the browser's + * default context. This intercepts requests at a lower level than Puppeteer's + * page.setRequestInterception(), avoiding "Request is already handled!" errors + * when Lighthouse sets up its own interception. * * @param {import('puppeteer').Browser} browser */ -function setupBrowserRequestInterception(browser) { +async function setupCdpRequestInterception(browser) { + // Build URL pattern list for CDP Fetch.enable from our route definitions + const cdpPatterns = mockRoutes.routes.map(route => ({ + urlPattern: route.pattern.replace('/**', '/*'), + requestStage: 'Request', + })) + + // Listen for new targets so we can attach CDP interception to each page browser.on('targetcreated', async target => { if (target.type() !== 'page') return try { - const page = await target.page() - if (!page) return + const cdp = await target.createCDPSession() - await page.setRequestInterception(true) - page.on('request', request => { - const requestUrl = request.url() + cdp.on('Fetch.requestPaused', async event => { + const requestUrl = event.request.url const result = mockRoutes.matchRoute(requestUrl) if (result) { - request.respond({ - status: result.response.status, - contentType: result.response.contentType, - body: result.response.body, + 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 { - request.continue() + await cdp.send('Fetch.continueRequest', { + requestId: event.requestId, + }) } }) + + await cdp.send('Fetch.enable', { patterns: cdpPatterns }) } catch { - // Target may have been closed before we could set up interception. + // Target may have been closed before we could attach. // This is expected for transient targets like service workers. } }) From 31f20eb2a7620b0e45e9f925b838d3ee74b63616 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 7 Feb 2026 19:53:28 +0000 Subject: [PATCH 3/3] chore: ignore puppeteer --- knip.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/knip.ts b/knip.ts index 6b7901476..f6a668b7c 100644 --- a/knip.ts +++ b/knip.ts @@ -32,7 +32,7 @@ const config: KnipConfig = { '@vercel/kv', '@voidzero-dev/vite-plus-core', 'vite-plus!', - 'h3', + 'puppeteer', /** Needs to be explicitly installed, even though it is not imported, to avoid type errors. */ 'unplugin-vue-router', 'vite-plugin-pwa',