forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import process from 'node:process'
2import type { CachedFetchResult } from '#shared/utils/fetch-cache-config'
3import { createFetch } from 'ofetch'
4
5/**
6 * Test fixtures plugin for CI environments.
7 *
8 * This plugin intercepts all cachedFetch calls and serves pre-recorded fixture data
9 * instead of hitting the real npm API.
10 *
11 * This ensures:
12 * - Tests are deterministic and don't depend on external API availability
13 * - We don't hammer the npm registry during CI runs
14 * - Tests run faster with no network latency
15 *
16 * Set NUXT_TEST_FIXTURES_VERBOSE=true for detailed logging.
17 */
18
19const VERBOSE = process.env.NUXT_TEST_FIXTURES_VERBOSE === 'true'
20
21const FIXTURE_PATHS = {
22 packument: 'npm-registry:packuments',
23 search: 'npm-registry:search',
24 org: 'npm-registry:orgs',
25 downloads: 'npm-api:downloads',
26 user: 'users',
27 esmHeaders: 'esm-sh:headers',
28 esmTypes: 'esm-sh:types',
29 githubContributors: 'github:contributors.json',
30} as const
31
32type FixtureType = keyof typeof FIXTURE_PATHS
33
34interface FixtureMatch {
35 type: FixtureType
36 name: string
37}
38
39interface MockResult {
40 data: unknown
41}
42
43function getFixturePath(type: FixtureType, name: string): string {
44 const dir = FIXTURE_PATHS[type]
45 let filename: string
46
47 switch (type) {
48 case 'packument':
49 case 'downloads':
50 filename = `${name}.json`
51 break
52 case 'search':
53 filename = `${name.replace(/:/g, '-')}.json`
54 break
55 case 'org':
56 case 'user':
57 filename = `${name}.json`
58 break
59 default:
60 filename = `${name}.json`
61 }
62
63 return `${dir}:${filename.replace(/\//g, ':')}`
64}
65
66/**
67 * Parse a scoped package name with optional version.
68 * Handles formats like: @scope/name, @scope/name@version, name, name@version
69 */
70function parseScopedPackageWithVersion(input: string): { name: string; version?: string } {
71 if (input.startsWith('@')) {
72 // Scoped package: @scope/name or @scope/name@version
73 const slashIndex = input.indexOf('/')
74 if (slashIndex === -1) {
75 // Invalid format like just "@scope"
76 return { name: input }
77 }
78 const afterSlash = input.slice(slashIndex + 1)
79 const atIndex = afterSlash.indexOf('@')
80 if (atIndex === -1) {
81 // @scope/name (no version)
82 return { name: input }
83 }
84 // @scope/name@version
85 return {
86 name: input.slice(0, slashIndex + 1 + atIndex),
87 version: afterSlash.slice(atIndex + 1),
88 }
89 }
90
91 // Unscoped package: name or name@version
92 const atIndex = input.indexOf('@')
93 if (atIndex === -1) {
94 return { name: input }
95 }
96 return {
97 name: input.slice(0, atIndex),
98 version: input.slice(atIndex + 1),
99 }
100}
101
102function getMockForUrl(url: string): MockResult | null {
103 let urlObj: URL
104 try {
105 urlObj = new URL(url)
106 } catch {
107 return null
108 }
109
110 const { host, pathname, searchParams } = urlObj
111
112 // OSV API - return empty vulnerability results
113 if (host === 'api.osv.dev') {
114 if (pathname === '/v1/querybatch') {
115 return { data: { results: [] } }
116 }
117 if (pathname.startsWith('/v1/query')) {
118 return { data: { vulns: [] } }
119 }
120 }
121
122 // JSR registry - return null (npm packages aren't on JSR)
123 if (host === 'jsr.io' && pathname.endsWith('/meta.json')) {
124 return { data: null }
125 }
126
127 // Bundlephobia API - return mock size data
128 if (host === 'bundlephobia.com' && pathname === '/api/size') {
129 const packageSpec = searchParams.get('package')
130 if (packageSpec) {
131 return {
132 data: {
133 name: packageSpec.split('@')[0],
134 size: 12345,
135 gzip: 4567,
136 dependencyCount: 3,
137 },
138 }
139 }
140 }
141
142 // npms.io API - return mock package score data
143 if (host === 'api.npms.io') {
144 const packageMatch = decodeURIComponent(pathname).match(/^\/v2\/package\/(.+)$/)
145 if (packageMatch?.[1]) {
146 return {
147 data: {
148 analyzedAt: new Date().toISOString(),
149 collected: {
150 metadata: { name: packageMatch[1] },
151 },
152 score: {
153 final: 0.75,
154 detail: {
155 quality: 0.8,
156 popularity: 0.7,
157 maintenance: 0.75,
158 },
159 },
160 },
161 }
162 }
163 }
164
165 // jsdelivr CDN - return 404 for README files, etc.
166 if (host === 'cdn.jsdelivr.net') {
167 // Return null data which will cause a 404 - README files are optional
168 return { data: null }
169 }
170
171 // jsdelivr data API - return mock file listing
172 if (host === 'data.jsdelivr.com') {
173 const packageMatch = decodeURIComponent(pathname).match(/^\/v1\/packages\/npm\/(.+)$/)
174 if (packageMatch?.[1]) {
175 const pkgWithVersion = packageMatch[1]
176 const parsed = parseScopedPackageWithVersion(pkgWithVersion)
177 return {
178 data: {
179 type: 'npm',
180 name: parsed.name,
181 version: parsed.version || 'latest',
182 files: [
183 { name: 'package.json', hash: 'abc123', size: 1000 },
184 { name: 'index.js', hash: 'def456', size: 500 },
185 { name: 'README.md', hash: 'ghi789', size: 2000 },
186 ],
187 },
188 }
189 }
190 }
191
192 // Gravatar API - return 404 (avatars not needed in tests)
193 if (host === 'www.gravatar.com') {
194 return { data: null }
195 }
196
197 // GitHub API - handled via fixtures, return null to use fixture system
198 // Note: The actual fixture loading is handled in fetchFromFixtures via special case
199 if (host === 'api.github.com') {
200 // Return null here so it goes through fetchFromFixtures which handles the fixture loading
201 return null
202 }
203
204 // esm.sh is handled specially via $fetch.raw override, not here
205 // Return null to indicate no mock available at the cachedFetch level
206
207 return null
208}
209
210/**
211 * Process a single package query for fast-npm-meta.
212 * Returns the metadata for a single package or null/error result.
213 */
214async function processSingleFastNpmMeta(
215 packageQuery: string,
216 storage: ReturnType<typeof useStorage>,
217 metadata: boolean,
218): Promise<Record<string, unknown>> {
219 let packageName = packageQuery
220 let specifier = 'latest'
221
222 if (packageName.startsWith('@')) {
223 const atIndex = packageName.indexOf('@', 1)
224 if (atIndex !== -1) {
225 specifier = packageName.slice(atIndex + 1)
226 packageName = packageName.slice(0, atIndex)
227 }
228 } else {
229 const atIndex = packageName.indexOf('@')
230 if (atIndex !== -1) {
231 specifier = packageName.slice(atIndex + 1)
232 packageName = packageName.slice(0, atIndex)
233 }
234 }
235
236 // Special case: packages with "does-not-exist" in the name should 404
237 if (packageName.includes('does-not-exist') || packageName.includes('nonexistent')) {
238 return { error: 'not_found' }
239 }
240
241 const fixturePath = getFixturePath('packument', packageName)
242 const packument = await storage.getItem<any>(fixturePath)
243
244 if (!packument) {
245 // For unknown packages without the special markers, try to return stub data
246 // This is handled elsewhere - returning error here for fast-npm-meta
247 return { error: 'not_found' }
248 }
249
250 let version: string | undefined
251 if (specifier === 'latest' || !specifier) {
252 version = packument['dist-tags']?.latest
253 } else if (packument['dist-tags']?.[specifier]) {
254 version = packument['dist-tags'][specifier]
255 } else if (packument.versions?.[specifier]) {
256 version = specifier
257 } else {
258 version = packument['dist-tags']?.latest
259 }
260
261 if (!version) {
262 return { error: 'version_not_found' }
263 }
264
265 const result: Record<string, unknown> = {
266 name: packageName,
267 specifier,
268 version,
269 publishedAt: packument.time?.[version] || new Date().toISOString(),
270 lastSynced: Date.now(),
271 }
272
273 // Include metadata if requested
274 if (metadata) {
275 const versionData = packument.versions?.[version]
276 if (versionData?.deprecated) {
277 result.deprecated = versionData.deprecated
278 }
279 }
280
281 return result
282}
283
284/**
285 * Process a single package for the /versions/ endpoint.
286 * Returns PackageVersionsInfo shape: { name, distTags, versions, specifier, time, lastSynced }
287 */
288async function processSingleVersionsMeta(
289 packageQuery: string,
290 storage: ReturnType<typeof useStorage>,
291 metadata: boolean,
292): Promise<Record<string, unknown>> {
293 let packageName = packageQuery
294 let specifier = '*'
295
296 if (packageName.startsWith('@')) {
297 const atIndex = packageName.indexOf('@', 1)
298 if (atIndex !== -1) {
299 specifier = packageName.slice(atIndex + 1)
300 packageName = packageName.slice(0, atIndex)
301 }
302 } else {
303 const atIndex = packageName.indexOf('@')
304 if (atIndex !== -1) {
305 specifier = packageName.slice(atIndex + 1)
306 packageName = packageName.slice(0, atIndex)
307 }
308 }
309
310 if (packageName.includes('does-not-exist') || packageName.includes('nonexistent')) {
311 return { name: packageName, error: 'not_found' }
312 }
313
314 const fixturePath = getFixturePath('packument', packageName)
315 const packument = await storage.getItem<any>(fixturePath)
316
317 if (!packument) {
318 return { name: packageName, error: 'not_found' }
319 }
320
321 const result: Record<string, unknown> = {
322 name: packageName,
323 specifier,
324 distTags: packument['dist-tags'] || {},
325 versions: Object.keys(packument.versions || {}),
326 time: packument.time || {},
327 lastSynced: Date.now(),
328 }
329
330 if (metadata) {
331 const versionsMeta: Record<string, Record<string, unknown>> = {}
332 for (const [ver, data] of Object.entries(packument.versions || {})) {
333 const meta: Record<string, unknown> = { version: ver }
334 const vData = data as Record<string, unknown>
335 if (vData.deprecated) meta.deprecated = vData.deprecated
336 if (packument.time?.[ver]) meta.time = packument.time[ver]
337 versionsMeta[ver] = meta
338 }
339 result.versionsMeta = versionsMeta
340 }
341
342 return result
343}
344
345async function handleFastNpmMeta(
346 url: string,
347 storage: ReturnType<typeof useStorage>,
348): Promise<MockResult | null> {
349 let urlObj: URL
350 try {
351 urlObj = new URL(url)
352 } catch {
353 return null
354 }
355
356 const { host, pathname, searchParams } = urlObj
357
358 if (host !== 'npm.antfu.dev') return null
359
360 const rawPath = decodeURIComponent(pathname.slice(1))
361 if (!rawPath) return null
362
363 const metadata = searchParams.get('metadata') === 'true'
364
365 // Determine if this is a /versions/ request
366 const isVersions = rawPath.startsWith('versions/')
367 const pathPart = isVersions ? rawPath.slice('versions/'.length) : rawPath
368 const processFn = isVersions
369 ? (pkg: string) => processSingleVersionsMeta(pkg, storage, metadata)
370 : (pkg: string) => processSingleFastNpmMeta(pkg, storage, metadata)
371
372 // Handle batch requests (package1+package2+...)
373 if (pathPart.includes('+')) {
374 const packages = pathPart.split('+')
375 const results = await Promise.all(packages.map(processFn))
376 return { data: results }
377 }
378
379 // Handle single package request
380 const result = await processFn(pathPart)
381 if ('error' in result) {
382 return { data: null }
383 }
384 return { data: result }
385}
386
387/**
388 * Handle GitHub API requests using fixtures.
389 */
390async function handleGitHubApi(
391 url: string,
392 storage: ReturnType<typeof useStorage>,
393): Promise<MockResult | null> {
394 let urlObj: URL
395 try {
396 urlObj = new URL(url)
397 } catch {
398 return null
399 }
400
401 const { host, pathname } = urlObj
402
403 if (host !== 'api.github.com') return null
404
405 // Contributors endpoint: /repos/{owner}/{repo}/contributors
406 const contributorsMatch = pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/contributors$/)
407 if (contributorsMatch) {
408 const contributors = await storage.getItem<unknown[]>(FIXTURE_PATHS.githubContributors)
409 if (contributors) {
410 return { data: contributors }
411 }
412 // Return empty array if no fixture exists
413 return { data: [] }
414 }
415
416 // Other GitHub API endpoints can be added here as needed
417 return null
418}
419
420interface FixtureMatchWithVersion extends FixtureMatch {
421 version?: string // 'latest', a semver version, or undefined for full packument
422}
423
424function matchUrlToFixture(url: string): FixtureMatchWithVersion | null {
425 let urlObj: URL
426 try {
427 urlObj = new URL(url)
428 } catch {
429 return null
430 }
431
432 const { host, pathname, searchParams } = urlObj
433
434 // npm registry (registry.npmjs.org)
435 if (host === 'registry.npmjs.org') {
436 // Search endpoint
437 if (pathname === '/-/v1/search') {
438 const query = searchParams.get('text')
439 if (query) {
440 const maintainerMatch = query.match(/^maintainer:(.+)$/)
441 if (maintainerMatch?.[1]) {
442 return { type: 'user', name: maintainerMatch[1] }
443 }
444 return { type: 'search', name: query }
445 }
446 return { type: 'search', name: '' }
447 }
448
449 // Org packages
450 const orgMatch = pathname.match(/^\/-\/org\/([^/]+)\/package$/)
451 if (orgMatch?.[1]) {
452 return { type: 'org', name: orgMatch[1] }
453 }
454
455 // Packument - handle both full packument and version manifest requests
456 let packagePath = decodeURIComponent(pathname.slice(1))
457 if (packagePath && !packagePath.startsWith('-/')) {
458 let version: string | undefined
459
460 if (packagePath.startsWith('@')) {
461 const parts = packagePath.split('/')
462 if (parts.length > 2) {
463 // @scope/name/version or @scope/name/latest
464 version = parts[2]
465 packagePath = `${parts[0]}/${parts[1]}`
466 }
467 // else just @scope/name - full packument
468 } else {
469 const slashIndex = packagePath.indexOf('/')
470 if (slashIndex !== -1) {
471 // name/version or name/latest
472 version = packagePath.slice(slashIndex + 1)
473 packagePath = packagePath.slice(0, slashIndex)
474 }
475 // else just name - full packument
476 }
477
478 return { type: 'packument', name: packagePath, version }
479 }
480 }
481
482 // npm API (api.npmjs.org)
483 if (host === 'api.npmjs.org') {
484 const downloadsMatch = pathname.match(/^\/downloads\/point\/[^/]+\/(.+)$/)
485 if (downloadsMatch?.[1]) {
486 return { type: 'downloads', name: decodeURIComponent(downloadsMatch[1]) }
487 }
488 }
489
490 return null
491}
492
493/**
494 * Log a message to stderr with clear formatting for unmocked requests.
495 */
496function logUnmockedRequest(type: string, detail: string, url: string): void {
497 process.stderr.write(
498 `\n` +
499 `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
500 `[test-fixtures] ${type}\n` +
501 `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
502 `${detail}\n` +
503 `URL: ${url}\n` +
504 `\n` +
505 `To fix: Add a fixture file or update test/e2e/test-utils.ts\n` +
506 `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`,
507 )
508}
509
510/**
511 * Shared fixture-backed fetch implementation.
512 * This is used by both cachedFetch and the global $fetch override.
513 */
514async function fetchFromFixtures<T>(
515 url: string,
516 storage: ReturnType<typeof useStorage>,
517): Promise<CachedFetchResult<T>> {
518 // Check for mock responses (OSV, JSR)
519 const mockResult = getMockForUrl(url)
520 if (mockResult) {
521 if (VERBOSE) process.stdout.write(`[test-fixtures] Mock: ${url}\n`)
522 return { data: mockResult.data as T, isStale: false, cachedAt: Date.now() }
523 }
524
525 // Check for fast-npm-meta
526 const fastNpmMetaResult = await handleFastNpmMeta(url, storage)
527 if (fastNpmMetaResult) {
528 if (VERBOSE) process.stdout.write(`[test-fixtures] Fast-npm-meta: ${url}\n`)
529 return { data: fastNpmMetaResult.data as T, isStale: false, cachedAt: Date.now() }
530 }
531
532 // Check for GitHub API
533 const githubResult = await handleGitHubApi(url, storage)
534 if (githubResult) {
535 if (VERBOSE) process.stdout.write(`[test-fixtures] GitHub API: ${url}\n`)
536 return { data: githubResult.data as T, isStale: false, cachedAt: Date.now() }
537 }
538
539 const match = matchUrlToFixture(url)
540
541 if (!match) {
542 logUnmockedRequest('NO FIXTURE PATTERN', 'URL does not match any known fixture pattern', url)
543 throw createError({
544 statusCode: 404,
545 statusMessage: 'No test fixture available',
546 message: `No fixture pattern matches URL: ${url}`,
547 })
548 }
549
550 const fixturePath = getFixturePath(match.type, match.name)
551 const rawData = await storage.getItem<any>(fixturePath)
552
553 if (rawData === null) {
554 // For user searches or search queries without fixtures, return empty results
555 if (match.type === 'user' || match.type === 'search') {
556 if (VERBOSE) process.stdout.write(`[test-fixtures] Empty ${match.type}: ${match.name}\n`)
557 return {
558 data: { objects: [], total: 0, time: new Date().toISOString() } as T,
559 isStale: false,
560 cachedAt: Date.now(),
561 }
562 }
563
564 // For org packages without fixtures, return 404
565 if (match.type === 'org') {
566 throw createError({
567 statusCode: 404,
568 statusMessage: 'Org not found',
569 message: `No fixture for org: ${match.name}`,
570 })
571 }
572
573 // For packuments without fixtures, return a stub packument
574 // This allows tests to work without needing fixtures for every dependency
575 if (match.type === 'packument') {
576 // Special case: packages with "does-not-exist" in the name should 404
577 // This allows tests to verify 404 behavior for nonexistent packages
578 if (match.name.includes('does-not-exist') || match.name.includes('nonexistent')) {
579 throw createError({
580 statusCode: 404,
581 statusMessage: 'Package not found',
582 message: `Package ${match.name} does not exist`,
583 })
584 }
585
586 if (VERBOSE) process.stderr.write(`[test-fixtures] Stub packument: ${match.name}\n`)
587 const stubVersion = '1.0.0'
588 const stubPackument = {
589 'name': match.name,
590 'dist-tags': { latest: stubVersion },
591 'versions': {
592 [stubVersion]: {
593 name: match.name,
594 version: stubVersion,
595 description: `Stub fixture for ${match.name}`,
596 dependencies: {},
597 },
598 },
599 'time': {
600 created: new Date().toISOString(),
601 modified: new Date().toISOString(),
602 [stubVersion]: new Date().toISOString(),
603 },
604 'maintainers': [],
605 }
606
607 // If a specific version was requested, return just that version manifest
608 if (match.version) {
609 return {
610 data: stubPackument.versions[stubVersion] as T,
611 isStale: false,
612 cachedAt: Date.now(),
613 }
614 }
615
616 return {
617 data: stubPackument as T,
618 isStale: false,
619 cachedAt: Date.now(),
620 }
621 }
622
623 // For downloads without fixtures, return zero downloads
624 if (match.type === 'downloads') {
625 if (VERBOSE) process.stderr.write(`[test-fixtures] Stub downloads: ${match.name}\n`)
626 return {
627 data: {
628 downloads: 0,
629 start: '2025-01-01',
630 end: '2025-01-31',
631 package: match.name,
632 } as T,
633 isStale: false,
634 cachedAt: Date.now(),
635 }
636 }
637
638 // Log missing fixture for unknown types
639 if (VERBOSE) {
640 process.stderr.write(`[test-fixtures] Missing: ${fixturePath}\n`)
641 }
642
643 throw createError({
644 statusCode: 404,
645 statusMessage: 'Not found',
646 message: `No fixture for ${match.type}: ${match.name}`,
647 })
648 }
649
650 // Handle version-specific requests for packuments (e.g., /create-vite/latest)
651 let data: T = rawData
652 if (match.type === 'packument' && match.version) {
653 const packument = rawData as any
654 let resolvedVersion = match.version
655
656 // Resolve 'latest' or dist-tags to actual version
657 if (packument['dist-tags']?.[resolvedVersion]) {
658 resolvedVersion = packument['dist-tags'][resolvedVersion]
659 }
660
661 // Return the version manifest instead of full packument
662 const versionData = packument.versions?.[resolvedVersion]
663 if (versionData) {
664 data = versionData as T
665 if (VERBOSE)
666 process.stdout.write(
667 `[test-fixtures] Served: ${match.type}/${match.name}@${resolvedVersion}\n`,
668 )
669 } else {
670 if (VERBOSE)
671 process.stderr.write(
672 `[test-fixtures] Version not found: ${match.name}@${resolvedVersion}\n`,
673 )
674 throw createError({
675 statusCode: 404,
676 statusMessage: 'Version not found',
677 message: `No version ${resolvedVersion} in fixture for ${match.name}`,
678 })
679 }
680 } else {
681 if (VERBOSE) process.stdout.write(`[test-fixtures] Served: ${match.type}/${match.name}\n`)
682 }
683
684 return { data, isStale: false, cachedAt: Date.now() }
685}
686
687/**
688 * Handle native fetch for esm.sh URLs.
689 */
690async function handleEsmShFetch(
691 urlStr: string,
692 init: RequestInit | undefined,
693 storage: ReturnType<typeof useStorage>,
694): Promise<Response> {
695 const method = init?.method?.toUpperCase() || 'GET'
696 const urlObj = new URL(urlStr)
697 const pathname = urlObj.pathname.slice(1) // Remove leading /
698
699 // HEAD request - return headers with x-typescript-types if fixture exists
700 if (method === 'HEAD') {
701 // Extract package@version from pathname
702 let pkgVersion = pathname
703 const slashIndex = pkgVersion.indexOf(
704 '/',
705 pkgVersion.includes('@') ? pkgVersion.lastIndexOf('@') + 1 : 0,
706 )
707 if (slashIndex !== -1) {
708 pkgVersion = pkgVersion.slice(0, slashIndex)
709 }
710
711 const fixturePath = `${FIXTURE_PATHS.esmHeaders}:${pkgVersion.replace(/\//g, ':')}.json`
712 const headerData = await storage.getItem<{ 'x-typescript-types': string }>(fixturePath)
713
714 if (headerData) {
715 if (VERBOSE) process.stdout.write(`[test-fixtures] fetch HEAD esm.sh: ${pkgVersion}\n`)
716 return new Response(null, {
717 status: 200,
718 headers: {
719 'x-typescript-types': headerData['x-typescript-types'],
720 'content-type': 'application/javascript',
721 },
722 })
723 }
724
725 // No fixture - return 200 without x-typescript-types header (types not available)
726 if (VERBOSE)
727 process.stdout.write(`[test-fixtures] fetch HEAD esm.sh (no fixture): ${pkgVersion}\n`)
728 return new Response(null, {
729 status: 200,
730 headers: { 'content-type': 'application/javascript' },
731 })
732 }
733
734 // GET request - return .d.ts content if fixture exists
735 if (method === 'GET' && pathname.endsWith('.d.ts')) {
736 const fixturePath = `${FIXTURE_PATHS.esmTypes}:${pathname.replace(/\//g, ':')}`
737 const content = await storage.getItem<string>(fixturePath)
738
739 if (content) {
740 if (VERBOSE) process.stdout.write(`[test-fixtures] fetch GET esm.sh: ${pathname}\n`)
741 return new Response(content, {
742 status: 200,
743 headers: { 'content-type': 'application/typescript' },
744 })
745 }
746
747 // Return a minimal stub .d.ts file instead of 404
748 // This allows docs tests to work without real type definition fixtures
749 if (VERBOSE)
750 process.stdout.write(`[test-fixtures] fetch GET esm.sh (stub types): ${pathname}\n`)
751 const stubTypes = `// Stub types for ${pathname}
752export declare function stubFunction(): void;
753export declare const stubConstant: string;
754export type StubType = string | number;
755export interface StubInterface {
756 value: string;
757}
758`
759 return new Response(stubTypes, {
760 status: 200,
761 headers: { 'content-type': 'application/typescript' },
762 })
763 }
764
765 // Other esm.sh requests - return empty response
766 return new Response(null, { status: 200 })
767}
768
769export default defineNitroPlugin(nitroApp => {
770 const storage = useStorage('fixtures')
771
772 if (VERBOSE) {
773 process.stdout.write('[test-fixtures] Test mode active (verbose logging enabled)\n')
774 }
775
776 const originalFetch = globalThis.fetch
777 const original$fetch = globalThis.$fetch
778
779 // Override native fetch for esm.sh requests and to inject test fixture responses
780 globalThis.fetch = async (input: URL | RequestInfo, init?: RequestInit): Promise<Response> => {
781 const urlStr =
782 typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
783
784 if (
785 urlStr.startsWith('/') ||
786 urlStr.startsWith('data:') ||
787 urlStr.includes('woff') ||
788 urlStr.includes('fonts')
789 ) {
790 return await originalFetch(input, init)
791 }
792
793 if (urlStr.startsWith('https://esm.sh/')) {
794 return await handleEsmShFetch(urlStr, init, storage)
795 }
796
797 try {
798 const res = await fetchFromFixtures(urlStr, storage)
799 if (res.data) {
800 return new Response(JSON.stringify(res.data), {
801 status: 200,
802 headers: { 'content-type': 'application/json' },
803 })
804 }
805 return new Response('Not Found', { status: 404 })
806 } catch (err: any) {
807 // Convert createError exceptions to proper HTTP responses
808 const statusCode = err?.statusCode || err?.status || 404
809 const message = err?.message || 'Not Found'
810 return new Response(JSON.stringify({ error: message }), {
811 status: statusCode,
812 headers: { 'content-type': 'application/json' },
813 })
814 }
815 }
816
817 const $fetch = createFetch({
818 fetch: globalThis.fetch,
819 })
820
821 // Create the wrapper function for globalThis.$fetch
822 const fetchWrapper = async <T = unknown>(
823 url: string,
824 options?: Parameters<typeof $fetch>[1],
825 ): Promise<T> => {
826 if (typeof url === 'string' && !url.startsWith('/')) {
827 return $fetch<T>(url, options as any)
828 }
829 return original$fetch<T>(url, options as any) as any
830 }
831
832 // Copy .raw and .create from the created $fetch instance to the wrapper
833 Object.assign(fetchWrapper, {
834 raw: $fetch.raw,
835 create: $fetch.create,
836 })
837
838 // Replace globalThis.$fetch with our wrapper (must be done AFTER setting .raw/.create)
839 // @ts-expect-error - wrapper function types don't fully match Nitro's $fetch types
840 globalThis.$fetch = fetchWrapper
841
842 // Per-request: set up cachedFetch on the event context
843 nitroApp.hooks.hook('request', event => {
844 event.context.cachedFetch = async (url: string, options?: any) => {
845 return {
846 data: await globalThis.$fetch(url, options),
847 isStale: false,
848 cachedAt: null,
849 }
850 }
851 })
852})