coverage-partitions.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { dirname, join } from 'node:path'
  4. import { afterEach, describe, expect, it, vi } from 'vitest'
  5. import {
  6. COVERAGE_PARTITION_MODE_ENV,
  7. COVERAGE_PARTITIONS_ENV,
  8. COVERAGE_TEST_TIMEOUT_ENV,
  9. CoveragePartitionCoordinator,
  10. assignWeightedPartitions,
  11. collectPartitionDurations,
  12. coverageTestTimeoutArgs,
  13. forwardedCoverageArgs,
  14. parseCoveragePartitionCount,
  15. parseListOutput,
  16. readFileDurations,
  17. writeFileDurations,
  18. type CoverageCommand,
  19. type CoverageCommandResult,
  20. type CoveragePartitionCoordinatorOptions,
  21. } from './coverage-partitions.ts'
  22. const passed: CoverageCommandResult = { exitCode: 0, signalCode: null }
  23. /** Every temporary root created by this file, removed after each test. */
  24. const roots: string[] = []
  25. afterEach(async () => {
  26. vi.restoreAllMocks()
  27. for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
  28. })
  29. async function writeBlob(command: CoverageCommand): Promise<void> {
  30. if (command.blobPath === undefined) return
  31. await mkdir(dirname(command.blobPath), { recursive: true })
  32. await writeFile(command.blobPath, '{}')
  33. }
  34. async function temporaryRoot(): Promise<string> {
  35. const root = await mkdtemp(join(tmpdir(), 'dsh-coverage-partitions-'))
  36. roots.push(root)
  37. return root
  38. }
  39. /** Write a Vitest results cache under a temporary root. */
  40. async function writeVitestCache(root: string, entries: Array<[string, { duration: number }]>): Promise<void> {
  41. const cacheDir = join(root, 'node_modules/.vite/vitest/cache-hash')
  42. await mkdir(cacheDir, { recursive: true })
  43. await writeFile(join(cacheDir, 'results.json'), JSON.stringify({ version: '4.1.8', results: entries }))
  44. }
  45. /** Run the coordinator and capture every partition config's source. */
  46. async function runCoordinatorReadingConfigs(
  47. root: string,
  48. options: Omit<CoveragePartitionCoordinatorOptions, 'root' | 'pnpmEntrypoint' | 'runCommand'>,
  49. ): Promise<Map<string, string>> {
  50. const configContents = new Map<string, string>()
  51. const runCommand = vi.fn(async (command: CoverageCommand) => {
  52. const configArgument = command.args.find(argument => argument.startsWith('--config='))
  53. if (configArgument !== undefined) {
  54. configContents.set(command.label, await readFile(join(root, configArgument.slice('--config='.length)), 'utf8'))
  55. }
  56. await writeBlob(command)
  57. return passed
  58. })
  59. const coordinator = new CoveragePartitionCoordinator({
  60. root,
  61. pnpmEntrypoint: '/pnpm.cjs',
  62. runCommand,
  63. ...options,
  64. })
  65. await expect(coordinator.run()).resolves.toBe(0)
  66. return configContents
  67. }
  68. function successfulCommandRecorder(commands: CoverageCommand[]) {
  69. return vi.fn(async (command: CoverageCommand) => {
  70. commands.push(command)
  71. await writeBlob(command)
  72. return passed
  73. })
  74. }
  75. describe('coverage partition count', () => {
  76. it.each([
  77. [undefined, undefined],
  78. ['', undefined],
  79. ['2', 2],
  80. ['3', 3],
  81. ])('parses %j as %j', (raw, expected) => {
  82. expect(parseCoveragePartitionCount(raw)).toBe(expected)
  83. })
  84. it.each(['0', '1', '2.5', '02', 'many'])('rejects %j', (raw) => {
  85. expect(() => parseCoveragePartitionCount(raw))
  86. .toThrow(`${COVERAGE_PARTITIONS_ENV} must be an integer greater than 1`)
  87. })
  88. })
  89. describe('coverage partition timeout', () => {
  90. it('applies one configured timeout to tests, polling, and hooks', () => {
  91. expect(coverageTestTimeoutArgs('30000')).toEqual([
  92. '--testTimeout=30000',
  93. '--expect.poll.timeout=30000',
  94. '--hookTimeout=30000',
  95. ])
  96. })
  97. it('keeps Vitest defaults when the timeout is absent', () => {
  98. expect(coverageTestTimeoutArgs(undefined)).toEqual([])
  99. })
  100. it('rejects invalid timeout input', () => {
  101. expect(() => coverageTestTimeoutArgs('0'))
  102. .toThrow(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer`)
  103. })
  104. })
  105. describe('coverage forwarded arguments', () => {
  106. it('removes one package-script separator', () => {
  107. expect(forwardedCoverageArgs(['--', 'scripts/example.spec.ts'])).toEqual(['scripts/example.spec.ts'])
  108. })
  109. it('preserves direct arguments and a subsequent Vitest separator', () => {
  110. expect(forwardedCoverageArgs(['--testNamePattern=example'])).toEqual(['--testNamePattern=example'])
  111. expect(forwardedCoverageArgs(['--', '--', 'example'])).toEqual(['--', 'example'])
  112. })
  113. })
  114. describe('weighted partition assignment', () => {
  115. it('seeds the heaviest files into different partitions', () => {
  116. const weights = new Map<string, number>([
  117. ['packages/a/tests/heavy-1.spec.ts', 100],
  118. ['packages/a/tests/heavy-2.spec.ts', 90],
  119. ['packages/a/tests/heavy-3.spec.ts', 80],
  120. ['packages/a/tests/light.spec.ts', 1],
  121. ])
  122. const buckets = assignWeightedPartitions([...weights.keys()], weights, 3)
  123. expect(buckets).toHaveLength(3)
  124. for (const bucket of buckets) {
  125. expect(bucket).not.toHaveLength(0)
  126. expect(bucket.filter(file => file.includes('heavy'))).toHaveLength(1)
  127. }
  128. })
  129. it('balances total weight across partitions', () => {
  130. const files = Array.from({ length: 20 }, (_, index) => `packages/a/tests/file-${index}.spec.ts`)
  131. const weights = new Map(files.map((file, index) => [file, (index % 7) + 1]))
  132. const buckets = assignWeightedPartitions(files, weights, 4)
  133. const sums = buckets.map(bucket => bucket.reduce((sum, file) => sum + (weights.get(file) ?? 0), 0))
  134. const spread = Math.max(...sums) - Math.min(...sums)
  135. expect(spread).toBeLessThanOrEqual(7)
  136. })
  137. it('steers assignment by weight, not by file count', () => {
  138. // Weight-aware LPT balances three buckets to 1500 each. A file-count-only
  139. // rule pairs the heaviest file with the fourth (1700), so the assertion
  140. // only passes when recorded weights steer the assignment.
  141. const weights = new Map<string, number>([
  142. ['a.spec.ts', 1000],
  143. ['b.spec.ts', 900],
  144. ['c.spec.ts', 800],
  145. ['d.spec.ts', 700],
  146. ['e.spec.ts', 600],
  147. ['f.spec.ts', 500],
  148. ])
  149. const buckets = assignWeightedPartitions([...weights.keys()], weights, 3)
  150. const sums = buckets.map(bucket => bucket.reduce((sum, file) => sum + (weights.get(file) ?? 0), 0))
  151. expect(Math.max(...sums)).toBeLessThanOrEqual(1550)
  152. })
  153. it('leaves trailing empty buckets when files are scarce', () => {
  154. const files = ['a.spec.ts', 'b.spec.ts']
  155. const buckets = assignWeightedPartitions(files, new Map(), 4)
  156. expect(buckets.map(bucket => bucket.length).sort()).toEqual([0, 0, 1, 1])
  157. })
  158. it('returns one empty bucket per partition for an empty inventory', () => {
  159. expect(assignWeightedPartitions([], new Map(), 3)).toEqual([[], [], []])
  160. })
  161. it('assigns unknown-weight files evenly', () => {
  162. const files = ['a.spec.ts', 'b.spec.ts', 'c.spec.ts', 'd.spec.ts']
  163. const buckets = assignWeightedPartitions(files, new Map(), 2)
  164. expect(buckets.map(bucket => bucket.length).sort()).toEqual([2, 2])
  165. })
  166. })
  167. describe('coverage file inventory', () => {
  168. it('parses vitest list --filesOnly output, keeps project ownership, and drops exempt suites', async () => {
  169. const root = await temporaryRoot()
  170. const exemptDir = join(root, 'packages/experimental/webworker-runtime/tests')
  171. await mkdir(exemptDir, { recursive: true })
  172. await writeFile(join(exemptDir, 'transform-corpus.spec.ts'), '')
  173. const output = [
  174. '[thread-safe] packages/a/tests/a.spec.ts',
  175. '[process-bound] packages/b/tests/b.spec.ts',
  176. '[thread-safe] packages/experimental/webworker-runtime/tests/transform-corpus.spec.ts',
  177. 'not a test line',
  178. ].join('\n')
  179. const inventory = parseListOutput(output, root)
  180. expect(inventory.files).toEqual([
  181. 'packages/a/tests/a.spec.ts',
  182. 'packages/b/tests/b.spec.ts',
  183. ])
  184. expect(inventory.projectOf.get('packages/a/tests/a.spec.ts')).toBe('thread-safe')
  185. expect(inventory.projectOf.get('packages/b/tests/b.spec.ts')).toBe('process-bound')
  186. })
  187. it('averages recorded durations per file from the results cache', async () => {
  188. const root = await temporaryRoot()
  189. await writeVitestCache(root, [
  190. ['thread-safe:packages/a/tests/x.spec.ts', { duration: 10 }],
  191. ['process-bound:packages/a/tests/x.spec.ts', { duration: 30 }],
  192. ['thread-safe:packages/a/tests/y.spec.ts', { duration: 5 }],
  193. ])
  194. const durations = readFileDurations(root)
  195. expect(durations.get('packages/a/tests/x.spec.ts')).toBe(20)
  196. expect(durations.get('packages/a/tests/y.spec.ts')).toBe(5)
  197. })
  198. it('prefers the persisted duration file over the vitest cache', async () => {
  199. const root = await temporaryRoot()
  200. await writeVitestCache(root, [['thread-safe:packages/a/tests/x.spec.ts', { duration: 100 }]])
  201. writeFileDurations(root, new Map([['packages/a/tests/x.spec.ts', 42]]))
  202. expect(readFileDurations(root).get('packages/a/tests/x.spec.ts')).toBe(42)
  203. })
  204. it('merges new durations into the persisted file', async () => {
  205. const root = await temporaryRoot()
  206. writeFileDurations(root, new Map([['packages/a/tests/x.spec.ts', 42]]))
  207. writeFileDurations(root, new Map([
  208. ['packages/a/tests/x.spec.ts', 55],
  209. ['packages/a/tests/y.spec.ts', 7],
  210. ]))
  211. const durations = readFileDurations(root)
  212. expect(durations.get('packages/a/tests/x.spec.ts')).toBe(55)
  213. expect(durations.get('packages/a/tests/y.spec.ts')).toBe(7)
  214. })
  215. it('extracts per-file durations from partition json reports', async () => {
  216. const root = await temporaryRoot()
  217. const report = join(root, 'partition-1.report.json')
  218. await writeFile(report, JSON.stringify({
  219. testResults: [
  220. { name: join(root, 'packages/a/tests/x.spec.ts'), startTime: 1000, endTime: 1500 },
  221. { name: 'not-a-spec', startTime: 1, endTime: 2 },
  222. ],
  223. }))
  224. const durations = collectPartitionDurations([report], root)
  225. expect(durations.get('packages/a/tests/x.spec.ts')).toBe(500)
  226. })
  227. })
  228. describe('coverage partition coordinator', () => {
  229. const weightedFiles = ['a.spec.ts', 'b.spec.ts', 'c.spec.ts']
  230. const weightedDurations = new Map([
  231. ['a.spec.ts', 100],
  232. ['b.spec.ts', 50],
  233. ['c.spec.ts', 10],
  234. ])
  235. const weightedProjects = new Map([
  236. ['a.spec.ts', 'thread-safe'],
  237. ['b.spec.ts', 'process-bound'],
  238. ['c.spec.ts', 'process-bound'],
  239. ])
  240. it('runs every single-worker partition before one merged threshold check', async () => {
  241. const root = await temporaryRoot()
  242. const commands: CoverageCommand[] = []
  243. const partitionConfigs: string[] = []
  244. const runCommand = vi.fn(async (command: CoverageCommand) => {
  245. commands.push(command)
  246. const configArgument = command.args.find(argument => argument.startsWith('--config='))
  247. if (configArgument !== undefined) {
  248. partitionConfigs.push(await readFile(join(root, configArgument.slice('--config='.length)), 'utf8'))
  249. }
  250. await writeBlob(command)
  251. return passed
  252. })
  253. const coordinator = new CoveragePartitionCoordinator({
  254. root,
  255. partitions: 3,
  256. pnpmEntrypoint: '/pnpm.cjs',
  257. vitestArgs: ['--testTimeout=30000'],
  258. files: ['a.spec.ts', 'b.spec.ts', 'c.spec.ts'],
  259. runCommand,
  260. })
  261. await expect(coordinator.run()).resolves.toBe(0)
  262. expect(commands.map(command => command.label)).toEqual([
  263. 'partition 1/3',
  264. 'partition 2/3',
  265. 'partition 3/3',
  266. 'merged coverage report',
  267. ])
  268. for (const command of commands.slice(0, 3)) {
  269. expect(command.command).toBe(process.execPath)
  270. expect(command.args[0]).toBe('/pnpm.cjs')
  271. expect(command.args).toEqual(expect.arrayContaining([
  272. '--coverage',
  273. '--coverage.reportOnFailure',
  274. '--maxWorkers=1',
  275. '--reporter=default',
  276. '--reporter=blob',
  277. '--reporter=json',
  278. '--testTimeout=30000',
  279. ]))
  280. expect(command.args).not.toContain('--shard=1/3')
  281. expect(command.args.some(argument => argument.startsWith('--config='))).toBe(true)
  282. expect(command.env).toEqual({
  283. [COVERAGE_PARTITIONS_ENV]: undefined,
  284. [COVERAGE_PARTITION_MODE_ENV]: '1',
  285. })
  286. }
  287. // The partition file list travels in a temporary config, not on the
  288. // command line (which exceeds the Windows CreateProcess limit).
  289. expect(partitionConfigs).toHaveLength(3)
  290. const allConfigs = partitionConfigs.join('\n')
  291. expect(allConfigs).toContain('a.spec.ts')
  292. expect(allConfigs).toContain('b.spec.ts')
  293. expect(allConfigs).toContain('c.spec.ts')
  294. for (const source of partitionConfigs) {
  295. expect(source).toContain("from '../../vitest.config.ts'")
  296. }
  297. const mergeCommand = commands[3]
  298. if (mergeCommand === undefined) throw new Error('coverage merge command was not observed')
  299. expect(mergeCommand.args).toContain('--coverage')
  300. expect(mergeCommand.args.some(argument => argument.startsWith('--merge-reports='))).toBe(true)
  301. expect(mergeCommand.env).toEqual({
  302. [COVERAGE_PARTITIONS_ENV]: undefined,
  303. [COVERAGE_PARTITION_MODE_ENV]: undefined,
  304. })
  305. })
  306. it('rejects an empty partition assignment before spawning any command', async () => {
  307. const root = await temporaryRoot()
  308. const runCommand = vi.fn()
  309. const coordinator = new CoveragePartitionCoordinator({
  310. root,
  311. partitions: 3,
  312. pnpmEntrypoint: '/pnpm.cjs',
  313. // One file for three partitions leaves two buckets empty; an empty
  314. // bucket would make Vitest run the whole suite.
  315. files: ['a.spec.ts'],
  316. runCommand,
  317. })
  318. await expect(coordinator.run()).rejects.toThrow('partition 2/3 has no files')
  319. expect(runCommand).not.toHaveBeenCalled()
  320. })
  321. it('starts the heaviest partition first for fail-fast', async () => {
  322. const root = await temporaryRoot()
  323. const configContents = await runCoordinatorReadingConfigs(root, {
  324. partitions: 2,
  325. files: weightedFiles,
  326. weights: weightedDurations,
  327. })
  328. // LPT: p1=[a] sum 100, p2=[b,c] sum 60; sorted heaviest-first makes
  329. // partition 1/2 the heavy one, so its config names a.spec.ts.
  330. expect(configContents.get('partition 1/2')).toContain('a.spec.ts')
  331. expect(configContents.get('partition 1/2')).not.toContain('b.spec.ts')
  332. })
  333. it('gives each project only its own files in the partition config', async () => {
  334. const root = await temporaryRoot()
  335. const configContents = await runCoordinatorReadingConfigs(root, {
  336. partitions: 2,
  337. files: weightedFiles,
  338. weights: weightedDurations,
  339. projectOf: weightedProjects,
  340. })
  341. const allConfigs = [...configContents.values()].join('\n')
  342. // The process-bound project must not receive the thread-safe file and
  343. // vice versa, or plain files would run twice.
  344. expect(allConfigs).toContain("include: project.test.name === 'process-bound' ?")
  345. expect(allConfigs).not.toContain('"a.spec.ts","b.spec.ts","c.spec.ts"')
  346. })
  347. it('runs a native pnpm entrypoint directly', async () => {
  348. const root = await temporaryRoot()
  349. const commands: CoverageCommand[] = []
  350. const runCommand = successfulCommandRecorder(commands)
  351. const coordinator = new CoveragePartitionCoordinator({
  352. root,
  353. partitions: 2,
  354. pnpmEntrypoint: '/tools/pnpm',
  355. files: ['a.spec.ts', 'b.spec.ts'],
  356. runCommand,
  357. })
  358. await expect(coordinator.run()).resolves.toBe(0)
  359. expect(commands).toHaveLength(3)
  360. for (const command of commands) {
  361. expect(command.command).toBe('/tools/pnpm')
  362. expect(command.args[0]).toBe('exec')
  363. }
  364. })
  365. it('merges normal test failures and returns their failed status', async () => {
  366. const root = await temporaryRoot()
  367. const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  368. const runCommand = vi.fn(async (command: CoverageCommand) => {
  369. await writeBlob(command)
  370. return command.label === 'partition 2/2'
  371. ? { exitCode: 1, signalCode: null, outputTail: 'specific Vitest failure' }
  372. : passed
  373. })
  374. const coordinator = new CoveragePartitionCoordinator({
  375. root,
  376. partitions: 2,
  377. pnpmEntrypoint: '/pnpm.cjs',
  378. files: ['a.spec.ts', 'b.spec.ts'],
  379. runCommand,
  380. })
  381. await expect(coordinator.run()).resolves.toBe(1)
  382. expect(reported).toHaveBeenCalledWith('coverage-partitions: FAIL partition 2/2 (exit 1)')
  383. expect(reported).toHaveBeenCalledWith(
  384. 'coverage-partitions: output tail for partition 2/2:\nspecific Vitest failure',
  385. )
  386. expect(runCommand).toHaveBeenCalledTimes(3)
  387. })
  388. it('rejects a missing partition blob before merge', async () => {
  389. const root = await temporaryRoot()
  390. const runCommand = vi.fn(async (command: CoverageCommand) => {
  391. if (command.label !== 'partition 2/2') await writeBlob(command)
  392. return passed
  393. })
  394. const coordinator = new CoveragePartitionCoordinator({
  395. root,
  396. partitions: 2,
  397. pnpmEntrypoint: '/pnpm.cjs',
  398. files: ['a.spec.ts', 'b.spec.ts'],
  399. runCommand,
  400. })
  401. await expect(coordinator.run()).rejects.toThrow('coverage partitions produced')
  402. expect(runCommand).toHaveBeenCalledTimes(2)
  403. })
  404. it('reports signal termination before missing-blob validation', async () => {
  405. const root = await temporaryRoot()
  406. const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  407. const runCommand = vi.fn(async (command: CoverageCommand) => {
  408. if (command.label === 'partition 1/2') await writeBlob(command)
  409. return command.label === 'partition 2/2'
  410. ? { exitCode: null, signalCode: 'SIGTERM' as const }
  411. : passed
  412. })
  413. const coordinator = new CoveragePartitionCoordinator({
  414. root,
  415. partitions: 2,
  416. pnpmEntrypoint: '/pnpm.cjs',
  417. files: ['a.spec.ts', 'b.spec.ts'],
  418. runCommand,
  419. })
  420. await expect(coordinator.run()).rejects.toThrow('coverage partitions produced')
  421. expect(reported).toHaveBeenCalledWith('coverage-partitions: FAIL partition 2/2 (signal SIGTERM)')
  422. })
  423. it('waits for every partition after one spawn failure', async () => {
  424. const root = await temporaryRoot()
  425. const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  426. let secondFinished = false
  427. const runCommand = vi.fn(async (command: CoverageCommand) => {
  428. await writeBlob(command)
  429. if (command.label === 'partition 1/2') {
  430. return { exitCode: null, signalCode: null, error: 'spawn unavailable' }
  431. }
  432. if (command.label === 'partition 2/2') secondFinished = true
  433. return passed
  434. })
  435. const coordinator = new CoveragePartitionCoordinator({
  436. root,
  437. partitions: 2,
  438. pnpmEntrypoint: '/pnpm.cjs',
  439. files: ['a.spec.ts', 'b.spec.ts'],
  440. runCommand,
  441. })
  442. await expect(coordinator.run()).resolves.toBe(1)
  443. expect(reported).toHaveBeenCalledWith('coverage-partitions: FAIL partition 1/2 (spawn unavailable)')
  444. expect(secondFinished).toBe(true)
  445. expect(runCommand).toHaveBeenCalledTimes(3)
  446. })
  447. it('unlinks a link-shaped coverage path without touching its target', async () => {
  448. const root = await temporaryRoot()
  449. const target = await temporaryRoot()
  450. const marker = join(target, 'marker.txt')
  451. await writeFile(marker, 'owned elsewhere')
  452. await symlink(target, join(root, 'coverage'), process.platform === 'win32' ? 'junction' : 'dir')
  453. const runCommand = vi.fn(async (command: CoverageCommand) => {
  454. await writeBlob(command)
  455. return passed
  456. })
  457. const coordinator = new CoveragePartitionCoordinator({
  458. root,
  459. partitions: 2,
  460. pnpmEntrypoint: '/pnpm.cjs',
  461. files: ['a.spec.ts', 'b.spec.ts'],
  462. runCommand,
  463. })
  464. await expect(coordinator.run()).resolves.toBe(0)
  465. await expect(access(marker)).resolves.toBeUndefined()
  466. })
  467. })