Skip to content

Commit 3ae5a95

Browse files
committed
test: add esm.sh handling + sanitise user emails
1 parent cc189df commit 3ae5a95

22 files changed

Lines changed: 1558 additions & 1218 deletions

File tree

modules/runtime/server/cache.ts

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const FIXTURE_PATHS = {
2323
org: 'npm-registry/orgs',
2424
downloads: 'npm-api/downloads',
2525
user: 'users',
26+
esmHeaders: 'esm-sh/headers',
27+
esmTypes: 'esm-sh/types',
2628
} as const
2729

2830
type FixtureType = keyof typeof FIXTURE_PATHS
@@ -84,6 +86,9 @@ function getMockForUrl(url: string): MockResult | null {
8486
return { data: null }
8587
}
8688

89+
// esm.sh is handled specially via $fetch.raw override, not here
90+
// Return null to indicate no mock available at the cachedFetch level
91+
8792
return null
8893
}
8994

@@ -152,6 +157,114 @@ async function handleFastNpmMeta(
152157
}
153158
}
154159

160+
/**
161+
* Handle esm.sh requests for the standard $fetch (returns data directly).
162+
*/
163+
async function handleEsmShRequest(
164+
url: string,
165+
_options: unknown,
166+
storage: ReturnType<typeof useStorage>,
167+
): Promise<unknown | null> {
168+
const urlObj = new URL(url)
169+
if (urlObj.hostname !== 'esm.sh') return null
170+
171+
// For GET requests to .d.ts files, return the file content
172+
const typesPath = urlObj.pathname.slice(1) // Remove leading /
173+
if (typesPath.endsWith('.d.ts')) {
174+
const fixturePath = `${FIXTURE_PATHS.esmTypes}:${typesPath.replace(/\//g, ':')}`
175+
const content = await storage.getItem<string>(fixturePath)
176+
if (content) {
177+
if (VERBOSE) process.stdout.write(`[test-fixtures] esm.sh types: ${typesPath}\n`)
178+
return content
179+
}
180+
}
181+
182+
return null
183+
}
184+
185+
/**
186+
* Handle esm.sh requests for $fetch.raw (returns response object with headers).
187+
*/
188+
async function handleEsmShRawRequest(
189+
url: string,
190+
options: unknown,
191+
storage: ReturnType<typeof useStorage>,
192+
): Promise<unknown | null> {
193+
const urlObj = new URL(url)
194+
if (urlObj.hostname !== 'esm.sh') return null
195+
196+
const pathname = urlObj.pathname.slice(1) // Remove leading /
197+
const opts = options as { method?: string } | undefined
198+
199+
// HEAD request - return headers with x-typescript-types
200+
if (opts?.method === 'HEAD') {
201+
// Extract package@version from pathname (e.g., "ufo@1.6.3" or "@scope/pkg@1.0.0")
202+
let pkgVersion = pathname
203+
// Remove any trailing path after the version
204+
const slashIndex = pkgVersion.indexOf(
205+
'/',
206+
pkgVersion.includes('@') ? pkgVersion.lastIndexOf('@') + 1 : 0,
207+
)
208+
if (slashIndex !== -1) {
209+
pkgVersion = pkgVersion.slice(0, slashIndex)
210+
}
211+
212+
const fixturePath = `${FIXTURE_PATHS.esmHeaders}:${pkgVersion.replace(/\//g, ':')}.json`
213+
const headerData = await storage.getItem<{ 'x-typescript-types': string }>(fixturePath)
214+
215+
if (headerData) {
216+
if (VERBOSE) process.stdout.write(`[test-fixtures] esm.sh HEAD: ${pkgVersion}\n`)
217+
// Return a mock response object similar to what $fetch.raw returns
218+
return {
219+
status: 200,
220+
statusText: 'OK',
221+
url,
222+
headers: new Headers({
223+
'x-typescript-types': headerData['x-typescript-types'],
224+
'content-type': 'application/javascript',
225+
}),
226+
_data: null,
227+
}
228+
}
229+
230+
// No fixture - return empty response (no types available)
231+
if (VERBOSE) process.stdout.write(`[test-fixtures] esm.sh HEAD (no fixture): ${pkgVersion}\n`)
232+
return {
233+
status: 200,
234+
statusText: 'OK',
235+
url,
236+
headers: new Headers({
237+
'content-type': 'application/javascript',
238+
}),
239+
_data: null,
240+
}
241+
}
242+
243+
// GET request - return .d.ts content
244+
if (pathname.endsWith('.d.ts') || pathname.includes('.d.ts')) {
245+
const fixturePath = `${FIXTURE_PATHS.esmTypes}:${pathname.replace(/\//g, ':')}`
246+
const content = await storage.getItem<string>(fixturePath)
247+
248+
if (content) {
249+
if (VERBOSE) process.stdout.write(`[test-fixtures] esm.sh GET: ${pathname}\n`)
250+
// Create a blob-like response
251+
return {
252+
status: 200,
253+
statusText: 'OK',
254+
url,
255+
headers: new Headers({
256+
'content-type': 'application/typescript',
257+
}),
258+
_data: {
259+
text: async () => content,
260+
},
261+
}
262+
}
263+
}
264+
265+
return null
266+
}
267+
155268
function matchUrlToFixture(url: string): FixtureMatch | null {
156269
let urlObj: URL
157270
try {
@@ -236,6 +349,80 @@ export default defineNitroPlugin(nitroApp => {
236349
process.stdout.write('[test-fixtures] Test mode active (verbose logging enabled)\n')
237350
}
238351

352+
// Override native fetch globally to intercept esm.sh requests from ofetch
353+
// This is needed because server/utils/docs/client.ts imports $fetch from ofetch
354+
// directly, bypassing our globalThis.$fetch override
355+
const originalNativeFetch = globalThis.fetch
356+
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
357+
const url =
358+
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
359+
if (url.includes('esm.sh')) {
360+
const method = init?.method || 'GET'
361+
362+
// HEAD request - return headers with x-typescript-types
363+
if (method === 'HEAD') {
364+
const urlObj = new URL(url)
365+
const pathname = urlObj.pathname.slice(1)
366+
let pkgVersion = pathname
367+
const slashIndex = pkgVersion.indexOf(
368+
'/',
369+
pkgVersion.includes('@') ? pkgVersion.lastIndexOf('@') + 1 : 0,
370+
)
371+
if (slashIndex !== -1) {
372+
pkgVersion = pkgVersion.slice(0, slashIndex)
373+
}
374+
375+
const fixturePath = `${FIXTURE_PATHS.esmHeaders}:${pkgVersion.replace(/\//g, ':')}.json`
376+
const headerData = await storage.getItem<{ 'x-typescript-types': string }>(fixturePath)
377+
378+
if (headerData) {
379+
if (VERBOSE) process.stdout.write(`[test-fixtures] fetch HEAD esm.sh: ${pkgVersion}\n`)
380+
return new Response(null, {
381+
status: 200,
382+
headers: new Headers({
383+
'x-typescript-types': headerData['x-typescript-types'],
384+
'content-type': 'application/javascript',
385+
}),
386+
})
387+
}
388+
389+
// No fixture - return response without types header
390+
if (VERBOSE)
391+
process.stdout.write(`[test-fixtures] fetch HEAD esm.sh (no fixture): ${pkgVersion}\n`)
392+
return new Response(null, {
393+
status: 200,
394+
headers: new Headers({ 'content-type': 'application/javascript' }),
395+
})
396+
}
397+
398+
// GET request for .d.ts files
399+
if (method === 'GET') {
400+
const urlObj = new URL(url)
401+
const pathname = urlObj.pathname.slice(1)
402+
const fixturePath = `${FIXTURE_PATHS.esmTypes}:${pathname.replace(/\//g, ':')}`
403+
const content = await storage.getItem<string>(fixturePath)
404+
405+
if (content) {
406+
if (VERBOSE) process.stdout.write(`[test-fixtures] fetch GET esm.sh: ${pathname}\n`)
407+
return new Response(content, {
408+
status: 200,
409+
headers: new Headers({ 'content-type': 'application/typescript' }),
410+
})
411+
}
412+
413+
// No fixture - return 404 so the loader skips this file
414+
if (VERBOSE)
415+
process.stdout.write(`[test-fixtures] fetch GET esm.sh (no fixture): ${pathname}\n`)
416+
return new Response('Not Found', {
417+
status: 404,
418+
headers: new Headers({ 'content-type': 'text/plain' }),
419+
})
420+
}
421+
}
422+
423+
return originalNativeFetch(input, init)
424+
}
425+
239426
nitroApp.hooks.hook('request', event => {
240427
event.context.cachedFetch = async <T = unknown>(
241428
url: string,
@@ -309,8 +496,48 @@ export default defineNitroPlugin(nitroApp => {
309496
if (typeof url === 'string' && url.startsWith('/')) {
310497
return originalFetch(url, options)
311498
}
499+
500+
// Handle esm.sh requests specially (used by docs feature)
501+
if (typeof url === 'string' && url.includes('esm.sh')) {
502+
const esmResult = await handleEsmShRequest(url, options, storage)
503+
if (esmResult !== null) {
504+
return esmResult
505+
}
506+
// If no fixture found, throw an error in test mode
507+
throw createError({
508+
statusCode: 404,
509+
statusMessage: 'No esm.sh fixture available',
510+
message: `No fixture for esm.sh URL: ${url}`,
511+
})
512+
}
513+
312514
const { data } = await event.context.cachedFetch!<any>(url as string, options)
313515
return data
314516
}
517+
518+
// Also override $fetch.raw for esm.sh
519+
const originalFetchRaw = globalThis.$fetch.raw
520+
// @ts-expect-error invalid global augmentation
521+
globalThis.$fetch.raw = async (url, options) => {
522+
if (typeof url === 'string' && url.startsWith('/')) {
523+
return originalFetchRaw(url, options)
524+
}
525+
526+
// Handle esm.sh requests specially (used by docs feature)
527+
if (typeof url === 'string' && url.includes('esm.sh')) {
528+
const esmResult = await handleEsmShRawRequest(url, options, storage)
529+
if (esmResult !== null) {
530+
return esmResult
531+
}
532+
// If no fixture found, throw an error in test mode
533+
throw createError({
534+
statusCode: 404,
535+
statusMessage: 'No esm.sh fixture available',
536+
message: `No fixture for esm.sh URL: ${url}`,
537+
})
538+
}
539+
540+
return originalFetchRaw(url, options)
541+
}
315542
})
316543
})

scripts/generate-fixtures.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@ const REQUIRED_ORGS = ['nuxt'] as const
6767
*/
6868
const REQUIRED_USERS = ['qwerzl'] as const
6969

70+
/**
71+
* Packages that need esm.sh TypeScript types fixtures for docs tests.
72+
* Format: { package: version }
73+
*/
74+
const REQUIRED_ESM_TYPES: Record<string, string> = {
75+
'ufo': '1.6.3',
76+
'is-odd': '3.0.1',
77+
}
78+
7079
// ============================================================================
7180
// Utility Functions
7281
// ============================================================================
@@ -77,9 +86,47 @@ function ensureDir(path: string): void {
7786
}
7887
}
7988

89+
/**
90+
* Sanitize email addresses in fixture data to avoid exposing personal info.
91+
* Replaces real emails with anonymized versions like "user1@example.com".
92+
*/
93+
function sanitizeEmails(data: unknown): unknown {
94+
const emailMap = new Map<string, string>()
95+
let emailCounter = 0
96+
97+
function getAnonymizedEmail(email: string): string {
98+
if (!emailMap.has(email)) {
99+
emailCounter++
100+
emailMap.set(email, `user${emailCounter}@example.com`)
101+
}
102+
return emailMap.get(email)!
103+
}
104+
105+
function sanitize(obj: unknown): unknown {
106+
if (obj === null || obj === undefined) return obj
107+
if (typeof obj === 'string') return obj
108+
if (Array.isArray(obj)) return obj.map(sanitize)
109+
if (typeof obj === 'object') {
110+
const result: Record<string, unknown> = {}
111+
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
112+
if (key === 'email' && typeof value === 'string') {
113+
result[key] = getAnonymizedEmail(value)
114+
} else {
115+
result[key] = sanitize(value)
116+
}
117+
}
118+
return result
119+
}
120+
return obj
121+
}
122+
123+
return sanitize(data)
124+
}
125+
80126
function writeFixture(path: string, data: unknown): void {
81127
ensureDir(dirname(path))
82-
writeFileSync(path, JSON.stringify(data, null, 2) + '\n')
128+
const sanitized = sanitizeEmails(data)
129+
writeFileSync(path, JSON.stringify(sanitized, null, 2) + '\n')
83130
console.log(` Written: ${path}`)
84131
}
85132

@@ -277,6 +324,54 @@ async function generateUserFixture(username: string): Promise<void> {
277324
}
278325
}
279326

327+
async function generateEsmTypesFixture(packageName: string, version: string): Promise<void> {
328+
console.log(` Fetching esm.sh types: ${packageName}@${version}`)
329+
330+
const baseUrl = `https://esm.sh/${packageName}@${version}`
331+
332+
try {
333+
// First, get the types URL from the header
334+
const headResponse = await fetch(baseUrl, { method: 'HEAD' })
335+
const typesUrl = headResponse.headers.get('x-typescript-types')
336+
337+
if (!typesUrl) {
338+
console.log(` No types available for ${packageName}@${version}`)
339+
return
340+
}
341+
342+
// Fetch the actual types content
343+
const typesResponse = await fetch(typesUrl)
344+
if (!typesResponse.ok) {
345+
throw new Error(`HTTP ${typesResponse.status}: ${typesUrl}`)
346+
}
347+
const typesContent = await typesResponse.text()
348+
349+
// Extract the path portion from the types URL for the fixture path
350+
// e.g., https://esm.sh/ufo@1.6.3/dist/index.d.ts -> ufo@1.6.3/dist/index.d.ts
351+
const typesPath = typesUrl.replace('https://esm.sh/', '')
352+
353+
// Save the types header info
354+
const headerFixturePath = join(
355+
FIXTURES_DIR,
356+
'esm-sh',
357+
'headers',
358+
`${packageName}@${version}.json`,
359+
)
360+
writeFixture(headerFixturePath, {
361+
'x-typescript-types': typesUrl,
362+
})
363+
364+
// Save the actual types content
365+
const typesFixturePath = join(FIXTURES_DIR, 'esm-sh', 'types', typesPath)
366+
ensureDir(dirname(typesFixturePath))
367+
writeFileSync(typesFixturePath, typesContent)
368+
console.log(` Written: ${typesFixturePath}`)
369+
} catch (error) {
370+
console.error(` Failed to fetch esm.sh types for ${packageName}@${version}:`, error)
371+
// Types are optional for some packages, don't throw
372+
}
373+
}
374+
280375
// ============================================================================
281376
// Main
282377
// ============================================================================
@@ -323,6 +418,12 @@ async function main(): Promise<void> {
323418
for (const user of REQUIRED_USERS) {
324419
await generateUserFixture(user)
325420
}
421+
422+
// Generate esm.sh types fixtures
423+
console.log('\nesm.sh Types:')
424+
for (const [pkg, version] of Object.entries(REQUIRED_ESM_TYPES)) {
425+
await generateEsmTypesFixture(pkg, version)
426+
}
326427
}
327428

328429
console.log('\n=== Fixture Generation Complete ===\n')

0 commit comments

Comments
 (0)