diff --git a/packages/sv/src/addons/tests/_setup/global.ts b/packages/sv/src/addons/tests/_setup/global.ts index d5938b925..133bbee46 100644 --- a/packages/sv/src/addons/tests/_setup/global.ts +++ b/packages/sv/src/addons/tests/_setup/global.ts @@ -2,6 +2,7 @@ import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { setupGlobal } from 'sv/testing'; import { exec } from 'tinyexec'; +import { collectPrewarmSpecs } from './prewarm.ts'; const TEST_DIR = fileURLToPath(new URL('../../../../.test-output/addons/', import.meta.url)); const CI = Boolean(process.env.CI); @@ -9,10 +10,31 @@ const CI = Boolean(process.env.CI); export default setupGlobal({ TEST_DIR, pre: async () => { - if (CI) { + if (!CI) return; + + await Promise.all([ // prefetch the storybook cli during ci to reduce fetching errors in tests - const { stdout } = await exec('pnpm', ['dlx', `create-storybook@latest`, '--version']); - console.info('storybook version:', stdout); - } + exec('pnpm', ['dlx', `create-storybook@latest`, '--version']).then(({ stdout }) => + console.info('storybook version:', stdout) + ), + // Warm the pnpm store with every dependency the add-on test projects install, so the + // parallel test files only hard-link from the store instead of each downloading cold - + // which is what saturates the CI runner. Specs are read from source, never hardcoded. + (async () => { + const specs = collectPrewarmSpecs(); + const start = Date.now(); + const { exitCode, stderr } = await exec('pnpm', ['store', 'add', ...specs], { + throwOnError: false + }); + // best effort: a miss just means some installs download cold, it must never fail the suite + if (exitCode !== 0) { + console.warn(`store prewarm skipped (pnpm store add exited ${exitCode})\n${stderr}`); + return; + } + console.info( + `prewarmed ${specs.length} deps into the pnpm store in ${Date.now() - start}ms` + ); + })() + ]); } }); diff --git a/packages/sv/src/addons/tests/_setup/prewarm.ts b/packages/sv/src/addons/tests/_setup/prewarm.ts new file mode 100644 index 000000000..f216ace12 --- /dev/null +++ b/packages/sv/src/addons/tests/_setup/prewarm.ts @@ -0,0 +1,61 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ADDONS_DIR = fileURLToPath(new URL('../../', import.meta.url)); +const TEMPLATES_DIR = fileURLToPath(new URL('../../../create/templates/', import.meta.url)); +const SHARED_DIR = fileURLToPath(new URL('../../../create/shared/', import.meta.url)); + +// same extraction the `update-deps` script uses, see scripts/update-dependencies.js +const DEP_PATTERNS = [ + // sv.dependency('pkg', '^1.2.3') / sv.devDependency('pkg', '^1.2.3') + /sv\.(?:dependency|devDependency)\('([^']+)',\s*'([^']+)'\)/g, + // object literals: { package: 'pkg', version: '^1.2.3' } (ex: tailwind add-on) + /package:\s*'([^']+)',\s*version:\s*'([^']+)'/g, + // constants tagged for update-deps: /* update-deps: pkg */ '^1.2.3' (ex: common.ts) + /\/\*\s*update-deps:\s*(\S+)\s*\*\/\s*'([^']+)'/g +]; + +function addSpec(specs: Map, name: string, version: string): void { + // keep only real registry ranges; skip `workspace:*`, `latest`, placeholders, computed versions + if (!/^[\^~]?\d/.test(version)) return; + specs.set(name, version); +} + +function collectFromSources(specs: Map): void { + for (const file of fs.readdirSync(ADDONS_DIR)) { + if (!file.endsWith('.ts')) continue; + const content = fs.readFileSync(path.join(ADDONS_DIR, file), 'utf8'); + for (const pattern of DEP_PATTERNS) { + for (const [, name, version] of content.matchAll(pattern)) addSpec(specs, name, version); + } + } +} + +function collectFromPackageJsons(dir: string, fileName: string, specs: Map): void { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const filePath = path.join(dir, entry.name, fileName); + if (!fs.existsSync(filePath)) continue; + const pkg = JSON.parse(fs.readFileSync(filePath, 'utf8')); + for (const deps of [pkg.dependencies, pkg.devDependencies]) { + for (const [name, version] of Object.entries(deps ?? {})) { + if (typeof version === 'string') addSpec(specs, name, version); + } + } + } +} + +/** + * Collects every `pkg@range` declared across the add-ons and project templates, read straight + * from source (same way `update-deps` does) so it never goes stale. Used to warm the pnpm store + * before the addon test files install their generated projects in parallel. + */ +export function collectPrewarmSpecs(): string[] { + const specs = new Map(); + collectFromSources(specs); + collectFromPackageJsons(TEMPLATES_DIR, 'package.template.json', specs); + collectFromPackageJsons(SHARED_DIR, 'package.json', specs); + return [...specs].map(([name, version]) => `${name}@${version}`).sort(); +}