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