[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

test: mock client side requests in lighthouse (#1171)

authored by

Daniel Roe and committed by
GitHub
33a9df02 16b012a9

+611 -450
+1 -1
.github/workflows/ci.yml
··· 184 184 run: pnpm build:test 185 185 186 186 - name: ♿ Accessibility audit (Lighthouse - ${{ matrix.mode }} mode) 187 - run: ./scripts/lighthouse-a11y.sh 187 + run: pnpm test:a11y:prebuilt 188 188 env: 189 189 LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} 190 190 LIGHTHOUSE_COLOR_MODE: ${{ matrix.mode }}
+42 -4
CONTRIBUTING.md
··· 52 52 - [Testing](#testing) 53 53 - [Unit tests](#unit-tests) 54 54 - [Component accessibility tests](#component-accessibility-tests) 55 + - [Lighthouse accessibility tests](#lighthouse-accessibility-tests) 55 56 - [End to end tests](#end-to-end-tests) 56 57 - [Test fixtures (mocking external APIs)](#test-fixtures-mocking-external-apis) 57 58 - [Submitting changes](#submitting-changes) ··· 111 112 pnpm test:unit # Unit tests only 112 113 pnpm test:nuxt # Nuxt component tests 113 114 pnpm test:browser # Playwright E2E tests 115 + pnpm test:a11y # Lighthouse accessibility audits 114 116 ``` 115 117 116 118 ### Project structure ··· 598 600 > [!IMPORTANT] 599 601 > 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. 600 602 603 + ### Lighthouse accessibility tests 604 + 605 + 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. 606 + 607 + #### How it works 608 + 609 + 1. The project is built in test mode (`pnpm build:test`), which activates server-side fixture mocking 610 + 2. Lighthouse CI starts a preview server and audits three URLs: `/`, `/search?q=nuxt`, and `/package/nuxt` 611 + 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 612 + 613 + #### Running locally 614 + 615 + ```bash 616 + # Build + run both light and dark audits 617 + pnpm test:a11y 618 + 619 + # Or against an existing test build 620 + pnpm test:a11y:prebuilt 621 + 622 + # Or run a single color mode manually 623 + pnpm build:test 624 + LIGHTHOUSE_COLOR_MODE=dark ./scripts/lighthouse-a11y.sh 625 + ``` 626 + 627 + 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/`. 628 + 629 + #### Configuration 630 + 631 + | File | Purpose | 632 + | ---------------------------- | --------------------------------------------------------- | 633 + | `.lighthouserc.cjs` | Lighthouse CI config (URLs, assertions, Chrome path) | 634 + | `lighthouse-setup.cjs` | Puppeteer script for color mode + client-side API mocking | 635 + | `scripts/lighthouse-a11y.sh` | Shell wrapper that runs the audit for a given color mode | 636 + 601 637 ### End to end tests 602 638 603 639 Write end-to-end tests using Playwright: ··· 619 655 - Serves pre-recorded fixture data from `test/fixtures/` 620 656 - Enabled via `NUXT_TEST_FIXTURES=true` or Nuxt test mode 621 657 622 - **Client-side mocking** (`test/e2e/test-utils.ts`): 658 + **Client-side mocking** (`test/fixtures/mock-routes.cjs`): 623 659 624 - - Uses Playwright's route interception to mock browser requests 625 - - All test files import from `./test-utils` instead of `@nuxt/test-utils/playwright` 660 + - Shared URL matching and response generation logic used by both Playwright E2E tests and Lighthouse CI 661 + - Playwright tests (`test/e2e/test-utils.ts`) use this via `page.route()` interception 662 + - Lighthouse tests (`lighthouse-setup.cjs`) use this via Puppeteer request interception 663 + - All E2E test files import from `./test-utils` instead of `@nuxt/test-utils/playwright` 626 664 - Throws a clear error if an unmocked external request is detected 627 665 628 666 #### Fixture files ··· 670 708 You need to either: 671 709 672 710 1. Add a fixture file for that package/endpoint 673 - 2. Update the mock handlers in `test/e2e/test-utils.ts` (client) or `modules/runtime/server/cache.ts` (server) 711 + 2. Update the mock handlers in `test/fixtures/mock-routes.cjs` (client) or `modules/runtime/server/cache.ts` (server) 674 712 675 713 ## Submitting changes 676 714
+1 -1
knip.ts
··· 32 32 '@vercel/kv', 33 33 '@voidzero-dev/vite-plus-core', 34 34 'vite-plus!', 35 - 'h3', 35 + 'puppeteer', 36 36 /** Needs to be explicitly installed, even though it is not imported, to avoid type errors. */ 37 37 'unplugin-vue-router', 38 38 'vite-plugin-pwa',
+68 -1
lighthouse-setup.cjs
··· 1 1 /** 2 2 * Lighthouse CI puppeteer setup script. 3 - * Sets the color mode (light/dark) before running accessibility audits. 3 + * 4 + * Sets the color mode (light/dark) before running accessibility audits 5 + * and intercepts client-side API requests using the same fixture data 6 + * as the Playwright E2E tests. 4 7 * 5 8 * The color mode is determined by the LIGHTHOUSE_COLOR_MODE environment variable. 6 9 * If not set, defaults to 'dark'. 10 + * 11 + * Request interception uses CDP (Chrome DevTools Protocol) Fetch domain 12 + * at the browser level, which avoids conflicts with Lighthouse's own 13 + * Puppeteer-level request interception. 7 14 */ 8 15 16 + const mockRoutes = require('./test/fixtures/mock-routes.cjs') 17 + 9 18 module.exports = async function setup(browser, { url }) { 10 19 const colorMode = process.env.LIGHTHOUSE_COLOR_MODE || 'dark' 20 + 21 + // Set up browser-level request interception via CDP Fetch domain. 22 + // This operates below Puppeteer's request interception layer so it 23 + // doesn't conflict with Lighthouse's own setRequestInterception usage. 24 + await setupCdpRequestInterception(browser) 25 + 11 26 const page = await browser.newPage() 12 27 13 28 // Set localStorage before navigating so @nuxtjs/color-mode picks it up ··· 21 36 // Close the page - Lighthouse will open its own with localStorage already set 22 37 await page.close() 23 38 } 39 + 40 + /** 41 + * Set up request interception using CDP's Fetch domain on the browser's 42 + * default context. This intercepts requests at a lower level than Puppeteer's 43 + * page.setRequestInterception(), avoiding "Request is already handled!" errors 44 + * when Lighthouse sets up its own interception. 45 + * 46 + * @param {import('puppeteer').Browser} browser 47 + */ 48 + async function setupCdpRequestInterception(browser) { 49 + // Build URL pattern list for CDP Fetch.enable from our route definitions 50 + const cdpPatterns = mockRoutes.routes.map(route => ({ 51 + urlPattern: route.pattern.replace('/**', '/*'), 52 + requestStage: 'Request', 53 + })) 54 + 55 + // Listen for new targets so we can attach CDP interception to each page 56 + browser.on('targetcreated', async target => { 57 + if (target.type() !== 'page') return 58 + 59 + try { 60 + const cdp = await target.createCDPSession() 61 + 62 + cdp.on('Fetch.requestPaused', async event => { 63 + const requestUrl = event.request.url 64 + const result = mockRoutes.matchRoute(requestUrl) 65 + 66 + if (result) { 67 + const body = Buffer.from(result.response.body).toString('base64') 68 + await cdp.send('Fetch.fulfillRequest', { 69 + requestId: event.requestId, 70 + responseCode: result.response.status, 71 + responseHeaders: [ 72 + { name: 'Content-Type', value: result.response.contentType }, 73 + { name: 'Access-Control-Allow-Origin', value: '*' }, 74 + ], 75 + body, 76 + }) 77 + } else { 78 + await cdp.send('Fetch.continueRequest', { 79 + requestId: event.requestId, 80 + }) 81 + } 82 + }) 83 + 84 + await cdp.send('Fetch.enable', { patterns: cdpPatterns }) 85 + } catch { 86 + // Target may have been closed before we could attach. 87 + // This is expected for transient targets like service workers. 88 + } 89 + }) 90 + }
+2
package.json
··· 31 31 "generate:fixtures": "node scripts/generate-fixtures.ts", 32 32 "generate:lexicons": "lex build --lexicons lexicons --out shared/types/lexicons --clear", 33 33 "test": "vite test", 34 + "test:a11y": "pnpm build:test && pnpm test:a11y:prebuilt", 35 + "test:a11y:prebuilt": "LIGHTHOUSE_COLOR_MODE=dark ./scripts/lighthouse-a11y.sh && LIGHTHOUSE_COLOR_MODE=light ./scripts/lighthouse-a11y.sh", 34 36 "test:browser": "pnpm build:test && pnpm test:browser:prebuilt", 35 37 "test:browser:prebuilt": "playwright test", 36 38 "test:browser:ui": "pnpm build:test && pnpm test:browser:prebuilt --ui",
+20 -443
test/e2e/test-utils.ts
··· 1 - import { test as base } from '@nuxt/test-utils/playwright' 2 1 import type { Page, Route } from '@playwright/test' 3 - import { existsSync, readFileSync } from 'node:fs' 4 - import { join } from 'node:path' 2 + import { test as base } from '@nuxt/test-utils/playwright' 3 + import { createRequire } from 'node:module' 5 4 6 - const FIXTURES_DIR = join(process.cwd(), 'test/fixtures') 7 - 8 - function readFixture(relativePath: string): unknown | null { 9 - const fullPath = join(FIXTURES_DIR, relativePath) 10 - if (!existsSync(fullPath)) { 11 - return null 12 - } 13 - try { 14 - return JSON.parse(readFileSync(fullPath, 'utf-8')) 15 - } catch { 16 - return null 17 - } 18 - } 19 - 20 - /** 21 - * Parse a scoped package name into its components. 22 - * Handles formats like: @scope/name, @scope/name@version, name, name@version 23 - */ 24 - function parseScopedPackage(input: string): { name: string; version?: string } { 25 - if (input.startsWith('@')) { 26 - // Scoped package: @scope/name or @scope/name@version 27 - const slashIndex = input.indexOf('/') 28 - if (slashIndex === -1) { 29 - // Invalid format like just "@scope" 30 - return { name: input } 31 - } 32 - const afterSlash = input.slice(slashIndex + 1) 33 - const atIndex = afterSlash.indexOf('@') 34 - if (atIndex === -1) { 35 - // @scope/name (no version) 36 - return { name: input } 37 - } 38 - // @scope/name@version 39 - return { 40 - name: input.slice(0, slashIndex + 1 + atIndex), 41 - version: afterSlash.slice(atIndex + 1), 42 - } 43 - } 44 - 45 - // Unscoped package: name or name@version 46 - const atIndex = input.indexOf('@') 47 - if (atIndex === -1) { 48 - return { name: input } 49 - } 50 - return { 51 - name: input.slice(0, atIndex), 52 - version: input.slice(atIndex + 1), 53 - } 54 - } 55 - 56 - function packageToFixturePath(packageName: string): string { 57 - if (packageName.startsWith('@')) { 58 - const [scope, name] = packageName.slice(1).split('/') 59 - if (!name) { 60 - // Guard against invalid scoped package format like just "@scope" 61 - return `npm-registry/packuments/${packageName}.json` 62 - } 63 - return `npm-registry/packuments/@${scope}/${name}.json` 64 - } 65 - return `npm-registry/packuments/${packageName}.json` 66 - } 67 - 68 - async function handleNpmRegistry(route: Route): Promise<boolean> { 69 - const url = new URL(route.request().url()) 70 - const pathname = decodeURIComponent(url.pathname) 71 - 72 - // Search endpoint 73 - if (pathname === '/-/v1/search') { 74 - const query = url.searchParams.get('text') 75 - if (query) { 76 - const maintainerMatch = query.match(/^maintainer:(.+)$/) 77 - if (maintainerMatch?.[1]) { 78 - const fixture = readFixture(`users/${maintainerMatch[1]}.json`) 79 - await route.fulfill({ 80 - json: fixture || { objects: [], total: 0, time: new Date().toISOString() }, 81 - }) 82 - return true 83 - } 84 - 85 - const searchName = query.replace(/:/g, '-') 86 - const fixture = readFixture(`npm-registry/search/${searchName}.json`) 87 - await route.fulfill({ 88 - json: fixture || { objects: [], total: 0, time: new Date().toISOString() }, 89 - }) 90 - return true 91 - } 92 - } 93 - 94 - // Org packages 95 - const orgMatch = pathname.match(/^\/-\/org\/([^/]+)\/package$/) 96 - if (orgMatch?.[1]) { 97 - const fixture = readFixture(`npm-registry/orgs/${orgMatch[1]}.json`) 98 - if (fixture) { 99 - await route.fulfill({ json: fixture }) 100 - return true 101 - } 102 - await route.fulfill({ status: 404, json: { error: 'Not found' } }) 103 - return true 104 - } 105 - 106 - // Packument 107 - if (!pathname.startsWith('/-/')) { 108 - let packageName = pathname.slice(1) 109 - 110 - if (packageName.startsWith('@')) { 111 - const parts = packageName.split('/') 112 - if (parts.length > 2) { 113 - packageName = `${parts[0]}/${parts[1]}` 114 - } 115 - } else { 116 - const slashIndex = packageName.indexOf('/') 117 - if (slashIndex !== -1) { 118 - packageName = packageName.slice(0, slashIndex) 119 - } 120 - } 121 - 122 - const fixture = readFixture(packageToFixturePath(packageName)) 123 - if (fixture) { 124 - await route.fulfill({ json: fixture }) 125 - return true 126 - } 127 - await route.fulfill({ status: 404, json: { error: 'Not found' } }) 128 - return true 129 - } 130 - 131 - return false 132 - } 133 - 134 - async function handleNpmApi(route: Route): Promise<boolean> { 135 - const url = new URL(route.request().url()) 136 - const pathname = decodeURIComponent(url.pathname) 137 - 138 - // Downloads point 139 - const pointMatch = pathname.match(/^\/downloads\/point\/[^/]+\/(.+)$/) 140 - if (pointMatch?.[1]) { 141 - const packageName = pointMatch[1] 142 - const fixture = readFixture(`npm-api/downloads/${packageName}.json`) 143 - await route.fulfill({ 144 - json: fixture || { 145 - downloads: 0, 146 - start: '2025-01-01', 147 - end: '2025-01-31', 148 - package: packageName, 149 - }, 150 - }) 151 - return true 152 - } 153 - 154 - // Downloads range 155 - const rangeMatch = pathname.match(/^\/downloads\/range\/[^/]+\/(.+)$/) 156 - if (rangeMatch?.[1]) { 157 - const packageName = rangeMatch[1] 158 - await route.fulfill({ 159 - json: { downloads: [], start: '2025-01-01', end: '2025-01-31', package: packageName }, 160 - }) 161 - return true 162 - } 163 - 164 - return false 165 - } 166 - 167 - async function handleOsvApi(route: Route): Promise<boolean> { 168 - const url = new URL(route.request().url()) 169 - 170 - if (url.pathname === '/v1/querybatch') { 171 - await route.fulfill({ json: { results: [] } }) 172 - return true 173 - } 174 - 175 - if (url.pathname.startsWith('/v1/query')) { 176 - await route.fulfill({ json: { vulns: [] } }) 177 - return true 178 - } 179 - 180 - return false 181 - } 182 - 183 - async function handleFastNpmMeta(route: Route): Promise<boolean> { 184 - const url = new URL(route.request().url()) 185 - let packageName = decodeURIComponent(url.pathname.slice(1)) 186 - 187 - if (!packageName) return false 188 - 189 - let specifier = 'latest' 190 - if (packageName.startsWith('@')) { 191 - const atIndex = packageName.indexOf('@', 1) 192 - if (atIndex !== -1) { 193 - specifier = packageName.slice(atIndex + 1) 194 - packageName = packageName.slice(0, atIndex) 195 - } 196 - } else { 197 - const atIndex = packageName.indexOf('@') 198 - if (atIndex !== -1) { 199 - specifier = packageName.slice(atIndex + 1) 200 - packageName = packageName.slice(0, atIndex) 201 - } 202 - } 203 - 204 - const packument = readFixture(packageToFixturePath(packageName)) as Record<string, unknown> | null 205 - if (!packument) return false 206 - 207 - const distTags = packument['dist-tags'] as Record<string, string> | undefined 208 - const versions = packument.versions as Record<string, unknown> | undefined 209 - const time = packument.time as Record<string, string> | undefined 210 - 211 - let version: string | undefined 212 - if (specifier === 'latest' || !specifier) { 213 - version = distTags?.latest 214 - } else if (distTags?.[specifier]) { 215 - version = distTags[specifier] 216 - } else if (versions?.[specifier]) { 217 - version = specifier 218 - } else { 219 - version = distTags?.latest 220 - } 221 - 222 - if (!version) return false 223 - 224 - await route.fulfill({ 225 - json: { 226 - name: packageName, 227 - specifier, 228 - version, 229 - publishedAt: time?.[version] || new Date().toISOString(), 230 - lastSynced: Date.now(), 231 - }, 232 - }) 233 - return true 234 - } 235 - 236 - async function handleJsrRegistry(route: Route): Promise<boolean> { 237 - const url = new URL(route.request().url()) 238 - 239 - if (url.pathname.endsWith('/meta.json')) { 240 - await route.fulfill({ json: null }) 241 - return true 242 - } 243 - 244 - return false 245 - } 246 - 247 - /** 248 - * Handle Bundlephobia API requests for package size info. 249 - * Returns mock size data for any package. 250 - */ 251 - async function handleBundlephobiaApi(route: Route): Promise<boolean> { 252 - const url = new URL(route.request().url()) 253 - 254 - if (url.pathname === '/api/size') { 255 - const packageSpec = url.searchParams.get('package') 256 - if (packageSpec) { 257 - // Return mock size data 258 - await route.fulfill({ 259 - json: { 260 - name: packageSpec.split('@')[0], 261 - size: 12345, 262 - gzip: 4567, 263 - dependencyCount: 3, 264 - }, 265 - }) 266 - return true 267 - } 268 - } 269 - 270 - return false 271 - } 272 - 273 - /** 274 - * Handle npms.io API requests for package score/quality metrics. 275 - * Returns mock score data for any package. 276 - */ 277 - async function handleNpmsApi(route: Route): Promise<boolean> { 278 - const url = new URL(route.request().url()) 279 - const pathname = decodeURIComponent(url.pathname) 280 - 281 - // Package score endpoint: /v2/package/{packageName} 282 - const packageMatch = pathname.match(/^\/v2\/package\/(.+)$/) 283 - if (packageMatch?.[1]) { 284 - const packageName = packageMatch[1] 285 - await route.fulfill({ 286 - json: { 287 - analyzedAt: new Date().toISOString(), 288 - collected: { 289 - metadata: { name: packageName }, 290 - }, 291 - score: { 292 - final: 0.75, 293 - detail: { 294 - quality: 0.8, 295 - popularity: 0.7, 296 - maintenance: 0.75, 297 - }, 298 - }, 299 - }, 300 - }) 301 - return true 302 - } 303 - 304 - return false 305 - } 306 - 307 - /** 308 - * Handle jsdelivr CDN requests for package files (README, etc.). 309 - * Returns 404 for most requests since we don't need actual README content for most tests. 310 - */ 311 - async function handleJsdelivrCdn(route: Route): Promise<boolean> { 312 - const url = new URL(route.request().url()) 313 - const pathname = decodeURIComponent(url.pathname) 314 - 315 - // README file requests - return 404 (package pages work fine without README) 316 - if (/readme/i.test(pathname)) { 317 - await route.fulfill({ status: 404, body: 'Not found' }) 318 - return true 319 - } 320 - 321 - // Other file requests (package.json, etc.) - return 404 322 - await route.fulfill({ status: 404, body: 'Not found' }) 323 - return true 324 - } 325 - 326 - /** 327 - * Handle jsdelivr data API requests for package file listings. 328 - * Returns mock file tree data. 329 - */ 330 - async function handleJsdelivrDataApi(route: Route): Promise<boolean> { 331 - const url = new URL(route.request().url()) 332 - const pathname = decodeURIComponent(url.pathname) 333 - 334 - // Package file listing: /v1/packages/npm/{package}@{version} 335 - const packageMatch = pathname.match(/^\/v1\/packages\/npm\/(.+)$/) 336 - if (packageMatch?.[1]) { 337 - const parsed = parseScopedPackage(packageMatch[1]) 338 - // Return a minimal file tree 339 - await route.fulfill({ 340 - json: { 341 - type: 'npm', 342 - name: parsed.name, 343 - version: parsed.version || 'latest', 344 - files: [ 345 - { name: 'package.json', hash: 'abc123', size: 1000 }, 346 - { name: 'index.js', hash: 'def456', size: 500 }, 347 - { name: 'README.md', hash: 'ghi789', size: 2000 }, 348 - ], 349 - }, 350 - }) 351 - return true 352 - } 353 - 354 - return false 355 - } 356 - 357 - /** 358 - * Handle Gravatar API requests for user avatars. 359 - * Returns 404 since we don't need actual avatars in tests. 360 - */ 361 - async function handleGravatarApi(route: Route): Promise<boolean> { 362 - await route.fulfill({ status: 404, body: 'Not found' }) 363 - return true 364 - } 365 - 366 - /** 367 - * Handle GitHub API requests. 368 - * Returns mock contributor data from fixtures for the contributors endpoint. 369 - */ 370 - async function handleGitHubApi(route: Route): Promise<boolean> { 371 - const url = new URL(route.request().url()) 372 - const pathname = url.pathname 373 - 374 - // Contributors endpoint: /repos/{owner}/{repo}/contributors 375 - const contributorsMatch = pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/contributors$/) 376 - if (contributorsMatch) { 377 - const fixture = readFixture('github/contributors.json') 378 - await route.fulfill({ 379 - json: fixture || [], 380 - }) 381 - return true 382 - } 383 - 384 - return false 385 - } 5 + const require = createRequire(import.meta.url) 6 + const mockRoutes = require('../fixtures/mock-routes.cjs') 386 7 387 8 /** 388 9 * Fail the test with a clear error message when an external API request isn't mocked. ··· 402 23 `\n` + 403 24 `To fix this, either:\n` + 404 25 ` 1. Add a fixture file for this request in test/fixtures/\n` + 405 - ` 2. Add handling for this URL pattern in test/e2e/test-utils.ts\n` + 26 + ` 2. Add handling for this URL pattern in test/fixtures/mock-routes.cjs\n` + 406 27 `\n` + 407 28 `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`, 408 29 ) ··· 410 31 } 411 32 412 33 async function setupRouteMocking(page: Page): Promise<void> { 413 - await page.route('https://registry.npmjs.org/**', async route => { 414 - const handled = await handleNpmRegistry(route) 415 - if (!handled) failUnmockedRequest(route, 'npm registry') 416 - }) 34 + for (const routeDef of mockRoutes.routes) { 35 + await page.route(routeDef.pattern, async (route: Route) => { 36 + const url = route.request().url() 37 + const result = mockRoutes.matchRoute(url) 417 38 418 - await page.route('https://api.npmjs.org/**', async route => { 419 - const handled = await handleNpmApi(route) 420 - if (!handled) failUnmockedRequest(route, 'npm API') 421 - }) 422 - 423 - await page.route('https://api.osv.dev/**', async route => { 424 - const handled = await handleOsvApi(route) 425 - if (!handled) failUnmockedRequest(route, 'OSV API') 426 - }) 427 - 428 - await page.route('https://npm.antfu.dev/**', async route => { 429 - const handled = await handleFastNpmMeta(route) 430 - if (!handled) failUnmockedRequest(route, 'fast-npm-meta') 431 - }) 432 - 433 - await page.route('https://jsr.io/**', async route => { 434 - const handled = await handleJsrRegistry(route) 435 - if (!handled) failUnmockedRequest(route, 'JSR registry') 436 - }) 437 - 438 - // Bundlephobia API for package size info (used by size badges) 439 - await page.route('https://bundlephobia.com/**', async route => { 440 - const handled = await handleBundlephobiaApi(route) 441 - if (!handled) failUnmockedRequest(route, 'Bundlephobia API') 442 - }) 443 - 444 - // npms.io API for package scores (used by quality/popularity/maintenance badges) 445 - await page.route('https://api.npms.io/**', async route => { 446 - const handled = await handleNpmsApi(route) 447 - if (!handled) failUnmockedRequest(route, 'npms.io API') 448 - }) 449 - 450 - // jsdelivr CDN for package files (README, etc.) 451 - await page.route('https://cdn.jsdelivr.net/**', async route => { 452 - const handled = await handleJsdelivrCdn(route) 453 - if (!handled) failUnmockedRequest(route, 'jsdelivr CDN') 454 - }) 455 - 456 - // jsdelivr data API for file listings 457 - await page.route('https://data.jsdelivr.com/**', async route => { 458 - const handled = await handleJsdelivrDataApi(route) 459 - if (!handled) failUnmockedRequest(route, 'jsdelivr Data API') 460 - }) 461 - 462 - // Gravatar API for user avatars 463 - await page.route('https://www.gravatar.com/**', async route => { 464 - const handled = await handleGravatarApi(route) 465 - if (!handled) failUnmockedRequest(route, 'Gravatar API') 466 - }) 467 - 468 - // GitHub API for contributors, etc. 469 - await page.route('https://api.github.com/**', async route => { 470 - const handled = await handleGitHubApi(route) 471 - if (!handled) failUnmockedRequest(route, 'GitHub API') 472 - }) 39 + if (result) { 40 + await route.fulfill({ 41 + status: result.response.status, 42 + contentType: result.response.contentType, 43 + body: result.response.body, 44 + }) 45 + } else { 46 + failUnmockedRequest(route, routeDef.name) 47 + } 48 + }) 49 + } 473 50 } 474 51 475 52 /**
+477
test/fixtures/mock-routes.cjs
··· 1 + /** 2 + * Shared route mock handlers for external API requests. 3 + * 4 + * This module contains the URL matching and response generation logic used by both: 5 + * - Playwright E2E tests (test/e2e/test-utils.ts) 6 + * - Lighthouse CI puppeteer setup (lighthouse-setup.cjs) 7 + * 8 + * It is intentionally written as CJS so it can be required from the CJS lighthouse 9 + * setup script and imported from ESM test utilities. 10 + */ 11 + 12 + 'use strict' 13 + 14 + const { existsSync, readFileSync } = require('node:fs') 15 + const { join } = require('node:path') 16 + 17 + const FIXTURES_DIR = join(__dirname) 18 + 19 + /** 20 + * @param {string} relativePath 21 + * @returns {unknown | null} 22 + */ 23 + function readFixture(relativePath) { 24 + const fullPath = join(FIXTURES_DIR, relativePath) 25 + if (!existsSync(fullPath)) { 26 + return null 27 + } 28 + try { 29 + return JSON.parse(readFileSync(fullPath, 'utf-8')) 30 + } catch { 31 + return null 32 + } 33 + } 34 + 35 + /** 36 + * Parse a scoped package name into its components. 37 + * Handles formats like: @scope/name, @scope/name@version, name, name@version 38 + * 39 + * @param {string} input 40 + * @returns {{ name: string; version?: string }} 41 + */ 42 + function parseScopedPackage(input) { 43 + if (input.startsWith('@')) { 44 + const slashIndex = input.indexOf('/') 45 + if (slashIndex === -1) { 46 + return { name: input } 47 + } 48 + const afterSlash = input.slice(slashIndex + 1) 49 + const atIndex = afterSlash.indexOf('@') 50 + if (atIndex === -1) { 51 + return { name: input } 52 + } 53 + return { 54 + name: input.slice(0, slashIndex + 1 + atIndex), 55 + version: afterSlash.slice(atIndex + 1), 56 + } 57 + } 58 + 59 + const atIndex = input.indexOf('@') 60 + if (atIndex === -1) { 61 + return { name: input } 62 + } 63 + return { 64 + name: input.slice(0, atIndex), 65 + version: input.slice(atIndex + 1), 66 + } 67 + } 68 + 69 + /** 70 + * @param {string} packageName 71 + * @returns {string} 72 + */ 73 + function packageToFixturePath(packageName) { 74 + if (packageName.startsWith('@')) { 75 + const [scope, name] = packageName.slice(1).split('/') 76 + if (!name) { 77 + return `npm-registry/packuments/${packageName}.json` 78 + } 79 + return `npm-registry/packuments/@${scope}/${name}.json` 80 + } 81 + return `npm-registry/packuments/${packageName}.json` 82 + } 83 + 84 + /** 85 + * @typedef {Object} MockResponse 86 + * @property {number} status 87 + * @property {string} contentType 88 + * @property {string} body 89 + */ 90 + 91 + /** 92 + * Determine the mock response for an npm registry request. 93 + * 94 + * @param {string} urlString 95 + * @returns {MockResponse | null} 96 + */ 97 + function matchNpmRegistry(urlString) { 98 + const url = new URL(urlString) 99 + const pathname = decodeURIComponent(url.pathname) 100 + 101 + // Search endpoint 102 + if (pathname === '/-/v1/search') { 103 + const query = url.searchParams.get('text') 104 + if (query) { 105 + const maintainerMatch = query.match(/^maintainer:(.+)$/) 106 + if (maintainerMatch && maintainerMatch[1]) { 107 + const fixture = readFixture(`users/${maintainerMatch[1]}.json`) 108 + return json(fixture || { objects: [], total: 0, time: new Date().toISOString() }) 109 + } 110 + 111 + const searchName = query.replace(/:/g, '-') 112 + const fixture = readFixture(`npm-registry/search/${searchName}.json`) 113 + return json(fixture || { objects: [], total: 0, time: new Date().toISOString() }) 114 + } 115 + } 116 + 117 + // Org packages 118 + const orgMatch = pathname.match(/^\/-\/org\/([^/]+)\/package$/) 119 + if (orgMatch && orgMatch[1]) { 120 + const fixture = readFixture(`npm-registry/orgs/${orgMatch[1]}.json`) 121 + if (fixture) { 122 + return json(fixture) 123 + } 124 + return json({ error: 'Not found' }, 404) 125 + } 126 + 127 + // Packument 128 + if (!pathname.startsWith('/-/')) { 129 + let packageName = pathname.slice(1) 130 + 131 + if (packageName.startsWith('@')) { 132 + const parts = packageName.split('/') 133 + if (parts.length > 2) { 134 + packageName = `${parts[0]}/${parts[1]}` 135 + } 136 + } else { 137 + const slashIndex = packageName.indexOf('/') 138 + if (slashIndex !== -1) { 139 + packageName = packageName.slice(0, slashIndex) 140 + } 141 + } 142 + 143 + const fixture = readFixture(packageToFixturePath(packageName)) 144 + if (fixture) { 145 + return json(fixture) 146 + } 147 + return json({ error: 'Not found' }, 404) 148 + } 149 + 150 + return null 151 + } 152 + 153 + /** 154 + * Determine the mock response for an npm API (downloads) request. 155 + * 156 + * @param {string} urlString 157 + * @returns {MockResponse | null} 158 + */ 159 + function matchNpmApi(urlString) { 160 + const url = new URL(urlString) 161 + const pathname = decodeURIComponent(url.pathname) 162 + 163 + // Downloads point 164 + const pointMatch = pathname.match(/^\/downloads\/point\/[^/]+\/(.+)$/) 165 + if (pointMatch && pointMatch[1]) { 166 + const packageName = pointMatch[1] 167 + const fixture = readFixture(`npm-api/downloads/${packageName}.json`) 168 + return json( 169 + fixture || { 170 + downloads: 0, 171 + start: '2025-01-01', 172 + end: '2025-01-31', 173 + package: packageName, 174 + }, 175 + ) 176 + } 177 + 178 + // Downloads range 179 + const rangeMatch = pathname.match(/^\/downloads\/range\/[^/]+\/(.+)$/) 180 + if (rangeMatch && rangeMatch[1]) { 181 + const packageName = rangeMatch[1] 182 + return json({ downloads: [], start: '2025-01-01', end: '2025-01-31', package: packageName }) 183 + } 184 + 185 + return null 186 + } 187 + 188 + /** 189 + * @param {string} urlString 190 + * @returns {MockResponse | null} 191 + */ 192 + function matchOsvApi(urlString) { 193 + const url = new URL(urlString) 194 + 195 + if (url.pathname === '/v1/querybatch') { 196 + return json({ results: [] }) 197 + } 198 + 199 + if (url.pathname.startsWith('/v1/query')) { 200 + return json({ vulns: [] }) 201 + } 202 + 203 + return null 204 + } 205 + 206 + /** 207 + * @param {string} urlString 208 + * @returns {MockResponse | null} 209 + */ 210 + function matchFastNpmMeta(urlString) { 211 + const url = new URL(urlString) 212 + let packageName = decodeURIComponent(url.pathname.slice(1)) 213 + 214 + if (!packageName) return null 215 + 216 + let specifier = 'latest' 217 + if (packageName.startsWith('@')) { 218 + const atIndex = packageName.indexOf('@', 1) 219 + if (atIndex !== -1) { 220 + specifier = packageName.slice(atIndex + 1) 221 + packageName = packageName.slice(0, atIndex) 222 + } 223 + } else { 224 + const atIndex = packageName.indexOf('@') 225 + if (atIndex !== -1) { 226 + specifier = packageName.slice(atIndex + 1) 227 + packageName = packageName.slice(0, atIndex) 228 + } 229 + } 230 + 231 + const packument = readFixture(packageToFixturePath(packageName)) 232 + if (!packument) return null 233 + 234 + const distTags = packument['dist-tags'] 235 + const versions = packument.versions 236 + const time = packument.time 237 + 238 + let version 239 + if (specifier === 'latest' || !specifier) { 240 + version = distTags && distTags.latest 241 + } else if (distTags && distTags[specifier]) { 242 + version = distTags[specifier] 243 + } else if (versions && versions[specifier]) { 244 + version = specifier 245 + } else { 246 + version = distTags && distTags.latest 247 + } 248 + 249 + if (!version) return null 250 + 251 + return json({ 252 + name: packageName, 253 + specifier, 254 + version, 255 + publishedAt: (time && time[version]) || new Date().toISOString(), 256 + lastSynced: Date.now(), 257 + }) 258 + } 259 + 260 + /** 261 + * @param {string} urlString 262 + * @returns {MockResponse | null} 263 + */ 264 + function matchJsrRegistry(urlString) { 265 + const url = new URL(urlString) 266 + 267 + if (url.pathname.endsWith('/meta.json')) { 268 + return json(null) 269 + } 270 + 271 + return null 272 + } 273 + 274 + /** 275 + * @param {string} urlString 276 + * @returns {MockResponse | null} 277 + */ 278 + function matchBundlephobiaApi(urlString) { 279 + const url = new URL(urlString) 280 + 281 + if (url.pathname === '/api/size') { 282 + const packageSpec = url.searchParams.get('package') 283 + if (packageSpec) { 284 + return json({ 285 + name: packageSpec.split('@')[0], 286 + size: 12345, 287 + gzip: 4567, 288 + dependencyCount: 3, 289 + }) 290 + } 291 + } 292 + 293 + return null 294 + } 295 + 296 + /** 297 + * @param {string} urlString 298 + * @returns {MockResponse | null} 299 + */ 300 + function matchNpmsApi(urlString) { 301 + const url = new URL(urlString) 302 + const pathname = decodeURIComponent(url.pathname) 303 + 304 + const packageMatch = pathname.match(/^\/v2\/package\/(.+)$/) 305 + if (packageMatch && packageMatch[1]) { 306 + const packageName = packageMatch[1] 307 + return json({ 308 + analyzedAt: new Date().toISOString(), 309 + collected: { 310 + metadata: { name: packageName }, 311 + }, 312 + score: { 313 + final: 0.75, 314 + detail: { 315 + quality: 0.8, 316 + popularity: 0.7, 317 + maintenance: 0.75, 318 + }, 319 + }, 320 + }) 321 + } 322 + 323 + return null 324 + } 325 + 326 + /** 327 + * @param {string} _urlString 328 + * @returns {MockResponse | null} 329 + */ 330 + function matchJsdelivrCdn(_urlString) { 331 + return { status: 404, contentType: 'text/plain', body: 'Not found' } 332 + } 333 + 334 + /** 335 + * @param {string} urlString 336 + * @returns {MockResponse | null} 337 + */ 338 + function matchJsdelivrDataApi(urlString) { 339 + const url = new URL(urlString) 340 + const pathname = decodeURIComponent(url.pathname) 341 + 342 + const packageMatch = pathname.match(/^\/v1\/packages\/npm\/(.+)$/) 343 + if (packageMatch && packageMatch[1]) { 344 + const parsed = parseScopedPackage(packageMatch[1]) 345 + return json({ 346 + type: 'npm', 347 + name: parsed.name, 348 + version: parsed.version || 'latest', 349 + files: [ 350 + { name: 'package.json', hash: 'abc123', size: 1000 }, 351 + { name: 'index.js', hash: 'def456', size: 500 }, 352 + { name: 'README.md', hash: 'ghi789', size: 2000 }, 353 + ], 354 + }) 355 + } 356 + 357 + return null 358 + } 359 + 360 + /** 361 + * @param {string} _urlString 362 + * @returns {MockResponse} 363 + */ 364 + function matchGravatarApi(_urlString) { 365 + return { status: 404, contentType: 'text/plain', body: 'Not found' } 366 + } 367 + 368 + /** 369 + * @param {string} urlString 370 + * @returns {MockResponse | null} 371 + */ 372 + function matchGitHubApi(urlString) { 373 + const url = new URL(urlString) 374 + const pathname = url.pathname 375 + 376 + const contributorsMatch = pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/contributors$/) 377 + if (contributorsMatch) { 378 + const fixture = readFixture('github/contributors.json') 379 + return json(fixture || []) 380 + } 381 + 382 + return null 383 + } 384 + 385 + /** 386 + * Route definitions mapping URL patterns to their matchers. 387 + * Each entry has a pattern (for Playwright's page.route) and a match function 388 + * that returns a MockResponse or null. 389 + * 390 + * @type {Array<{ name: string; pattern: string; match: (url: string) => MockResponse | null }>} 391 + */ 392 + const routes = [ 393 + { name: 'npm registry', pattern: 'https://registry.npmjs.org/**', match: matchNpmRegistry }, 394 + { name: 'npm API', pattern: 'https://api.npmjs.org/**', match: matchNpmApi }, 395 + { name: 'OSV API', pattern: 'https://api.osv.dev/**', match: matchOsvApi }, 396 + { name: 'fast-npm-meta', pattern: 'https://npm.antfu.dev/**', match: matchFastNpmMeta }, 397 + { name: 'JSR registry', pattern: 'https://jsr.io/**', match: matchJsrRegistry }, 398 + { name: 'Bundlephobia API', pattern: 'https://bundlephobia.com/**', match: matchBundlephobiaApi }, 399 + { name: 'npms.io API', pattern: 'https://api.npms.io/**', match: matchNpmsApi }, 400 + { name: 'jsdelivr CDN', pattern: 'https://cdn.jsdelivr.net/**', match: matchJsdelivrCdn }, 401 + { 402 + name: 'jsdelivr Data API', 403 + pattern: 'https://data.jsdelivr.com/**', 404 + match: matchJsdelivrDataApi, 405 + }, 406 + { name: 'Gravatar API', pattern: 'https://www.gravatar.com/**', match: matchGravatarApi }, 407 + { name: 'GitHub API', pattern: 'https://api.github.com/**', match: matchGitHubApi }, 408 + ] 409 + 410 + /** 411 + * Try to match a URL against all known API routes and return a mock response. 412 + * 413 + * @param {string} url - The full request URL 414 + * @returns {{ name: string; response: MockResponse } | null} 415 + */ 416 + function matchRoute(url) { 417 + for (const route of routes) { 418 + if (urlMatchesPattern(url, route.pattern)) { 419 + const response = route.match(url) 420 + if (response) { 421 + return { name: route.name, response } 422 + } 423 + // URL matches the domain pattern but handler returned null => unmocked 424 + return null 425 + } 426 + } 427 + return null 428 + } 429 + 430 + /** 431 + * Check if a URL matches a simple glob pattern like "https://example.com/**". 432 + * 433 + * @param {string} url 434 + * @param {string} pattern 435 + * @returns {boolean} 436 + */ 437 + function urlMatchesPattern(url, pattern) { 438 + // Convert "https://example.com/**" to a prefix check 439 + if (pattern.endsWith('/**')) { 440 + const prefix = pattern.slice(0, -2) 441 + return url.startsWith(prefix) 442 + } 443 + return url === pattern 444 + } 445 + 446 + /** 447 + * Check if a URL belongs to any of the known external API domains. 448 + * 449 + * @param {string} url 450 + * @returns {string | null} The API name if matched, null otherwise 451 + */ 452 + function getExternalApiName(url) { 453 + for (const route of routes) { 454 + if (urlMatchesPattern(url, route.pattern)) { 455 + return route.name 456 + } 457 + } 458 + return null 459 + } 460 + 461 + // Helper to build a JSON MockResponse 462 + function json(data, status = 200) { 463 + return { 464 + status, 465 + contentType: 'application/json', 466 + body: JSON.stringify(data), 467 + } 468 + } 469 + 470 + module.exports = { 471 + routes, 472 + matchRoute, 473 + getExternalApiName, 474 + readFixture, 475 + parseScopedPackage, 476 + packageToFixturePath, 477 + }