|
| 1 | +import { test as base } from '@nuxt/test-utils/playwright' |
| 2 | +import type { Page, Route } from '@playwright/test' |
| 3 | +import { readFileSync, existsSync } from 'node:fs' |
| 4 | +import { join } from 'node:path' |
| 5 | + |
| 6 | +const FIXTURES_DIR = join(process.cwd(), 'test/fixtures') |
| 7 | + |
| 8 | +/** |
| 9 | + * Read a fixture file from disk |
| 10 | + */ |
| 11 | +function readFixture(relativePath: string): unknown | null { |
| 12 | + const fullPath = join(FIXTURES_DIR, relativePath) |
| 13 | + if (!existsSync(fullPath)) { |
| 14 | + return null |
| 15 | + } |
| 16 | + try { |
| 17 | + return JSON.parse(readFileSync(fullPath, 'utf-8')) |
| 18 | + } catch { |
| 19 | + return null |
| 20 | + } |
| 21 | +} |
| 22 | + |
| 23 | +/** |
| 24 | + * Convert a package name to a fixture file path |
| 25 | + */ |
| 26 | +function packageToFixturePath(packageName: string): string { |
| 27 | + // Scoped packages: @scope/name -> @scope/name.json (in subdirectory) |
| 28 | + if (packageName.startsWith('@')) { |
| 29 | + const [scope, name] = packageName.slice(1).split('/') |
| 30 | + return `npm-registry/packuments/@${scope}/${name}.json` |
| 31 | + } |
| 32 | + return `npm-registry/packuments/${packageName}.json` |
| 33 | +} |
| 34 | + |
| 35 | +/** |
| 36 | + * Handle npm registry requests (registry.npmjs.org) |
| 37 | + */ |
| 38 | +async function handleNpmRegistry(route: Route): Promise<boolean> { |
| 39 | + const url = new URL(route.request().url()) |
| 40 | + const pathname = decodeURIComponent(url.pathname) |
| 41 | + |
| 42 | + // Search endpoint: /-/v1/search?text=query |
| 43 | + if (pathname === '/-/v1/search') { |
| 44 | + const query = url.searchParams.get('text') |
| 45 | + if (query) { |
| 46 | + // Check for maintainer search (user lookup) |
| 47 | + const maintainerMatch = query.match(/^maintainer:(.+)$/) |
| 48 | + if (maintainerMatch?.[1]) { |
| 49 | + const fixture = readFixture(`users/${maintainerMatch[1]}.json`) |
| 50 | + if (fixture) { |
| 51 | + await route.fulfill({ json: fixture }) |
| 52 | + return true |
| 53 | + } |
| 54 | + // Return empty results for unknown users |
| 55 | + await route.fulfill({ |
| 56 | + json: { objects: [], total: 0, time: new Date().toISOString() }, |
| 57 | + }) |
| 58 | + return true |
| 59 | + } |
| 60 | + |
| 61 | + // Regular search |
| 62 | + const searchName = query.replace(/:/g, '-') |
| 63 | + const fixture = readFixture(`npm-registry/search/${searchName}.json`) |
| 64 | + if (fixture) { |
| 65 | + await route.fulfill({ json: fixture }) |
| 66 | + return true |
| 67 | + } |
| 68 | + |
| 69 | + // Return empty results for searches without fixtures |
| 70 | + await route.fulfill({ |
| 71 | + json: { objects: [], total: 0, time: new Date().toISOString() }, |
| 72 | + }) |
| 73 | + return true |
| 74 | + } |
| 75 | + } |
| 76 | + |
| 77 | + // Org packages: /-/org/{orgname}/package |
| 78 | + const orgMatch = pathname.match(/^\/-\/org\/([^/]+)\/package$/) |
| 79 | + if (orgMatch?.[1]) { |
| 80 | + const fixture = readFixture(`npm-registry/orgs/${orgMatch[1]}.json`) |
| 81 | + if (fixture) { |
| 82 | + await route.fulfill({ json: fixture }) |
| 83 | + return true |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | + // Packument: /{package} or /{package}/{version} |
| 88 | + if (!pathname.startsWith('/-/')) { |
| 89 | + let packageName = pathname.slice(1) |
| 90 | + |
| 91 | + // Strip version if present |
| 92 | + if (packageName.startsWith('@')) { |
| 93 | + const parts = packageName.split('/') |
| 94 | + if (parts.length > 2) { |
| 95 | + packageName = `${parts[0]}/${parts[1]}` |
| 96 | + } |
| 97 | + } else { |
| 98 | + const slashIndex = packageName.indexOf('/') |
| 99 | + if (slashIndex !== -1) { |
| 100 | + packageName = packageName.slice(0, slashIndex) |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + const fixturePath = packageToFixturePath(packageName) |
| 105 | + const fixture = readFixture(fixturePath) |
| 106 | + if (fixture) { |
| 107 | + await route.fulfill({ json: fixture }) |
| 108 | + return true |
| 109 | + } |
| 110 | + |
| 111 | + // Return 404 for unknown packages |
| 112 | + await route.fulfill({ |
| 113 | + status: 404, |
| 114 | + json: { error: 'Not found' }, |
| 115 | + }) |
| 116 | + return true |
| 117 | + } |
| 118 | + |
| 119 | + return false |
| 120 | +} |
| 121 | + |
| 122 | +/** |
| 123 | + * Handle npm API requests (api.npmjs.org) |
| 124 | + */ |
| 125 | +async function handleNpmApi(route: Route): Promise<boolean> { |
| 126 | + const url = new URL(route.request().url()) |
| 127 | + const pathname = decodeURIComponent(url.pathname) |
| 128 | + |
| 129 | + // Downloads point: /downloads/point/{period}/{package} |
| 130 | + const pointMatch = pathname.match(/^\/downloads\/point\/[^/]+\/(.+)$/) |
| 131 | + if (pointMatch?.[1]) { |
| 132 | + const packageName = pointMatch[1] |
| 133 | + const fixturePath = `npm-api/downloads/${packageName}.json` |
| 134 | + const fixture = readFixture(fixturePath) |
| 135 | + if (fixture) { |
| 136 | + await route.fulfill({ json: fixture }) |
| 137 | + return true |
| 138 | + } |
| 139 | + // Return zero downloads for unknown packages |
| 140 | + await route.fulfill({ |
| 141 | + json: { downloads: 0, start: '2025-01-01', end: '2025-01-31', package: packageName }, |
| 142 | + }) |
| 143 | + return true |
| 144 | + } |
| 145 | + |
| 146 | + // Downloads range: /downloads/range/{period}/{package} |
| 147 | + // This is used for download charts - return empty data |
| 148 | + const rangeMatch = pathname.match(/^\/downloads\/range\/[^/]+\/(.+)$/) |
| 149 | + if (rangeMatch?.[1]) { |
| 150 | + const packageName = rangeMatch[1] |
| 151 | + // Return empty downloads array for range requests |
| 152 | + await route.fulfill({ |
| 153 | + json: { downloads: [], start: '2025-01-01', end: '2025-01-31', package: packageName }, |
| 154 | + }) |
| 155 | + return true |
| 156 | + } |
| 157 | + |
| 158 | + return false |
| 159 | +} |
| 160 | + |
| 161 | +/** |
| 162 | + * Handle OSV API requests (api.osv.dev) |
| 163 | + */ |
| 164 | +async function handleOsvApi(route: Route): Promise<boolean> { |
| 165 | + const url = new URL(route.request().url()) |
| 166 | + |
| 167 | + if (url.pathname === '/v1/querybatch') { |
| 168 | + await route.fulfill({ json: { results: [] } }) |
| 169 | + return true |
| 170 | + } |
| 171 | + |
| 172 | + if (url.pathname.startsWith('/v1/query')) { |
| 173 | + await route.fulfill({ json: { vulns: [] } }) |
| 174 | + return true |
| 175 | + } |
| 176 | + |
| 177 | + return false |
| 178 | +} |
| 179 | + |
| 180 | +/** |
| 181 | + * Handle fast-npm-meta requests (npm.antfu.dev) |
| 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 | + // Handle @version syntax (vue@3.4.0, @nuxt/kit@3.0.0) |
| 190 | + let specifier = 'latest' |
| 191 | + if (packageName.startsWith('@')) { |
| 192 | + const atIndex = packageName.indexOf('@', 1) |
| 193 | + if (atIndex !== -1) { |
| 194 | + specifier = packageName.slice(atIndex + 1) |
| 195 | + packageName = packageName.slice(0, atIndex) |
| 196 | + } |
| 197 | + } else { |
| 198 | + const atIndex = packageName.indexOf('@') |
| 199 | + if (atIndex !== -1) { |
| 200 | + specifier = packageName.slice(atIndex + 1) |
| 201 | + packageName = packageName.slice(0, atIndex) |
| 202 | + } |
| 203 | + } |
| 204 | + |
| 205 | + const fixturePath = packageToFixturePath(packageName) |
| 206 | + const packument = readFixture(fixturePath) as Record<string, unknown> | null |
| 207 | + |
| 208 | + if (!packument) return false |
| 209 | + |
| 210 | + const distTags = packument['dist-tags'] as Record<string, string> | undefined |
| 211 | + const versions = packument.versions as Record<string, unknown> | undefined |
| 212 | + const time = packument.time as Record<string, string> | undefined |
| 213 | + |
| 214 | + let version: string | undefined |
| 215 | + if (specifier === 'latest' || !specifier) { |
| 216 | + version = distTags?.latest |
| 217 | + } else if (distTags?.[specifier]) { |
| 218 | + version = distTags[specifier] |
| 219 | + } else if (versions?.[specifier]) { |
| 220 | + version = specifier |
| 221 | + } else { |
| 222 | + version = distTags?.latest |
| 223 | + } |
| 224 | + |
| 225 | + if (!version) return false |
| 226 | + |
| 227 | + await route.fulfill({ |
| 228 | + json: { |
| 229 | + name: packageName, |
| 230 | + specifier, |
| 231 | + version, |
| 232 | + publishedAt: time?.[version] || new Date().toISOString(), |
| 233 | + lastSynced: Date.now(), |
| 234 | + }, |
| 235 | + }) |
| 236 | + return true |
| 237 | +} |
| 238 | + |
| 239 | +/** |
| 240 | + * Handle JSR registry requests (jsr.io) |
| 241 | + */ |
| 242 | +async function handleJsrRegistry(route: Route): Promise<boolean> { |
| 243 | + const url = new URL(route.request().url()) |
| 244 | + |
| 245 | + if (url.pathname.endsWith('/meta.json')) { |
| 246 | + // Most npm packages aren't on JSR, return null |
| 247 | + await route.fulfill({ json: null }) |
| 248 | + return true |
| 249 | + } |
| 250 | + |
| 251 | + return false |
| 252 | +} |
| 253 | + |
| 254 | +/** |
| 255 | + * Setup route mocking for a page |
| 256 | + */ |
| 257 | +async function setupRouteMocking(page: Page): Promise<void> { |
| 258 | + // Mock npm registry (registry.npmjs.org) |
| 259 | + await page.route('https://registry.npmjs.org/**', async route => { |
| 260 | + const handled = await handleNpmRegistry(route) |
| 261 | + if (!handled) { |
| 262 | + // Log unhandled requests for debugging |
| 263 | + console.warn(`[mock] Unhandled npm registry request: ${route.request().url()}`) |
| 264 | + await route.continue() |
| 265 | + } |
| 266 | + }) |
| 267 | + |
| 268 | + // Mock npm API (api.npmjs.org) |
| 269 | + await page.route('https://api.npmjs.org/**', async route => { |
| 270 | + const handled = await handleNpmApi(route) |
| 271 | + if (!handled) { |
| 272 | + console.warn(`[mock] Unhandled npm API request: ${route.request().url()}`) |
| 273 | + await route.continue() |
| 274 | + } |
| 275 | + }) |
| 276 | + |
| 277 | + // Mock OSV API (api.osv.dev) |
| 278 | + await page.route('https://api.osv.dev/**', async route => { |
| 279 | + const handled = await handleOsvApi(route) |
| 280 | + if (!handled) { |
| 281 | + await route.continue() |
| 282 | + } |
| 283 | + }) |
| 284 | + |
| 285 | + // Mock fast-npm-meta (npm.antfu.dev) |
| 286 | + await page.route('https://npm.antfu.dev/**', async route => { |
| 287 | + const handled = await handleFastNpmMeta(route) |
| 288 | + if (!handled) { |
| 289 | + console.warn(`[mock] Unhandled fast-npm-meta request: ${route.request().url()}`) |
| 290 | + await route.continue() |
| 291 | + } |
| 292 | + }) |
| 293 | + |
| 294 | + // Mock JSR registry (jsr.io) |
| 295 | + await page.route('https://jsr.io/**', async route => { |
| 296 | + const handled = await handleJsrRegistry(route) |
| 297 | + if (!handled) { |
| 298 | + await route.continue() |
| 299 | + } |
| 300 | + }) |
| 301 | +} |
| 302 | + |
| 303 | +/** |
| 304 | + * Extended test fixture with external API mocking |
| 305 | + */ |
| 306 | +export const test = base.extend<{ mockExternalApis: void }>({ |
| 307 | + mockExternalApis: [ |
| 308 | + async ({ page }, use) => { |
| 309 | + await setupRouteMocking(page) |
| 310 | + await use() |
| 311 | + }, |
| 312 | + { auto: true }, // Automatically use this fixture for all tests |
| 313 | + ], |
| 314 | +}) |
| 315 | + |
| 316 | +export { expect } from '@nuxt/test-utils/playwright' |
0 commit comments