verify-cold-install-time.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. import { existsSync } from 'node:fs'
  2. import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises'
  3. import { dirname, join, relative, resolve, sep } from 'node:path'
  4. import { spawnSync } from 'node:child_process'
  5. import { performance } from 'node:perf_hooks'
  6. import { fileURLToPath } from 'node:url'
  7. import {
  8. collectPackageEntrypoints,
  9. loadEcosystemReleaseContext
  10. } from './lib/ecosystem-packages.mjs'
  11. const scriptDir = dirname(fileURLToPath(import.meta.url))
  12. const sourceRoot = resolve(scriptDir, '..')
  13. const args = process.argv.slice(2)
  14. const readArg = (name, fallback) => {
  15. const index = args.indexOf(name)
  16. return index >= 0 ? args[index + 1] : fallback
  17. }
  18. const readRepeatedArg = (name) =>
  19. args
  20. .filter(arg => arg.startsWith(`${name}=`))
  21. .map(arg => arg.slice(name.length + 1))
  22. const commandTimeoutMs = Number(
  23. readArg('--timeout-ms', process.env.FILE_VIEWER_COLD_INSTALL_TIMEOUT_MS || '600000')
  24. )
  25. const coldInstallRoot = resolve(
  26. sourceRoot,
  27. readArg(
  28. '--tmp-dir',
  29. process.env.FILE_VIEWER_COLD_INSTALL_DIR || '.release/cold-install-time'
  30. )
  31. )
  32. const keepTemp = args.includes('--keep-temp')
  33. const warnOnly = args.includes('--warn-only') || process.env.FILE_VIEWER_COLD_INSTALL_WARN_ONLY === '1'
  34. const jsonOutput = args.includes('--json')
  35. const budgetMultiplier = Number(process.env.FILE_VIEWER_COLD_INSTALL_BUDGET_MULTIPLIER || '1')
  36. const { entries } = await loadEcosystemReleaseContext(sourceRoot)
  37. const entryByPackageName = new Map(entries.map(entry => [entry.packageName, entry]))
  38. const targetDefinitions = [
  39. {
  40. id: 'core',
  41. label: 'Core only',
  42. dependencies: ['@file-viewer/core'],
  43. smokeImports: ['@file-viewer/core'],
  44. maxMs: 60_000
  45. },
  46. {
  47. id: 'vue3',
  48. label: 'Vue 3 wrapper only',
  49. dependencies: ['@file-viewer/vue3'],
  50. smokeImports: [],
  51. maxMs: 120_000
  52. },
  53. {
  54. id: 'vue3-lite',
  55. aliases: ['preset-lite', 'lite'],
  56. label: 'Vue 3 + lite preset',
  57. dependencies: ['@file-viewer/vue3', '@file-viewer/preset-lite'],
  58. smokeImports: ['@file-viewer/preset-lite'],
  59. maxMs: 150_000
  60. },
  61. {
  62. id: 'vue3-office',
  63. aliases: ['preset-office', 'office'],
  64. label: 'Vue 3 + office preset',
  65. dependencies: ['@file-viewer/vue3', '@file-viewer/preset-office'],
  66. smokeImports: ['@file-viewer/preset-office'],
  67. maxMs: 240_000
  68. },
  69. {
  70. id: 'vue3-engineering',
  71. aliases: ['preset-engineering', 'engineering'],
  72. label: 'Vue 3 + engineering preset',
  73. dependencies: ['@file-viewer/vue3', '@file-viewer/preset-engineering'],
  74. smokeImports: ['@file-viewer/preset-engineering'],
  75. maxMs: 300_000
  76. },
  77. {
  78. id: 'vue3-all',
  79. aliases: ['preset-all', 'all'],
  80. label: 'Vue 3 + full preset',
  81. dependencies: ['@file-viewer/vue3', '@file-viewer/preset-all'],
  82. smokeImports: ['@file-viewer/preset-all'],
  83. maxMs: 420_000
  84. }
  85. ]
  86. const targetById = new Map()
  87. for (const target of targetDefinitions) {
  88. targetById.set(target.id, target)
  89. for (const alias of target.aliases || []) {
  90. targetById.set(alias, target)
  91. }
  92. }
  93. const selectedTargetNames = readRepeatedArg('--target')
  94. const selectedTargets = selectedTargetNames.length
  95. ? [...new Map(selectedTargetNames.map(name => {
  96. const target = targetById.get(name)
  97. if (!target) {
  98. throw new Error(
  99. `Unknown cold install target "${name}". Known targets: ${targetDefinitions.map(item => item.id).join(', ')}`
  100. )
  101. }
  102. return [target.id, target]
  103. })).values()]
  104. : targetDefinitions
  105. function assert(condition, message) {
  106. if (!condition) {
  107. throw new Error(message)
  108. }
  109. }
  110. function run(command, commandArgs, cwd = sourceRoot, options = {}) {
  111. if (!jsonOutput) {
  112. console.log(`$ ${[command, ...commandArgs].join(' ')}`)
  113. }
  114. const captureOutput = options.capture || jsonOutput
  115. const result = spawnSync(command, commandArgs, {
  116. cwd,
  117. encoding: 'utf8',
  118. stdio: captureOutput ? 'pipe' : 'inherit',
  119. timeout: options.timeoutMs ?? commandTimeoutMs,
  120. killSignal: 'SIGTERM',
  121. env: {
  122. ...process.env,
  123. npm_config_loglevel: 'error'
  124. }
  125. })
  126. if (result.error) {
  127. throw new Error(
  128. [
  129. `Command failed in ${cwd}: ${command} ${commandArgs.join(' ')}`,
  130. result.error.message,
  131. result.stdout,
  132. result.stderr
  133. ].filter(Boolean).join('\n')
  134. )
  135. }
  136. if (result.status !== 0) {
  137. throw new Error(
  138. [
  139. `Command failed in ${cwd}: ${command} ${commandArgs.join(' ')}`,
  140. result.stdout,
  141. result.stderr
  142. ].filter(Boolean).join('\n')
  143. )
  144. }
  145. return result.stdout?.trim() || ''
  146. }
  147. function runtimeDependencyNames(packageJson) {
  148. return Object.keys({
  149. ...(packageJson.dependencies || {}),
  150. ...(packageJson.optionalDependencies || {})
  151. }).sort()
  152. }
  153. function collectLocalDependencyClosure(entry, collected = new Map()) {
  154. for (const dependencyName of runtimeDependencyNames(entry.packageJson)) {
  155. const dependencyEntry = entryByPackageName.get(dependencyName)
  156. if (!dependencyEntry || collected.has(dependencyEntry.packageName)) {
  157. continue
  158. }
  159. collected.set(dependencyEntry.packageName, dependencyEntry)
  160. collectLocalDependencyClosure(dependencyEntry, collected)
  161. }
  162. return collected
  163. }
  164. async function assertBuiltEntrypoints(entry) {
  165. for (const entrypoint of collectPackageEntrypoints(entry.packageJson)) {
  166. const absoluteEntrypoint = join(entry.absoluteDir, entrypoint)
  167. assert(
  168. existsSync(absoluteEntrypoint),
  169. `${entry.packageName} is missing ${entrypoint}. Build it before cold install verification.`
  170. )
  171. }
  172. }
  173. function toFileSpec(fromDir, targetFile) {
  174. const relativePath = relative(fromDir, targetFile).split(sep).join('/')
  175. return `file:${relativePath.startsWith('.') ? relativePath : `./${relativePath}`}`
  176. }
  177. async function directorySizeBytes(directory) {
  178. if (!existsSync(directory)) {
  179. return 0
  180. }
  181. let total = 0
  182. const items = await readdir(directory, { withFileTypes: true })
  183. for (const item of items) {
  184. const absolutePath = join(directory, item.name)
  185. if (item.isDirectory()) {
  186. total += await directorySizeBytes(absolutePath)
  187. } else {
  188. total += (await stat(absolutePath)).size
  189. }
  190. }
  191. return total
  192. }
  193. function formatBytes(value) {
  194. if (value < 1024) {
  195. return `${value} B`
  196. }
  197. if (value < 1024 * 1024) {
  198. return `${(value / 1024).toFixed(1)} KiB`
  199. }
  200. return `${(value / 1024 / 1024).toFixed(2)} MiB`
  201. }
  202. function formatMs(value) {
  203. if (value < 1000) {
  204. return `${Math.round(value)} ms`
  205. }
  206. return `${(value / 1000).toFixed(2)} s`
  207. }
  208. function collectTargetEntries(target) {
  209. const entriesByPackage = new Map()
  210. for (const packageName of target.dependencies) {
  211. const entry = entryByPackageName.get(packageName)
  212. assert(entry, `Missing package entry for cold install target ${target.id}: ${packageName}`)
  213. entriesByPackage.set(entry.packageName, entry)
  214. collectLocalDependencyClosure(entry).forEach(dependencyEntry => {
  215. entriesByPackage.set(dependencyEntry.packageName, dependencyEntry)
  216. })
  217. }
  218. return [...entriesByPackage.values()]
  219. }
  220. const requiredEntries = new Map()
  221. for (const target of selectedTargets) {
  222. for (const entry of collectTargetEntries(target)) {
  223. requiredEntries.set(entry.packageName, entry)
  224. }
  225. }
  226. const tarballDir = join(coldInstallRoot, 'tarballs')
  227. const casesDir = join(coldInstallRoot, 'cases')
  228. if (!keepTemp) {
  229. await rm(coldInstallRoot, { recursive: true, force: true })
  230. }
  231. await mkdir(tarballDir, { recursive: true })
  232. await mkdir(casesDir, { recursive: true })
  233. const tarballByPackageName = new Map()
  234. for (const entry of requiredEntries.values()) {
  235. await assertBuiltEntrypoints(entry)
  236. run('pnpm', ['-C', entry.packageDir, 'pack', '--pack-destination', tarballDir], sourceRoot, {
  237. capture: true
  238. })
  239. const tarball = join(tarballDir, entry.tarballName)
  240. assert(existsSync(tarball), `Expected tarball was not created: ${tarball}`)
  241. tarballByPackageName.set(entry.packageName, tarball)
  242. if (!jsonOutput) {
  243. console.log(`[cold-install] Packed ${entry.packageName} -> ${entry.tarballName}`)
  244. }
  245. }
  246. const reports = []
  247. const errors = []
  248. for (const target of selectedTargets) {
  249. const targetEntries = collectTargetEntries(target)
  250. const caseRoot = join(casesDir, target.id)
  251. await rm(caseRoot, { recursive: true, force: true })
  252. await mkdir(caseRoot, { recursive: true })
  253. const dependencies = Object.fromEntries(
  254. targetEntries.map(entry => [
  255. entry.packageName,
  256. toFileSpec(caseRoot, tarballByPackageName.get(entry.packageName))
  257. ])
  258. )
  259. await writeFile(
  260. join(caseRoot, 'package.json'),
  261. `${JSON.stringify(
  262. {
  263. name: `file-viewer-cold-install-${target.id}`,
  264. private: true,
  265. type: 'module',
  266. scripts: {
  267. smoke: 'node smoke.mjs'
  268. },
  269. dependencies
  270. },
  271. null,
  272. 2
  273. )}\n`
  274. )
  275. await writeFile(
  276. join(caseRoot, 'smoke.mjs'),
  277. `${target.smokeImports.map((packageName, index) => `import * as package${index} from '${packageName}'`).join('\n')}
  278. ${target.smokeImports.map((_packageName, index) => `if (!package${index}) throw new Error('Missing package ${index}')`).join('\n')}
  279. console.log('[cold-install] Smoke import passed for ${target.id}.')
  280. `
  281. )
  282. const startedAt = performance.now()
  283. run(
  284. 'npm',
  285. [
  286. 'install',
  287. '--ignore-scripts',
  288. '--no-audit',
  289. '--no-fund',
  290. '--prefer-online',
  291. '--legacy-peer-deps',
  292. '--install-strategy=shallow'
  293. ],
  294. caseRoot
  295. )
  296. const elapsedMs = performance.now() - startedAt
  297. if (target.smokeImports.length) {
  298. run('npm', ['run', 'smoke'], caseRoot)
  299. }
  300. const nodeModulesBytes = await directorySizeBytes(join(caseRoot, 'node_modules'))
  301. const maxMs = Math.round(target.maxMs * budgetMultiplier)
  302. const report = {
  303. id: target.id,
  304. label: target.label,
  305. dependencyCount: targetEntries.length,
  306. elapsedMs: Math.round(elapsedMs),
  307. maxMs,
  308. nodeModulesBytes
  309. }
  310. reports.push(report)
  311. if (elapsedMs > maxMs) {
  312. errors.push(
  313. `${target.id} cold install took ${formatMs(elapsedMs)}, exceeding budget ${formatMs(maxMs)}`
  314. )
  315. }
  316. }
  317. if (jsonOutput) {
  318. console.log(JSON.stringify({ reports, warnOnly }, null, 2))
  319. } else {
  320. for (const report of reports) {
  321. console.log(
  322. `[cold-install] ${report.id}: ${formatMs(report.elapsedMs)} / budget ${formatMs(report.maxMs)}, local packages ${report.dependencyCount}, node_modules ${formatBytes(report.nodeModulesBytes)}.`
  323. )
  324. }
  325. }
  326. if (errors.length) {
  327. const prefix = warnOnly ? '[cold-install] Warnings' : '[cold-install] Failed'
  328. console.error(prefix)
  329. errors.forEach(error => console.error(` - ${error}`))
  330. if (!warnOnly) {
  331. process.exitCode = 1
  332. }
  333. } else {
  334. if (!jsonOutput) {
  335. console.log(`[cold-install] Passed ${reports.length} cold install target${reports.length === 1 ? '' : 's'}.`)
  336. }
  337. }