coverage-partitions.spec.ts 19 KB

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