forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1#!/usr/bin/env npx tsx
2/**
3 * Fixture Generator Script
4 *
5 * Fetches data from npm registry and API, saving as JSON fixtures
6 * for use in CI tests.
7 *
8 * Usage:
9 * pnpm generate:fixtures # Generate all fixtures
10 * pnpm generate:fixtures vue nuxt # Generate specific packages only
11 */
12
13import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
14import { dirname, join } from 'node:path'
15import { fileURLToPath } from 'node:url'
16
17const FIXTURES_DIR = fileURLToPath(new URL('../test/fixtures', import.meta.url))
18
19const NPM_REGISTRY = 'https://registry.npmjs.org'
20const NPM_API = 'https://api.npmjs.org'
21
22// ============================================================================
23// Configuration: What fixtures to generate
24// ============================================================================
25
26/**
27 * Packages required by E2E tests.
28 * Keep this list minimal - only add packages that are directly used in tests.
29 *
30 * To find what's needed, check:
31 * - goto() calls in test/e2e/*.spec.ts
32 * - API endpoint tests (badges, vulnerabilities)
33 * - create-command tests (need create-* packages)
34 */
35const REQUIRED_PACKAGES = [
36 // Core packages for various tests
37 'vue', // search, badges, vulnerabilities, version test (3.5.27)
38 'nuxt', // org tests, badges, create-command
39 'vite', // create-command test
40 'next', // create-command test
41 '@nuxt/kit', // scoped package tests, version test (3.20.0)
42 '@types/node', // scoped package tests
43 // Docs page tests
44 'ufo', // docs test with version 1.6.3
45 'is-odd', // docs test (3.0.1), install copy test, "no create" test, hyphen-in-name test
46 // Edge case: package name with dots
47 'lodash.merge',
48 // Create-command feature (checks if create-* package exists)
49 'create-vite',
50 'create-next-app',
51 'create-nuxt',
52] as const
53
54/**
55 * Search queries used in tests.
56 */
57const REQUIRED_SEARCHES = ['vue', 'nuxt', 'keywords:framework'] as const
58
59/**
60 * Organizations whose package lists are needed.
61 */
62const REQUIRED_ORGS = ['nuxt'] as const
63
64/**
65 * Users whose package lists are needed.
66 * Use users with few packages to keep fixtures small.
67 */
68const REQUIRED_USERS = ['qwerzl'] as const
69
70/**
71 * Packages that need esm.sh TypeScript types fixtures for docs tests.
72 * Format: { package: version }
73 */
74const REQUIRED_ESM_TYPES: Record<string, string> = {
75 'ufo': '1.6.3',
76 'is-odd': '3.0.1',
77}
78
79// ============================================================================
80// Utility Functions
81// ============================================================================
82
83function ensureDir(path: string): void {
84 if (!existsSync(path)) {
85 mkdirSync(path, { recursive: true })
86 }
87}
88
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 */
93function 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
126function writeFixture(path: string, data: unknown): void {
127 ensureDir(dirname(path))
128 const sanitized = sanitizeEmails(data)
129 writeFileSync(path, JSON.stringify(sanitized, null, 2) + '\n')
130 console.log(` Written: ${path}`)
131}
132
133async function fetchJson<T>(url: string): Promise<T> {
134 const response = await fetch(url)
135 if (!response.ok) {
136 throw new Error(`HTTP ${response.status}: ${url}`)
137 }
138 return response.json() as Promise<T>
139}
140
141function encodePackageName(name: string): string {
142 // Encode scoped packages: @scope/name -> @scope%2Fname
143 if (name.startsWith('@')) {
144 return '@' + encodeURIComponent(name.slice(1))
145 }
146 return encodeURIComponent(name)
147}
148
149function packageToFilename(name: string): string {
150 return `${name}.json`
151}
152
153function searchQueryToFilename(query: string): string {
154 return `${query.replace(/:/g, '-')}.json`
155}
156
157// ============================================================================
158// Packument Slimming
159// ============================================================================
160
161/**
162 * Number of recent versions to keep in slimmed packuments.
163 * This matches the RECENT_VERSIONS_COUNT in useNpmRegistry.ts
164 */
165const RECENT_VERSIONS_COUNT = 10
166
167/**
168 * Slim down a packument to only essential fields.
169 * This dramatically reduces file size while keeping all data tests need.
170 */
171function slimPackument(pkg: Record<string, unknown>): Record<string, unknown> {
172 const distTags = (pkg['dist-tags'] ?? {}) as Record<string, string>
173 const versions = (pkg.versions ?? {}) as Record<string, Record<string, unknown>>
174 const time = (pkg.time ?? {}) as Record<string, string>
175
176 // Get versions pointed to by dist-tags
177 const distTagVersions = new Set(Object.values(distTags))
178
179 // Get recent versions by publish time
180 const recentVersions = Object.keys(versions)
181 .filter(v => time[v])
182 .sort((a, b) => {
183 const timeA = time[a]
184 const timeB = time[b]
185 if (!timeA || !timeB) return 0
186 return new Date(timeB).getTime() - new Date(timeA).getTime()
187 })
188 .slice(0, RECENT_VERSIONS_COUNT)
189
190 // Combine: recent versions + dist-tag versions (deduplicated)
191 const includedVersions = new Set([...recentVersions, ...distTagVersions])
192
193 // Build filtered versions object - keep full version data for included versions
194 const filteredVersions: Record<string, Record<string, unknown>> = {}
195 for (const v of includedVersions) {
196 const version = versions[v]
197 if (version) {
198 // Keep most fields but remove readme from individual versions
199 // eslint-disable-next-line @typescript-eslint/no-unused-vars
200 const { readme, ...rest } = version
201 filteredVersions[v] = rest
202 }
203 }
204
205 // Build filtered time object (only for included versions + metadata)
206 const filteredTime: Record<string, string> = {}
207 if (time.modified) filteredTime.modified = time.modified
208 if (time.created) filteredTime.created = time.created
209 for (const v of includedVersions) {
210 if (time[v]) filteredTime[v] = time[v]
211 }
212
213 // Return slimmed packument
214 return {
215 '_id': pkg._id,
216 '_rev': pkg._rev,
217 'name': pkg.name,
218 'description': pkg.description,
219 'dist-tags': distTags,
220 'versions': filteredVersions,
221 'time': filteredTime,
222 'maintainers': pkg.maintainers,
223 'author': pkg.author,
224 'license': pkg.license,
225 'homepage': pkg.homepage,
226 'keywords': pkg.keywords,
227 'repository': pkg.repository,
228 'bugs': pkg.bugs,
229 // Keep readme at root level (used for package page)
230 'readme': pkg.readme,
231 'readmeFilename': pkg.readmeFilename,
232 }
233}
234
235// ============================================================================
236// Fixture Generators
237// ============================================================================
238
239async function generatePackumentFixture(packageName: string): Promise<void> {
240 console.log(` Fetching packument: ${packageName}`)
241
242 const encoded = encodePackageName(packageName)
243 const url = `${NPM_REGISTRY}/${encoded}`
244
245 try {
246 const data = await fetchJson<Record<string, unknown>>(url)
247 const slimmed = slimPackument(data)
248 const filename = packageToFilename(packageName)
249 const path = join(FIXTURES_DIR, 'npm-registry', 'packuments', filename)
250 writeFixture(path, slimmed)
251 } catch (error) {
252 console.error(` Failed to fetch ${packageName}:`, error)
253 throw error
254 }
255}
256
257async function generateDownloadsFixture(packageName: string): Promise<void> {
258 console.log(` Fetching downloads: ${packageName}`)
259
260 const encoded = encodePackageName(packageName)
261 const url = `${NPM_API}/downloads/point/last-week/${encoded}`
262
263 try {
264 const data = await fetchJson(url)
265 const filename = packageToFilename(packageName)
266 const path = join(FIXTURES_DIR, 'npm-api', 'downloads', filename)
267 writeFixture(path, data)
268 } catch (error) {
269 console.error(` Failed to fetch downloads for ${packageName}:`, error)
270 // Downloads are optional, don't throw
271 }
272}
273
274async function generateSearchFixture(query: string): Promise<void> {
275 console.log(` Fetching search: ${query}`)
276
277 const params = new URLSearchParams({ text: query, size: '25' })
278 const url = `${NPM_REGISTRY}/-/v1/search?${params}`
279
280 try {
281 const data = await fetchJson(url)
282 const filename = searchQueryToFilename(query)
283 const path = join(FIXTURES_DIR, 'npm-registry', 'search', filename)
284 writeFixture(path, data)
285 } catch (error) {
286 console.error(` Failed to fetch search "${query}":`, error)
287 throw error
288 }
289}
290
291async function generateOrgFixture(orgName: string): Promise<void> {
292 console.log(` Fetching org packages: ${orgName}`)
293
294 const url = `${NPM_REGISTRY}/-/org/${encodeURIComponent(orgName)}/package`
295
296 try {
297 const data = await fetchJson(url)
298 const path = join(FIXTURES_DIR, 'npm-registry', 'orgs', `${orgName}.json`)
299 writeFixture(path, data)
300 } catch (error) {
301 console.error(` Failed to fetch org ${orgName}:`, error)
302 throw error
303 }
304}
305
306async function generateUserFixture(username: string): Promise<void> {
307 console.log(` Fetching user packages: ${username}`)
308
309 // npm doesn't have a direct API for user packages, but we can search
310 // with the maintainer filter
311 const params = new URLSearchParams({
312 text: `maintainer:${username}`,
313 size: '100',
314 })
315 const url = `${NPM_REGISTRY}/-/v1/search?${params}`
316
317 try {
318 const data = await fetchJson(url)
319 const path = join(FIXTURES_DIR, 'users', `${username}.json`)
320 writeFixture(path, data)
321 } catch (error) {
322 console.error(` Failed to fetch user ${username}:`, error)
323 throw error
324 }
325}
326
327async 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
336 if (!headResponse.ok) {
337 console.log(
338 ` esm.sh HEAD request failed for ${packageName}@${version}: HTTP ${headResponse.status}`,
339 )
340 return
341 }
342
343 const typesUrl = headResponse.headers.get('x-typescript-types')
344
345 if (!typesUrl) {
346 console.log(` No types available for ${packageName}@${version}`)
347 return
348 }
349
350 // Fetch the actual types content
351 const typesResponse = await fetch(typesUrl)
352 if (!typesResponse.ok) {
353 throw new Error(`HTTP ${typesResponse.status}: ${typesUrl}`)
354 }
355 const typesContent = await typesResponse.text()
356
357 // Extract the path portion from the types URL for the fixture path
358 // e.g., https://esm.sh/ufo@1.6.3/dist/index.d.ts -> ufo@1.6.3/dist/index.d.ts
359 const typesPath = typesUrl.replace('https://esm.sh/', '')
360
361 // Save the types header info
362 const headerFixturePath = join(
363 FIXTURES_DIR,
364 'esm-sh',
365 'headers',
366 `${packageName}@${version}.json`,
367 )
368 writeFixture(headerFixturePath, {
369 'x-typescript-types': typesUrl,
370 })
371
372 // Save the actual types content
373 const typesFixturePath = join(FIXTURES_DIR, 'esm-sh', 'types', typesPath)
374 ensureDir(dirname(typesFixturePath))
375 writeFileSync(typesFixturePath, typesContent)
376 console.log(` Written: ${typesFixturePath}`)
377 } catch (error) {
378 console.error(` Failed to fetch esm.sh types for ${packageName}@${version}:`, error)
379 // Types are optional for some packages, don't throw
380 }
381}
382
383// ============================================================================
384// Main
385// ============================================================================
386
387async function main(): Promise<void> {
388 const args = process.argv.slice(2)
389
390 // If specific packages are provided, only generate those
391 const specificPackages = args.filter(arg => !arg.startsWith('-'))
392
393 console.log('\n=== Generating Test Fixtures ===\n')
394
395 // Determine which packages to generate
396 const packagesToGenerate = specificPackages.length > 0 ? specificPackages : [...REQUIRED_PACKAGES]
397
398 // Generate packument fixtures
399 console.log('\nPackuments:')
400 for (const pkg of packagesToGenerate) {
401 await generatePackumentFixture(pkg)
402 }
403
404 // Generate downloads fixtures
405 console.log('\nDownloads:')
406 for (const pkg of packagesToGenerate) {
407 await generateDownloadsFixture(pkg)
408 }
409
410 // Only generate search/org/user fixtures when doing a full generation
411 if (specificPackages.length === 0) {
412 // Generate search fixtures
413 console.log('\nSearch Results:')
414 for (const query of REQUIRED_SEARCHES) {
415 await generateSearchFixture(query)
416 }
417
418 // Generate org fixtures
419 console.log('\nOrganizations:')
420 for (const org of REQUIRED_ORGS) {
421 await generateOrgFixture(org)
422 }
423
424 // Generate user fixtures
425 console.log('\nUsers:')
426 for (const user of REQUIRED_USERS) {
427 await generateUserFixture(user)
428 }
429
430 // Generate esm.sh types fixtures
431 console.log('\nesm.sh Types:')
432 for (const [pkg, version] of Object.entries(REQUIRED_ESM_TYPES)) {
433 await generateEsmTypesFixture(pkg, version)
434 }
435 }
436
437 console.log('\n=== Fixture Generation Complete ===\n')
438}
439
440main().catch(error => {
441 console.error('Fixture generation failed:', error)
442 process.exit(1)
443})