coverage-partitions.spec.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. import { access, mkdir, mkdtemp, 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. coverageTestTimeoutArgs,
  11. forwardedCoverageArgs,
  12. parseCoveragePartitionCount,
  13. type CoverageCommand,
  14. type CoverageCommandResult,
  15. } from './coverage-partitions.ts'
  16. const passed: CoverageCommandResult = { exitCode: 0, signalCode: null }
  17. afterEach(() => vi.restoreAllMocks())
  18. async function writeBlob(command: CoverageCommand): Promise<void> {
  19. if (command.blobPath === undefined) return
  20. await mkdir(dirname(command.blobPath), { recursive: true })
  21. await writeFile(command.blobPath, '{}')
  22. }
  23. async function temporaryRoot(): Promise<string> {
  24. return await mkdtemp(join(tmpdir(), 'dsh-coverage-partitions-'))
  25. }
  26. function successfulCommandRecorder(commands: CoverageCommand[]) {
  27. return vi.fn(async (command: CoverageCommand) => {
  28. commands.push(command)
  29. await writeBlob(command)
  30. return passed
  31. })
  32. }
  33. describe('coverage partition count', () => {
  34. it.each([
  35. [undefined, undefined],
  36. ['', undefined],
  37. ['2', 2],
  38. ['3', 3],
  39. ])('parses %j as %j', (raw, expected) => {
  40. expect(parseCoveragePartitionCount(raw)).toBe(expected)
  41. })
  42. it.each(['0', '1', '2.5', '02', 'many'])('rejects %j', (raw) => {
  43. expect(() => parseCoveragePartitionCount(raw))
  44. .toThrow(`${COVERAGE_PARTITIONS_ENV} must be an integer greater than 1`)
  45. })
  46. })
  47. describe('coverage partition timeout', () => {
  48. it('applies one configured timeout to tests and polling', () => {
  49. expect(coverageTestTimeoutArgs('30000')).toEqual([
  50. '--testTimeout=30000',
  51. '--expect.poll.timeout=30000',
  52. ])
  53. })
  54. it('keeps Vitest defaults when the timeout is absent', () => {
  55. expect(coverageTestTimeoutArgs(undefined)).toEqual([])
  56. })
  57. it('rejects invalid timeout input', () => {
  58. expect(() => coverageTestTimeoutArgs('0'))
  59. .toThrow(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer`)
  60. })
  61. })
  62. describe('coverage forwarded arguments', () => {
  63. it('removes one package-script separator', () => {
  64. expect(forwardedCoverageArgs(['--', 'scripts/example.spec.ts'])).toEqual(['scripts/example.spec.ts'])
  65. })
  66. it('preserves direct arguments and a subsequent Vitest separator', () => {
  67. expect(forwardedCoverageArgs(['--testNamePattern=example'])).toEqual(['--testNamePattern=example'])
  68. expect(forwardedCoverageArgs(['--', '--', 'example'])).toEqual(['--', 'example'])
  69. })
  70. })
  71. describe('coverage partition coordinator', () => {
  72. it('runs every single-worker partition before one merged threshold check', async () => {
  73. const root = await temporaryRoot()
  74. const commands: CoverageCommand[] = []
  75. const runCommand = successfulCommandRecorder(commands)
  76. const coordinator = new CoveragePartitionCoordinator({
  77. root,
  78. partitions: 3,
  79. pnpmEntrypoint: '/pnpm.cjs',
  80. vitestArgs: ['--testTimeout=30000'],
  81. runCommand,
  82. })
  83. await expect(coordinator.run()).resolves.toBe(0)
  84. expect(commands.map(command => command.label)).toEqual([
  85. 'partition 1/3',
  86. 'partition 2/3',
  87. 'partition 3/3',
  88. 'merged coverage report',
  89. ])
  90. for (const [index, command] of commands.slice(0, 3).entries()) {
  91. expect(command.command).toBe(process.execPath)
  92. expect(command.args[0]).toBe('/pnpm.cjs')
  93. expect(command.args).toEqual(expect.arrayContaining([
  94. '--coverage',
  95. '--coverage.reportOnFailure',
  96. '--maxWorkers=1',
  97. `--shard=${index + 1}/3`,
  98. '--reporter=default',
  99. '--reporter=blob',
  100. '--testTimeout=30000',
  101. ]))
  102. expect(command.env).toEqual({
  103. [COVERAGE_PARTITIONS_ENV]: undefined,
  104. [COVERAGE_PARTITION_MODE_ENV]: '1',
  105. })
  106. }
  107. const mergeCommand = commands[3]
  108. if (mergeCommand === undefined) throw new Error('coverage merge command was not observed')
  109. expect(mergeCommand.args).toContain('--coverage')
  110. expect(mergeCommand.args.some(argument => argument.startsWith('--merge-reports='))).toBe(true)
  111. expect(mergeCommand.env).toEqual({
  112. [COVERAGE_PARTITIONS_ENV]: undefined,
  113. [COVERAGE_PARTITION_MODE_ENV]: undefined,
  114. })
  115. })
  116. it('runs a native pnpm entrypoint directly', async () => {
  117. const root = await temporaryRoot()
  118. const commands: CoverageCommand[] = []
  119. const runCommand = successfulCommandRecorder(commands)
  120. const coordinator = new CoveragePartitionCoordinator({
  121. root,
  122. partitions: 2,
  123. pnpmEntrypoint: '/tools/pnpm',
  124. runCommand,
  125. })
  126. await expect(coordinator.run()).resolves.toBe(0)
  127. expect(commands).toHaveLength(3)
  128. for (const command of commands) {
  129. expect(command.command).toBe('/tools/pnpm')
  130. expect(command.args[0]).toBe('exec')
  131. }
  132. })
  133. it('merges normal test failures and returns their failed status', async () => {
  134. const root = await temporaryRoot()
  135. const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  136. const runCommand = vi.fn(async (command: CoverageCommand) => {
  137. await writeBlob(command)
  138. return command.label === 'partition 2/2'
  139. ? { exitCode: 1, signalCode: null, outputTail: 'specific Vitest failure' }
  140. : passed
  141. })
  142. const coordinator = new CoveragePartitionCoordinator({
  143. root,
  144. partitions: 2,
  145. pnpmEntrypoint: '/pnpm.cjs',
  146. runCommand,
  147. })
  148. await expect(coordinator.run()).resolves.toBe(1)
  149. expect(reported).toHaveBeenCalledWith('coverage-partitions: FAIL partition 2/2 (exit 1)')
  150. expect(reported).toHaveBeenCalledWith(
  151. 'coverage-partitions: output tail for partition 2/2:\nspecific Vitest failure',
  152. )
  153. expect(runCommand).toHaveBeenCalledTimes(3)
  154. })
  155. it('rejects a missing partition blob before merge', async () => {
  156. const root = await temporaryRoot()
  157. const runCommand = vi.fn(async (command: CoverageCommand) => {
  158. if (command.label !== 'partition 2/2') await writeBlob(command)
  159. return passed
  160. })
  161. const coordinator = new CoveragePartitionCoordinator({
  162. root,
  163. partitions: 2,
  164. pnpmEntrypoint: '/pnpm.cjs',
  165. runCommand,
  166. })
  167. await expect(coordinator.run()).rejects.toThrow('coverage partitions produced')
  168. expect(runCommand).toHaveBeenCalledTimes(2)
  169. })
  170. it('reports signal termination before missing-blob validation', async () => {
  171. const root = await temporaryRoot()
  172. const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  173. const runCommand = vi.fn(async (command: CoverageCommand) => {
  174. if (command.label === 'partition 1/2') await writeBlob(command)
  175. return command.label === 'partition 2/2'
  176. ? { exitCode: null, signalCode: 'SIGTERM' as const }
  177. : passed
  178. })
  179. const coordinator = new CoveragePartitionCoordinator({
  180. root,
  181. partitions: 2,
  182. pnpmEntrypoint: '/pnpm.cjs',
  183. runCommand,
  184. })
  185. await expect(coordinator.run()).rejects.toThrow('coverage partitions produced')
  186. expect(reported).toHaveBeenCalledWith('coverage-partitions: FAIL partition 2/2 (signal SIGTERM)')
  187. })
  188. it('waits for every partition after one spawn failure', async () => {
  189. const root = await temporaryRoot()
  190. const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  191. let secondFinished = false
  192. const runCommand = vi.fn(async (command: CoverageCommand) => {
  193. await writeBlob(command)
  194. if (command.label === 'partition 1/2') {
  195. return { exitCode: null, signalCode: null, error: 'spawn unavailable' }
  196. }
  197. if (command.label === 'partition 2/2') secondFinished = true
  198. return passed
  199. })
  200. const coordinator = new CoveragePartitionCoordinator({
  201. root,
  202. partitions: 2,
  203. pnpmEntrypoint: '/pnpm.cjs',
  204. runCommand,
  205. })
  206. await expect(coordinator.run()).resolves.toBe(1)
  207. expect(reported).toHaveBeenCalledWith('coverage-partitions: FAIL partition 1/2 (spawn unavailable)')
  208. expect(secondFinished).toBe(true)
  209. expect(runCommand).toHaveBeenCalledTimes(3)
  210. })
  211. it('unlinks a link-shaped coverage path without touching its target', async () => {
  212. const root = await temporaryRoot()
  213. const target = await temporaryRoot()
  214. const marker = join(target, 'marker.txt')
  215. await writeFile(marker, 'owned elsewhere')
  216. await symlink(target, join(root, 'coverage'), process.platform === 'win32' ? 'junction' : 'dir')
  217. const runCommand = vi.fn(async (command: CoverageCommand) => {
  218. await writeBlob(command)
  219. return passed
  220. })
  221. const coordinator = new CoveragePartitionCoordinator({
  222. root,
  223. partitions: 2,
  224. pnpmEntrypoint: '/pnpm.cjs',
  225. runCommand,
  226. })
  227. await expect(coordinator.run()).resolves.toBe(0)
  228. await expect(access(marker)).resolves.toBeUndefined()
  229. })
  230. })