coverage-partitions.spec.ts 19 KB

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