[READ-ONLY] a fast, modern browser for the npm registry
at main 443 lines 14 kB view raw
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})