coverage-partitions.spec.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715
  1. import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
  2. import { createRequire } from 'node:module'
  3. import { tmpdir } from 'node:os'
  4. import { dirname, join, resolve } from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import {
  8. COVERAGE_PARTITION_MODE_ENV,
  9. COVERAGE_PARTITIONS_ENV,
  10. COVERAGE_TEST_TIMEOUT_ENV,
  11. CoveragePartitionCoordinator,
  12. assignWeightedPartitions,
  13. collectPartitionDurations,
  14. coverageTestTimeoutArgs,
  15. forwardedCoverageArgs,
  16. parseCoveragePartitionCount,
  17. parseListOutput,
  18. readFileDurations,
  19. writeFileDurations,
  20. type CoverageCommand,
  21. type CoverageCommandResult,
  22. type CoveragePartitionCoordinatorOptions,
  23. } from './coverage-partitions.ts'
  24. import {
  25. END_OF_LINE_COLUMN,
  26. canonicalizeEndOfLineColumns,
  27. } from './coverage-canonical-locations.ts'
  28. const passed: CoverageCommandResult = { exitCode: 0, signalCode: null }
  29. /** Every temporary root created by this file, removed after each test. */
  30. const roots: string[] = []
  31. afterEach(async () => {
  32. vi.restoreAllMocks()
  33. for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
  34. })
  35. async function writeBlob(command: CoverageCommand): Promise<void> {
  36. if (command.blobPath === undefined) return
  37. await mkdir(dirname(command.blobPath), { recursive: true })
  38. await writeFile(command.blobPath, '{}')
  39. }
  40. async function temporaryRoot(): Promise<string> {
  41. const root = await mkdtemp(join(tmpdir(), 'dsh-coverage-partitions-'))
  42. roots.push(root)
  43. return root
  44. }
  45. /** Write a Vitest results cache under a temporary root. */
  46. async function writeVitestCache(root: string, entries: Array<[string, { duration: number }]>): Promise<void> {
  47. const cacheDir = join(root, 'node_modules/.vite/vitest/cache-hash')
  48. await mkdir(cacheDir, { recursive: true })
  49. await writeFile(join(cacheDir, 'results.json'), JSON.stringify({ version: '4.1.8', results: entries }))
  50. }
  51. /** Run the coordinator and capture every partition config's source. */
  52. async function runCoordinatorReadingConfigs(
  53. root: string,
  54. options: Omit<CoveragePartitionCoordinatorOptions, 'root' | 'pnpmEntrypoint' | 'runCommand'>,
  55. ): Promise<Map<string, string>> {
  56. const configContents = new Map<string, string>()
  57. const runCommand = vi.fn(async (command: CoverageCommand) => {
  58. const configArgument = command.args.find(argument => argument.startsWith('--config='))
  59. if (configArgument !== undefined) {
  60. configContents.set(command.label, await readFile(join(root, configArgument.slice('--config='.length)), 'utf8'))
  61. }
  62. await writeBlob(command)
  63. return passed
  64. })
  65. const coordinator = new CoveragePartitionCoordinator({
  66. root,
  67. pnpmEntrypoint: '/pnpm.cjs',
  68. runCommand,
  69. ...options,
  70. })
  71. await expect(coordinator.run()).resolves.toBe(0)
  72. return configContents
  73. }
  74. function successfulCommandRecorder(commands: CoverageCommand[]) {
  75. return vi.fn(async (command: CoverageCommand) => {
  76. commands.push(command)
  77. await writeBlob(command)
  78. return passed
  79. })
  80. }
  81. describe('coverage partition count', () => {
  82. it.each([
  83. [undefined, undefined],
  84. ['', undefined],
  85. ['2', 2],
  86. ['3', 3],
  87. ])('parses %j as %j', (raw, expected) => {
  88. expect(parseCoveragePartitionCount(raw)).toBe(expected)
  89. })
  90. it.each(['0', '1', '2.5', '02', 'many'])('rejects %j', (raw) => {
  91. expect(() => parseCoveragePartitionCount(raw))
  92. .toThrow(`${COVERAGE_PARTITIONS_ENV} must be an integer greater than 1`)
  93. })
  94. })
  95. describe('coverage partition timeout', () => {
  96. it('applies one configured timeout to tests, polling, and hooks', () => {
  97. expect(coverageTestTimeoutArgs('30000')).toEqual([
  98. '--testTimeout=30000',
  99. '--expect.poll.timeout=30000',
  100. '--hookTimeout=30000',
  101. ])
  102. })
  103. it('keeps Vitest defaults when the timeout is absent', () => {
  104. expect(coverageTestTimeoutArgs(undefined)).toEqual([])
  105. })
  106. it('rejects invalid timeout input', () => {
  107. expect(() => coverageTestTimeoutArgs('0'))
  108. .toThrow(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer`)
  109. })
  110. })
  111. describe('coverage forwarded arguments', () => {
  112. it('removes one package-script separator', () => {
  113. expect(forwardedCoverageArgs(['--', 'scripts/example.spec.ts'])).toEqual(['scripts/example.spec.ts'])
  114. })
  115. it('preserves direct arguments and a subsequent Vitest separator', () => {
  116. expect(forwardedCoverageArgs(['--testNamePattern=example'])).toEqual(['--testNamePattern=example'])
  117. expect(forwardedCoverageArgs(['--', '--', 'example'])).toEqual(['--', 'example'])
  118. })
  119. })
  120. describe('weighted partition assignment', () => {
  121. it('seeds the heaviest files into different partitions', () => {
  122. const weights = new Map<string, number>([
  123. ['packages/a/tests/heavy-1.spec.ts', 100],
  124. ['packages/a/tests/heavy-2.spec.ts', 90],
  125. ['packages/a/tests/heavy-3.spec.ts', 80],
  126. ['packages/a/tests/light.spec.ts', 1],
  127. ])
  128. const buckets = assignWeightedPartitions([...weights.keys()], weights, 3)
  129. expect(buckets).toHaveLength(3)
  130. for (const bucket of buckets) {
  131. expect(bucket).not.toHaveLength(0)
  132. expect(bucket.filter(file => file.includes('heavy'))).toHaveLength(1)
  133. }
  134. })
  135. it('balances total weight across partitions', () => {
  136. const files = Array.from({ length: 20 }, (_, index) => `packages/a/tests/file-${index}.spec.ts`)
  137. const weights = new Map(files.map((file, index) => [file, (index % 7) + 1]))
  138. const buckets = assignWeightedPartitions(files, weights, 4)
  139. const sums = buckets.map(bucket => bucket.reduce((sum, file) => sum + (weights.get(file) ?? 0), 0))
  140. const spread = Math.max(...sums) - Math.min(...sums)
  141. expect(spread).toBeLessThanOrEqual(7)
  142. })
  143. it('steers assignment by weight, not by file count', () => {
  144. // Weight-aware LPT balances three buckets to 1500 each. A file-count-only
  145. // rule pairs the heaviest file with the fourth (1700), so the assertion
  146. // only passes when recorded weights steer the assignment.
  147. const weights = new Map<string, number>([
  148. ['a.spec.ts', 1000],
  149. ['b.spec.ts', 900],
  150. ['c.spec.ts', 800],
  151. ['d.spec.ts', 700],
  152. ['e.spec.ts', 600],
  153. ['f.spec.ts', 500],
  154. ])
  155. const buckets = assignWeightedPartitions([...weights.keys()], weights, 3)
  156. const sums = buckets.map(bucket => bucket.reduce((sum, file) => sum + (weights.get(file) ?? 0), 0))
  157. expect(Math.max(...sums)).toBeLessThanOrEqual(1550)
  158. })
  159. it('leaves trailing empty buckets when files are scarce', () => {
  160. const files = ['a.spec.ts', 'b.spec.ts']
  161. const buckets = assignWeightedPartitions(files, new Map(), 4)
  162. expect(buckets.map(bucket => bucket.length).sort()).toEqual([0, 0, 1, 1])
  163. })
  164. it('returns one empty bucket per partition for an empty inventory', () => {
  165. expect(assignWeightedPartitions([], new Map(), 3)).toEqual([[], [], []])
  166. })
  167. it('assigns unknown-weight files evenly', () => {
  168. const files = ['a.spec.ts', 'b.spec.ts', 'c.spec.ts', 'd.spec.ts']
  169. const buckets = assignWeightedPartitions(files, new Map(), 2)
  170. expect(buckets.map(bucket => bucket.length).sort()).toEqual([2, 2])
  171. })
  172. })
  173. describe('coverage file inventory', () => {
  174. it('parses vitest list --filesOnly output, keeps project ownership, and drops exempt suites', async () => {
  175. const root = await temporaryRoot()
  176. const exemptDir = join(root, 'packages/experimental/webworker-runtime/tests')
  177. await mkdir(exemptDir, { recursive: true })
  178. await writeFile(join(exemptDir, 'transform-corpus.spec.ts'), '')
  179. const output = [
  180. '[thread-safe] packages/a/tests/a.spec.ts',
  181. '[process-bound] packages/b/tests/b.spec.ts',
  182. '[thread-safe] packages/experimental/webworker-runtime/tests/transform-corpus.spec.ts',
  183. 'not a test line',
  184. ].join('\n')
  185. const inventory = parseListOutput(output, root)
  186. expect(inventory.files).toEqual([
  187. 'packages/a/tests/a.spec.ts',
  188. 'packages/b/tests/b.spec.ts',
  189. ])
  190. expect(inventory.projectOf.get('packages/a/tests/a.spec.ts')).toBe('thread-safe')
  191. expect(inventory.projectOf.get('packages/b/tests/b.spec.ts')).toBe('process-bound')
  192. })
  193. it('removes every Typert package from instrumented files and project assignments', async () => {
  194. const root = await temporaryRoot()
  195. const typertFiles = [
  196. 'packages/typert/generator/tests/type-model.spec.ts',
  197. 'packages/typert/loader/tests/loader.spec.ts',
  198. 'packages/typert/protocol/tests/protocol.spec.ts',
  199. 'packages/typert/registry/tests/typert.spec.ts',
  200. 'packages/typert/future/tests/nested/client.spec.tsx',
  201. ]
  202. for (const file of typertFiles) {
  203. await mkdir(dirname(join(root, file)), { recursive: true })
  204. await writeFile(join(root, file), '')
  205. }
  206. const retained = 'packages/api/gateway/tests/rpc.spec.ts'
  207. const inventory = parseListOutput(
  208. [...typertFiles, retained].map(file => `[thread-safe] ${file}`).join('\n'),
  209. root,
  210. )
  211. expect(inventory.files).toEqual([retained])
  212. expect([...inventory.projectOf]).toEqual([[retained, 'thread-safe']])
  213. })
  214. it('averages recorded durations per file from the results cache', async () => {
  215. const root = await temporaryRoot()
  216. await writeVitestCache(root, [
  217. ['thread-safe:packages/a/tests/x.spec.ts', { duration: 10 }],
  218. ['process-bound:packages/a/tests/x.spec.ts', { duration: 30 }],
  219. ['thread-safe:packages/a/tests/y.spec.ts', { duration: 5 }],
  220. ])
  221. const durations = readFileDurations(root)
  222. expect(durations.get('packages/a/tests/x.spec.ts')).toBe(20)
  223. expect(durations.get('packages/a/tests/y.spec.ts')).toBe(5)
  224. })
  225. it('prefers the persisted duration file over the vitest cache', async () => {
  226. const root = await temporaryRoot()
  227. await writeVitestCache(root, [['thread-safe:packages/a/tests/x.spec.ts', { duration: 100 }]])
  228. writeFileDurations(root, new Map([['packages/a/tests/x.spec.ts', 42]]))
  229. expect(readFileDurations(root).get('packages/a/tests/x.spec.ts')).toBe(42)
  230. })
  231. it('merges new durations into the persisted file', async () => {
  232. const root = await temporaryRoot()
  233. writeFileDurations(root, new Map([['packages/a/tests/x.spec.ts', 42]]))
  234. writeFileDurations(root, new Map([
  235. ['packages/a/tests/x.spec.ts', 55],
  236. ['packages/a/tests/y.spec.ts', 7],
  237. ]))
  238. const durations = readFileDurations(root)
  239. expect(durations.get('packages/a/tests/x.spec.ts')).toBe(55)
  240. expect(durations.get('packages/a/tests/y.spec.ts')).toBe(7)
  241. })
  242. it('extracts per-file durations from partition json reports', async () => {
  243. const root = await temporaryRoot()
  244. const report = join(root, 'partition-1.report.json')
  245. await writeFile(report, JSON.stringify({
  246. testResults: [
  247. { name: join(root, 'packages/a/tests/x.spec.ts'), startTime: 1000, endTime: 1500 },
  248. { name: 'not-a-spec', startTime: 1, endTime: 2 },
  249. ],
  250. }))
  251. const durations = collectPartitionDurations([report], root)
  252. expect(durations.get('packages/a/tests/x.spec.ts')).toBe(500)
  253. })
  254. })
  255. /**
  256. * One source statement as two Vite environments spell it: the ssr map ends the
  257. * declaration at its identifier, the jsdom/client map at the nested call, and
  258. * both stop at their own line's end.
  259. */
  260. const CRASH_LOOP = { start: { line: 36, column: 2 }, end: { line: 54, column: Infinity } }
  261. /** File path the canonicalization fixtures share. */
  262. const CANONICALIZED_FILE = 'packages/util/home-paths/src/index.ts'
  263. /** Istanbul statement location in the fixture's own terms. */
  264. interface StatementLocation {
  265. start: { line: number; column: number }
  266. end: { line: number; column: number }
  267. }
  268. /** Statement hits and locations read back from a merged istanbul map. */
  269. interface MergedStatements {
  270. s: Record<string, number>
  271. statementMap: Record<string, StatementLocation>
  272. }
  273. /** The istanbul entry points the merge assertions use. */
  274. interface CoverageLibrary {
  275. createCoverageMap: (data: unknown) => {
  276. merge: (data: unknown) => void
  277. fileCoverageFor: (file: string) => MergedStatements
  278. }
  279. }
  280. /**
  281. * istanbul-lib-coverage is the merge implementation the partition blobs feed.
  282. * It arrives as a dependency of the declared istanbul-lib-report devDependency,
  283. * so this spec resolves it through that owner instead of declaring its own copy.
  284. */
  285. const coverageLibrary = createRequire(
  286. createRequire(import.meta.url).resolve('istanbul-lib-report'),
  287. )('istanbul-lib-coverage') as CoverageLibrary
  288. /** One file's statement coverage, shaped as a partition blob carries it. */
  289. function statementRecord(statements: StatementLocation[], hits: number[]): Record<string, unknown> {
  290. return {
  291. [CANONICALIZED_FILE]: {
  292. path: CANONICALIZED_FILE,
  293. statementMap: Object.fromEntries(statements.map((location, index) => [index, location])),
  294. s: Object.fromEntries(hits.map((hit, index) => [index, hit])),
  295. fnMap: {},
  296. f: {},
  297. branchMap: {},
  298. b: {},
  299. },
  300. }
  301. }
  302. /**
  303. * Merge partition records the way the coordinator's merge command does. The
  304. * blob serializer's JSON hop turns a non-finite end column into `null`, which
  305. * is what strips istanbul of the range containment it reconciles records with.
  306. * Canonicalization runs on the `CoverageMap` the reporter hook receives, whose
  307. * `data` holds the raw per-file records merged here.
  308. */
  309. function mergePartitionRecords(
  310. records: Array<Record<string, unknown>>,
  311. canonicalize: boolean,
  312. ): MergedStatements {
  313. const map = coverageLibrary.createCoverageMap({})
  314. for (const record of records) {
  315. if (canonicalize) canonicalizeEndOfLineColumns({ data: record })
  316. map.merge(JSON.parse(JSON.stringify(record)) as unknown)
  317. }
  318. return map.fileCoverageFor(CANONICALIZED_FILE)
  319. }
  320. /** Start positions of the statements the merged map still reports as unhit. */
  321. function uncoveredStarts(coverage: MergedStatements): Array<{ line: number; column: number }> {
  322. return Object.entries(coverage.s)
  323. .filter(([, hits]) => hits === 0)
  324. .map(([index]) => coverage.statementMap[index])
  325. .filter((location): location is StatementLocation => location !== undefined)
  326. .map(location => location.start)
  327. }
  328. describe('coverage location canonicalization', () => {
  329. // index.ts:48 as the ssr environment spells it (at `parent`) and as the
  330. // client environment spells it (at `dirname(current)`); the loop holding
  331. // both spellings ran in each, and only the client record never took the
  332. // catch branch that owns the statement.
  333. const ssrRecord = statementRecord(
  334. [CRASH_LOOP, { start: { line: 48, column: 12 }, end: { line: 48, column: Infinity } }],
  335. [3, 2],
  336. )
  337. const clientRecord = statementRecord(
  338. [CRASH_LOOP, { start: { line: 48, column: 21 }, end: { line: 48, column: Infinity } }],
  339. [5, 0],
  340. )
  341. it('drops the phantom statement a client-only spelling leaves after the blob merge', () => {
  342. // 33 statements, 32 covered: the ssr spelling is hit while the client
  343. // spelling of the same source statement stays unhit, so a file whose every
  344. // statement ran fails the per-file 100% gate.
  345. expect(uncoveredStarts(mergePartitionRecords([ssrRecord, clientRecord], false)))
  346. .toEqual([{ line: 48, column: 21 }])
  347. expect(uncoveredStarts(mergePartitionRecords([ssrRecord, clientRecord], true))).toEqual([])
  348. })
  349. it('canonicalizes line-end columns in statement, function, and branch locations', () => {
  350. const lineEnd = (line: number): StatementLocation => ({
  351. start: { line, column: 4 },
  352. end: { line, column: Infinity },
  353. })
  354. const record = {
  355. [CANONICALIZED_FILE]: {
  356. path: CANONICALIZED_FILE,
  357. statementMap: { 0: lineEnd(10), 1: { start: { line: 11, column: 4 }, end: { line: 11, column: 9 } } },
  358. s: { 0: 1, 1: 1 },
  359. fnMap: { 0: { name: 'probe', decl: lineEnd(10), loc: lineEnd(10) } },
  360. f: { 0: 1 },
  361. branchMap: { 0: { type: 'if', loc: lineEnd(12), locations: [lineEnd(12), lineEnd(13)] } },
  362. b: { 0: [1, 1] },
  363. },
  364. }
  365. canonicalizeEndOfLineColumns({ data: record })
  366. const file = record[CANONICALIZED_FILE]
  367. expect(file.statementMap[0]?.end.column).toBe(END_OF_LINE_COLUMN)
  368. expect(file.statementMap[1]?.end.column).toBe(9)
  369. expect(file.fnMap[0]?.decl.end.column).toBe(END_OF_LINE_COLUMN)
  370. expect(file.fnMap[0]?.loc.end.column).toBe(END_OF_LINE_COLUMN)
  371. expect(file.branchMap[0]?.loc.end.column).toBe(END_OF_LINE_COLUMN)
  372. expect(file.branchMap[0]?.locations[0]?.end.column).toBe(END_OF_LINE_COLUMN)
  373. expect(file.branchMap[0]?.locations[1]?.end.column).toBe(END_OF_LINE_COLUMN)
  374. })
  375. it('rejects a payload that carries no istanbul coverage data', () => {
  376. // Vitest types the reporter hook as `unknown`, so a payload without the
  377. // istanbul `data` record must fail the partition loudly rather than leave
  378. // every location uncanonicalized.
  379. expect(() => {
  380. canonicalizeEndOfLineColumns(undefined)
  381. }).toThrow(/not an istanbul CoverageMap/)
  382. expect(() => {
  383. canonicalizeEndOfLineColumns({})
  384. }).toThrow(/not an istanbul CoverageMap/)
  385. })
  386. })
  387. describe('coverage partition coordinator', () => {
  388. const weightedFiles = ['a.spec.ts', 'b.spec.ts', 'c.spec.ts']
  389. const weightedDurations = new Map([
  390. ['a.spec.ts', 100],
  391. ['b.spec.ts', 50],
  392. ['c.spec.ts', 10],
  393. ])
  394. const weightedProjects = new Map([
  395. ['a.spec.ts', 'thread-safe'],
  396. ['b.spec.ts', 'process-bound'],
  397. ['c.spec.ts', 'process-bound'],
  398. ])
  399. it('passes the canonicalizing reporter to every partition', async () => {
  400. const root = await temporaryRoot()
  401. const commands: CoverageCommand[] = []
  402. const runCommand = successfulCommandRecorder(commands)
  403. const coordinator = new CoveragePartitionCoordinator({
  404. root,
  405. partitions: 2,
  406. pnpmEntrypoint: '/pnpm.cjs',
  407. files: ['a.spec.ts', 'b.spec.ts'],
  408. runCommand,
  409. })
  410. await expect(coordinator.run()).resolves.toBe(0)
  411. // The coordinator passes a root-relative argument and runs every child with
  412. // the repository root as its working directory, so the argument must name
  413. // the reporter that sits beside this spec. Whether that reporter runs
  414. // before the blob write is Vitest's reporter ordering, not a command the
  415. // coordinator builds.
  416. const specDirectory = dirname(fileURLToPath(import.meta.url))
  417. const reporterPath = join(specDirectory, 'coverage-canonical-locations.ts')
  418. for (const command of commands.slice(0, 2)) {
  419. const argument = command.args.find(candidate => candidate.startsWith('--reporter=') && candidate.endsWith('coverage-canonical-locations.ts'))
  420. if (argument === undefined) throw new Error(`${command.label} does not wire the coverage canonicalizer`)
  421. expect(resolve(specDirectory, '..', argument.slice('--reporter='.length))).toBe(reporterPath)
  422. }
  423. })
  424. it('runs every single-worker partition before one merged threshold check', async () => {
  425. const root = await temporaryRoot()
  426. const commands: CoverageCommand[] = []
  427. const partitionConfigs: string[] = []
  428. const runCommand = vi.fn(async (command: CoverageCommand) => {
  429. commands.push(command)
  430. const configArgument = command.args.find(argument => argument.startsWith('--config='))
  431. if (configArgument !== undefined) {
  432. partitionConfigs.push(await readFile(join(root, configArgument.slice('--config='.length)), 'utf8'))
  433. }
  434. await writeBlob(command)
  435. return passed
  436. })
  437. const coordinator = new CoveragePartitionCoordinator({
  438. root,
  439. partitions: 3,
  440. pnpmEntrypoint: '/pnpm.cjs',
  441. vitestArgs: ['--testTimeout=30000'],
  442. files: ['a.spec.ts', 'b.spec.ts', 'c.spec.ts'],
  443. runCommand,
  444. })
  445. await expect(coordinator.run()).resolves.toBe(0)
  446. expect(commands.map(command => command.label)).toEqual([
  447. 'partition 1/3',
  448. 'partition 2/3',
  449. 'partition 3/3',
  450. 'merged coverage report',
  451. ])
  452. for (const command of commands.slice(0, 3)) {
  453. expect(command.command).toBe(process.execPath)
  454. expect(command.args[0]).toBe('/pnpm.cjs')
  455. expect(command.args).toEqual(expect.arrayContaining([
  456. '--coverage',
  457. '--coverage.reportOnFailure',
  458. '--maxWorkers=1',
  459. '--reporter=default',
  460. '--reporter=blob',
  461. '--reporter=json',
  462. '--testTimeout=30000',
  463. ]))
  464. expect(command.args).not.toContain('--shard=1/3')
  465. expect(command.args.some(argument => argument.startsWith('--config='))).toBe(true)
  466. expect(command.env).toEqual({
  467. [COVERAGE_PARTITIONS_ENV]: undefined,
  468. [COVERAGE_PARTITION_MODE_ENV]: '1',
  469. })
  470. }
  471. // The partition file list travels in a temporary config, not on the
  472. // command line (which exceeds the Windows CreateProcess limit).
  473. expect(partitionConfigs).toHaveLength(3)
  474. const allConfigs = partitionConfigs.join('\n')
  475. expect(allConfigs).toContain('a.spec.ts')
  476. expect(allConfigs).toContain('b.spec.ts')
  477. expect(allConfigs).toContain('c.spec.ts')
  478. for (const source of partitionConfigs) {
  479. expect(source).toContain("from '../../vitest.config.ts'")
  480. }
  481. const mergeCommand = commands[3]
  482. if (mergeCommand === undefined) throw new Error('coverage merge command was not observed')
  483. expect(mergeCommand.args).toContain('--coverage')
  484. expect(mergeCommand.args.some(argument => argument.startsWith('--merge-reports='))).toBe(true)
  485. expect(mergeCommand.env).toEqual({
  486. [COVERAGE_PARTITIONS_ENV]: undefined,
  487. [COVERAGE_PARTITION_MODE_ENV]: undefined,
  488. })
  489. })
  490. it('rejects an empty partition assignment before spawning any command', async () => {
  491. const root = await temporaryRoot()
  492. const runCommand = vi.fn()
  493. const coordinator = new CoveragePartitionCoordinator({
  494. root,
  495. partitions: 3,
  496. pnpmEntrypoint: '/pnpm.cjs',
  497. // One file for three partitions leaves two buckets empty; an empty
  498. // bucket would make Vitest run the whole suite.
  499. files: ['a.spec.ts'],
  500. runCommand,
  501. })
  502. await expect(coordinator.run()).rejects.toThrow('partition 2/3 has no files')
  503. expect(runCommand).not.toHaveBeenCalled()
  504. })
  505. it('starts the heaviest partition first for fail-fast', async () => {
  506. const root = await temporaryRoot()
  507. const configContents = await runCoordinatorReadingConfigs(root, {
  508. partitions: 2,
  509. files: weightedFiles,
  510. weights: weightedDurations,
  511. })
  512. // LPT: p1=[a] sum 100, p2=[b,c] sum 60; sorted heaviest-first makes
  513. // partition 1/2 the heavy one, so its config names a.spec.ts.
  514. expect(configContents.get('partition 1/2')).toContain('a.spec.ts')
  515. expect(configContents.get('partition 1/2')).not.toContain('b.spec.ts')
  516. })
  517. it('gives each project only its own files in the partition config', async () => {
  518. const root = await temporaryRoot()
  519. const configContents = await runCoordinatorReadingConfigs(root, {
  520. partitions: 2,
  521. files: weightedFiles,
  522. weights: weightedDurations,
  523. projectOf: weightedProjects,
  524. })
  525. const allConfigs = [...configContents.values()].join('\n')
  526. // The process-bound project must not receive the thread-safe file and
  527. // vice versa, or plain files would run twice.
  528. expect(allConfigs).toContain("include: project.test.name === 'process-bound' ?")
  529. expect(allConfigs).not.toContain('"a.spec.ts","b.spec.ts","c.spec.ts"')
  530. })
  531. it('runs a native pnpm entrypoint directly', async () => {
  532. const root = await temporaryRoot()
  533. const commands: CoverageCommand[] = []
  534. const runCommand = successfulCommandRecorder(commands)
  535. const coordinator = new CoveragePartitionCoordinator({
  536. root,
  537. partitions: 2,
  538. pnpmEntrypoint: '/tools/pnpm',
  539. files: ['a.spec.ts', 'b.spec.ts'],
  540. runCommand,
  541. })
  542. await expect(coordinator.run()).resolves.toBe(0)
  543. expect(commands).toHaveLength(3)
  544. for (const command of commands) {
  545. expect(command.command).toBe('/tools/pnpm')
  546. expect(command.args[0]).toBe('exec')
  547. }
  548. })
  549. it('merges normal test failures and returns their failed status', async () => {
  550. const root = await temporaryRoot()
  551. const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  552. const runCommand = vi.fn(async (command: CoverageCommand) => {
  553. await writeBlob(command)
  554. return command.label === 'partition 2/2'
  555. ? { exitCode: 1, signalCode: null, outputTail: 'specific Vitest failure' }
  556. : passed
  557. })
  558. const coordinator = new CoveragePartitionCoordinator({
  559. root,
  560. partitions: 2,
  561. pnpmEntrypoint: '/pnpm.cjs',
  562. files: ['a.spec.ts', 'b.spec.ts'],
  563. runCommand,
  564. })
  565. await expect(coordinator.run()).resolves.toBe(1)
  566. expect(reported).toHaveBeenCalledWith('coverage-partitions: FAIL partition 2/2 (exit 1)')
  567. expect(reported).toHaveBeenCalledWith(
  568. 'coverage-partitions: output tail for partition 2/2:\nspecific Vitest failure',
  569. )
  570. expect(runCommand).toHaveBeenCalledTimes(3)
  571. })
  572. it('rejects a missing partition blob before merge', async () => {
  573. const root = await temporaryRoot()
  574. const runCommand = vi.fn(async (command: CoverageCommand) => {
  575. if (command.label !== 'partition 2/2') await writeBlob(command)
  576. return passed
  577. })
  578. const coordinator = new CoveragePartitionCoordinator({
  579. root,
  580. partitions: 2,
  581. pnpmEntrypoint: '/pnpm.cjs',
  582. files: ['a.spec.ts', 'b.spec.ts'],
  583. runCommand,
  584. })
  585. await expect(coordinator.run()).rejects.toThrow('coverage partitions produced')
  586. expect(runCommand).toHaveBeenCalledTimes(2)
  587. })
  588. it('reports signal termination before missing-blob validation', async () => {
  589. const root = await temporaryRoot()
  590. const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  591. const runCommand = vi.fn(async (command: CoverageCommand) => {
  592. if (command.label === 'partition 1/2') await writeBlob(command)
  593. return command.label === 'partition 2/2'
  594. ? { exitCode: null, signalCode: 'SIGTERM' as const }
  595. : passed
  596. })
  597. const coordinator = new CoveragePartitionCoordinator({
  598. root,
  599. partitions: 2,
  600. pnpmEntrypoint: '/pnpm.cjs',
  601. files: ['a.spec.ts', 'b.spec.ts'],
  602. runCommand,
  603. })
  604. await expect(coordinator.run()).rejects.toThrow('coverage partitions produced')
  605. expect(reported).toHaveBeenCalledWith('coverage-partitions: FAIL partition 2/2 (signal SIGTERM)')
  606. })
  607. it('waits for every partition after one spawn failure', async () => {
  608. const root = await temporaryRoot()
  609. const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  610. let secondFinished = false
  611. const runCommand = vi.fn(async (command: CoverageCommand) => {
  612. await writeBlob(command)
  613. if (command.label === 'partition 1/2') {
  614. return { exitCode: null, signalCode: null, error: 'spawn unavailable' }
  615. }
  616. if (command.label === 'partition 2/2') secondFinished = true
  617. return passed
  618. })
  619. const coordinator = new CoveragePartitionCoordinator({
  620. root,
  621. partitions: 2,
  622. pnpmEntrypoint: '/pnpm.cjs',
  623. files: ['a.spec.ts', 'b.spec.ts'],
  624. runCommand,
  625. })
  626. await expect(coordinator.run()).resolves.toBe(1)
  627. expect(reported).toHaveBeenCalledWith('coverage-partitions: FAIL partition 1/2 (spawn unavailable)')
  628. expect(secondFinished).toBe(true)
  629. expect(runCommand).toHaveBeenCalledTimes(3)
  630. })
  631. it('unlinks a link-shaped coverage path without touching its target', async () => {
  632. const root = await temporaryRoot()
  633. const target = await temporaryRoot()
  634. const marker = join(target, 'marker.txt')
  635. await writeFile(marker, 'owned elsewhere')
  636. await symlink(target, join(root, 'coverage'), process.platform === 'win32' ? 'junction' : 'dir')
  637. const runCommand = vi.fn(async (command: CoverageCommand) => {
  638. await writeBlob(command)
  639. return passed
  640. })
  641. const coordinator = new CoveragePartitionCoordinator({
  642. root,
  643. partitions: 2,
  644. pnpmEntrypoint: '/pnpm.cjs',
  645. files: ['a.spec.ts', 'b.spec.ts'],
  646. runCommand,
  647. })
  648. await expect(coordinator.run()).resolves.toBe(0)
  649. await expect(access(marker)).resolves.toBeUndefined()
  650. })
  651. })