run-gates.spec.ts 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029
  1. import { readFileSync } from 'node:fs'
  2. import { describe, expect, it, vi, type MockInstance } from 'vitest'
  3. import {
  4. cliGateOptions,
  5. collectDescendants,
  6. defaultConcurrency,
  7. formatGateResultReason,
  8. gatesForMode,
  9. parsePidPpidLines,
  10. runGate,
  11. runGates,
  12. taskkillArgs,
  13. type Gate,
  14. type GateResult,
  15. } from './run-gates.ts'
  16. /**
  17. * Capture output a gate streams through runGate's streamOutput path.
  18. * @returns the accumulated chunks and the stdout spy to restore in finally.
  19. */
  20. function captureStreamedOutput(): { writes: string[]; write: MockInstance } {
  21. const writes: string[] = []
  22. const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
  23. writes.push(String(chunk))
  24. return true
  25. })
  26. return { writes, write }
  27. }
  28. /**
  29. * A process has stopped executing when its /proc entry is gone, or when it
  30. * lingers as a zombie ('Z') — an un-reaped but dead entry still answers
  31. * kill(pid, 0), so existence is not a liveness check. Non-Linux falls back to
  32. * kill(pid, 0), whose ESRCH means the process is gone.
  33. */
  34. function procStopped(pid: number): boolean {
  35. if (process.platform === 'linux') {
  36. try {
  37. const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
  38. return /\)\s+Z\s/.test(stat)
  39. } catch {
  40. return true
  41. }
  42. }
  43. try {
  44. process.kill(pid, 0)
  45. return false
  46. } catch {
  47. return true
  48. }
  49. }
  50. /**
  51. * Wait until the captured output contains `marker` and the grandchild pid the
  52. * gate printed, then return that pid.
  53. * @param writes - chunks captured from the gate's streamed stdout.
  54. * @param marker - the output line that proves the gate reached the abort point.
  55. * @param deadline - fail the wait when exceeded.
  56. * @returns the grandchild pid printed by the gate script.
  57. */
  58. async function waitForGrandchildPid(writes: string[], marker: string, deadline: number): Promise<number> {
  59. let pid: number | undefined
  60. while ((pid === undefined || !writes.join('').includes(marker)) && Date.now() < deadline) {
  61. const match = writes.join('').match(/grandchild:(\d+)/)
  62. if (match !== null) pid = Number(match[1])
  63. await new Promise(resolve => setTimeout(resolve, 50))
  64. }
  65. expect(pid ?? 0).toBeGreaterThan(0)
  66. expect(writes.join('')).toContain(marker)
  67. return pid!
  68. }
  69. /**
  70. * Abort the run and assert it settles marked aborted with the grandchild no
  71. * longer executing — the abort path must have signalled it from the captured
  72. * descendant list rather than settling over a live orphan.
  73. * @param promise - the pending `runGate` promise.
  74. * @param controller - the signal source to abort.
  75. * @param pid - the grandchild pid the gate script printed.
  76. */
  77. async function abortAndExpectTreeStopped(promise: Promise<GateResult>, controller: AbortController, pid: number): Promise<void> {
  78. controller.abort()
  79. const result = await promise
  80. expect(result.aborted).toBe(true)
  81. const stopDeadline = Date.now() + 8000
  82. while (!procStopped(pid) && Date.now() < stopDeadline) {
  83. await new Promise(resolve => setTimeout(resolve, 50))
  84. }
  85. expect(procStopped(pid)).toBe(true)
  86. }
  87. function gate(id: string, options: Partial<Gate> = {}): Gate {
  88. return {
  89. id,
  90. label: id,
  91. displayCommand: `run ${id}`,
  92. command: process.execPath,
  93. args: ['-e', ''],
  94. ...options,
  95. }
  96. }
  97. function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): GateResult {
  98. return {
  99. gate: subject,
  100. status,
  101. durationMs: 10,
  102. output: [],
  103. exitCode: status === 'passed' ? 0 : 1,
  104. signalCode: null,
  105. }
  106. }
  107. function withPnpmEntrypoint<T>(action: () => T, entrypoint = '/private/pnpm.cjs'): T {
  108. const previous = process.env.npm_execpath
  109. process.env.npm_execpath = entrypoint
  110. try {
  111. return action()
  112. } finally {
  113. if (previous === undefined) Reflect.deleteProperty(process.env, 'npm_execpath')
  114. else process.env.npm_execpath = previous
  115. }
  116. }
  117. function withEnv<T>(name: string, value: string | undefined, action: () => T): T {
  118. const previous = process.env[name]
  119. if (value === undefined) Reflect.deleteProperty(process.env, name)
  120. else process.env[name] = value
  121. try {
  122. return action()
  123. } finally {
  124. if (previous === undefined) Reflect.deleteProperty(process.env, name)
  125. else process.env[name] = previous
  126. }
  127. }
  128. describe('gate graph validation', () => {
  129. it.each([
  130. 'ci-primary',
  131. 'ci-linux-primary',
  132. 'ci-static',
  133. 'ci-lint-contracts-ready',
  134. 'ci-coverage',
  135. 'ci-bench',
  136. 'ci-snapshot',
  137. 'ci-artifacts',
  138. 'ci-consumers',
  139. 'ci-windows-blocking',
  140. 'ci-windows-complete',
  141. 'ci-windows-observational',
  142. 'node-compat',
  143. 'check-all',
  144. 'hygiene',
  145. 'doc-sync',
  146. 'doc-quick',
  147. ] as const)('constructs and executes preflight for a valid non-empty %s graph', async (mode) => {
  148. const subject = withPnpmEntrypoint(() => gatesForMode(mode))
  149. const execute = vi.fn(async (item: Gate) => resultFor(item))
  150. await expect(runGates(subject, subject.length, execute)).resolves.toHaveLength(subject.length)
  151. })
  152. it('builds the native addon before benchmarks through the ci-bench script chain', () => {
  153. const subject = withPnpmEntrypoint(() => gatesForMode('ci-bench'))
  154. const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
  155. scripts: Record<string, string>
  156. }
  157. expect(scripts['check:ci:bench']).toBe('tsx scripts/run-gates.ts ci-bench')
  158. expect(subject).toHaveLength(1)
  159. expect(subject[0]).toMatchObject({
  160. id: 'bench',
  161. displayCommand: 'pnpm run test:bench',
  162. args: ['/private/pnpm.cjs', 'run', 'test:bench'],
  163. })
  164. expect(scripts['test:bench']).toBe('npm run build:bench && npm run build:web && npm run test:bench:built')
  165. expect(scripts['build:bench']).toBe(
  166. 'npm run build:native-system && npm run build:lib && tsdown --config benchmarks/tsdown.config.ts',
  167. )
  168. expect(scripts['build:native-system']).toBe('tsx native/system/scripts/build.ts --host-addon-only')
  169. expect(scripts['test:bench:built']).toBe('vitest run --config vitest.bench.config.ts')
  170. })
  171. it('checks all maintained repository references locally and in CI', () => {
  172. const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
  173. scripts: Record<string, string>
  174. }
  175. for (const mode of ['doc-sync', 'doc-quick', 'ci-static'] as const) {
  176. const gates = withPnpmEntrypoint(() => gatesForMode(mode))
  177. expect(gates).toContainEqual(expect.objectContaining({
  178. id: 'repository-references',
  179. displayCommand: 'pnpm run verify-repository-references',
  180. }))
  181. }
  182. expect(scripts['verify-repository-references']).toBe('tsx scripts/verify-repository-references.ts')
  183. })
  184. it('keeps the public repository link policy in the documentation gate', () => {
  185. const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
  186. expect(ids).toContain('public-repository-links')
  187. })
  188. it('keeps the concrete terminology policy in the documentation gate', () => {
  189. const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
  190. expect(ids).toContain('concrete-terms')
  191. })
  192. it('checks retrospective releases alongside the current persistence history', () => {
  193. for (const mode of ['doc-sync', 'ci-static'] as const) {
  194. const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
  195. expect(ids).toEqual(expect.arrayContaining(['persistence-changes', 'persistence-releases']))
  196. }
  197. })
  198. it('requires complete Session format references locally and in CI', () => {
  199. const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
  200. scripts: Record<string, string>
  201. }
  202. for (const mode of ['doc-sync', 'doc-quick', 'ci-static'] as const) {
  203. expect(withPnpmEntrypoint(() => gatesForMode(mode))).toContainEqual(expect.objectContaining({
  204. id: 'persistence-formats',
  205. displayCommand: 'pnpm run verify-persistence-formats',
  206. }))
  207. }
  208. expect(scripts['verify-persistence-formats']).toBe('tsx scripts/persistence-formats.ts')
  209. })
  210. it('keeps package-group subsystem ownership in the documentation gate', () => {
  211. const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
  212. expect(ids).toContain('subsystem-pages')
  213. })
  214. it('keeps the package README Summary limit in the documentation gate', () => {
  215. const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
  216. expect(ids).toContain('package-readme-summaries')
  217. })
  218. it('derives the quick documentation aggregate from marked doc-sync leaves', () => {
  219. const full = withPnpmEntrypoint(() => gatesForMode('doc-sync'))
  220. const quick = withPnpmEntrypoint(() => gatesForMode('doc-quick'))
  221. expect(quick).toEqual(full.filter(gate => gate.quick === true))
  222. })
  223. it('checks the recorded npm dependency catalog in both documentation aggregates', () => {
  224. for (const mode of ['doc-sync', 'doc-quick'] as const) {
  225. expect(withPnpmEntrypoint(() => gatesForMode(mode).find(gate => gate.id === 'dependency-catalog')))
  226. .toMatchObject({ args: ['/private/pnpm.cjs', 'run', 'verify-dependency-catalog'] })
  227. }
  228. })
  229. it('keeps the hygiene aggregate aligned with the package script checks', () => {
  230. const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id))
  231. expect(ids).toEqual([
  232. 'rescope-vendor', 'publint', 'constraints', 'default-product-isolation', 'package-dependencies', 'application-entrypoints',
  233. 'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types',
  234. 'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'no-bare-dispatcher', 'cordis-config',
  235. 'runtime-closure',
  236. ])
  237. expect(defaultConcurrency('hygiene', ids.length, 8)).toEqual({
  238. workers: 4,
  239. source: '8 available CPU(s), hygiene cap 4',
  240. })
  241. })
  242. it('schedules the longest documentation leaves before short checks', () => {
  243. const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
  244. expect(ids.slice(0, 11)).toEqual([
  245. 'doc-typecheck', 'docs-site-build', 'doc-graphs', 'markdown-links', 'type-equivalence',
  246. 'cordis-catalog', 'cordis-inspect-catalog', 'workflow-guest', 'mermaid', 'scoped-events', 'translation-pairing',
  247. ])
  248. })
  249. it('launches a native pnpm entrypoint directly', () => {
  250. const entrypoint = String.raw`C:\Program Files\pnpm\pnpm.exe`
  251. const subject = withPnpmEntrypoint(() => gatesForMode('ci-windows-blocking')[0], entrypoint)
  252. expect(subject).toMatchObject({
  253. command: entrypoint,
  254. args: ['run', 'build'],
  255. })
  256. })
  257. it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
  258. 'keeps the DSH package license policy in %s',
  259. (mode) => {
  260. const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
  261. expect(ids).toContain('dsh-package-licenses')
  262. },
  263. )
  264. it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
  265. 'keeps package dependency enforcement in %s',
  266. (mode) => {
  267. const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
  268. expect(ids).toContain('package-dependencies')
  269. },
  270. )
  271. it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
  272. 'executes default-product experimental isolation in %s',
  273. (mode) => {
  274. const gate = withPnpmEntrypoint(() => gatesForMode(mode)
  275. .find(subject => subject.id === 'default-product-isolation'))
  276. expect(gate?.args).toContain('verify-default-product-isolation')
  277. expect(gate?.allowFailure).not.toBe(true)
  278. },
  279. )
  280. it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
  281. 'keeps the client dependency policy in %s',
  282. (mode) => {
  283. const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
  284. expect(ids).toContain('client-packages')
  285. },
  286. )
  287. it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
  288. 'keeps weighted approval policy tests in %s',
  289. (mode) => {
  290. const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
  291. expect(ids).toContain('approval-policy')
  292. },
  293. )
  294. it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
  295. 'keeps hard-coded Client UI copy enforcement in %s',
  296. (mode) => {
  297. const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
  298. expect(ids).toContain('client-ui-i18n')
  299. },
  300. )
  301. it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
  302. 'keeps application entrypoint enforcement in %s',
  303. (mode) => {
  304. const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
  305. expect(ids).toContain('application-entrypoints')
  306. },
  307. )
  308. it('keeps native Windows coverage blocking and behind the complete build', () => {
  309. const complete = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
  310. const observational = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational'))
  311. .filter(gate => gate.id !== 'build' && gate.id !== 'docs-site-build')
  312. const byId = new Map(complete.map(subject => [subject.id, subject]))
  313. expect(byId.get('coverage')?.allowFailure).not.toBe(true)
  314. expect(byId.get('coverage')?.needs).toContain('build')
  315. expect(byId.get('coverage-exempt-heavy')?.allowFailure).not.toBe(true)
  316. expect(byId.get('coverage')?.needs).toContain('build')
  317. expect(byId.get('coverage-exempt-heavy')?.needs).toContain('build')
  318. expect(byId.get('coverage-exempt-heavy')?.args).toContain(
  319. 'packages/experimental/webworker-packer/tests/image-loadable.spec.ts',
  320. )
  321. expect(observational).not.toHaveLength(0)
  322. for (const gate of observational) {
  323. const completeGate = byId.get(gate.id)
  324. expect(completeGate?.allowFailure).toBe(true)
  325. expect(completeGate?.after).toEqual(expect.arrayContaining([
  326. 'coverage',
  327. 'coverage-exempt-heavy',
  328. ]))
  329. expect(completeGate?.needs).toEqual(gate.needs)
  330. }
  331. })
  332. it('runs the Windows built-bin smoke after other observational gates settle', () => {
  333. const observational = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational'))
  334. const builtBin = observational.find(gate => gate.id === 'built-bin-smoke')
  335. expect(builtBin?.after).toEqual(
  336. observational.filter(gate => gate.id !== 'built-bin-smoke').map(gate => gate.id),
  337. )
  338. const completeBuiltBin = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
  339. .find(gate => gate.id === 'built-bin-smoke')
  340. expect(completeBuiltBin?.after).toContain('windows-site')
  341. expect(completeBuiltBin?.after).not.toContain('docs-site-build')
  342. })
  343. it('applies one configured test, polling, and hook timeout to both coverage gates', () => {
  344. const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '15000', () =>
  345. withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))
  346. for (const id of ['coverage', 'coverage-exempt-heavy']) {
  347. expect(gates.find(subject => subject.id === id)?.args).toEqual(expect.arrayContaining([
  348. '--testTimeout=15000',
  349. '--expect.poll.timeout=15000',
  350. '--hookTimeout=15000',
  351. ]))
  352. }
  353. })
  354. it('keeps Vitest timeout defaults when the coverage override is absent', () => {
  355. const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', undefined, () =>
  356. withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))
  357. for (const id of ['coverage', 'coverage-exempt-heavy']) {
  358. expect(gates.find(subject => subject.id === id)?.args).not.toEqual(expect.arrayContaining([
  359. expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout|hookTimeout)=/),
  360. ]))
  361. }
  362. })
  363. it('rejects an invalid coverage timeout before starting a gate', () => {
  364. expect(() => withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '0', () =>
  365. withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))))
  366. .toThrow('DSH_COVERAGE_TEST_TIMEOUT_MS must be a positive integer')
  367. })
  368. it('selects partitioned coverage only when explicitly configured', () => {
  369. const coverage = withEnv('DSH_COVERAGE_PARTITIONS', '3', () =>
  370. withPnpmEntrypoint(() => gatesForMode('ci-windows-complete').find(subject => subject.id === 'coverage')))
  371. expect(coverage).toMatchObject({
  372. displayCommand: 'DSH_COVERAGE_PARTITIONS=3 pnpm run test:coverage:partitioned',
  373. args: ['/private/pnpm.cjs', 'run', 'test:coverage:partitioned'],
  374. env: { DSH_COVERAGE_EXEMPT_HEAVY: '1' },
  375. streamOutput: true,
  376. })
  377. })
  378. it('rejects an invalid coverage partition count before starting a gate', () => {
  379. expect(() => withEnv('DSH_COVERAGE_PARTITIONS', '1', () =>
  380. withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))))
  381. .toThrow('DSH_COVERAGE_PARTITIONS must be an integer greater than 1')
  382. })
  383. it.each([
  384. ['empty', [], /gate graph has no gates/],
  385. ['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/],
  386. ['unknown dependencies', [gate('subject', { needs: ['missing'] })], /depends on unknown gate "missing"/],
  387. ['unknown ordering predecessors', [gate('subject', { after: ['missing'] })], /waits for unknown gate "missing"/],
  388. ['cycles', [gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })], /dependency cycle: first -> second -> first/],
  389. ['mixed cycles', [gate('first', { after: ['second'] }), gate('second', { needs: ['first'] })], /dependency cycle: first -> second -> first/],
  390. ] as const)('rejects %s before starting a child', async (_label, invalid, message) => {
  391. const execute = vi.fn(async (subject: Gate) => resultFor(subject))
  392. await expect(runGates([...invalid], 1, execute)).rejects.toThrow(message)
  393. expect(execute).not.toHaveBeenCalled()
  394. })
  395. it('rejects an invalid worker count before starting a child', async () => {
  396. const execute = vi.fn(async (subject: Gate) => resultFor(subject))
  397. await expect(runGates([gate('subject')], 0, execute)).rejects.toThrow('max concurrency must be a positive integer')
  398. expect(execute).not.toHaveBeenCalled()
  399. })
  400. it('skips dependents after their prerequisite fails', async () => {
  401. const dependent = gate('dependent', { needs: ['root'] })
  402. const root = gate('root')
  403. const execute = vi.fn(async (subject: Gate) => resultFor(subject, 'failed'))
  404. const results = await runGates([dependent, root], 1, execute)
  405. expect(execute).toHaveBeenCalledOnce()
  406. expect(execute).toHaveBeenCalledWith(root, undefined)
  407. expect(results[0]).toMatchObject({ gate: dependent, status: 'skipped', error: 'dependency failed or skipped: root' })
  408. })
  409. it('runs an ordered follower after its predecessor fails', async () => {
  410. const follower = gate('follower', { after: ['root'] })
  411. const root = gate('root')
  412. const execute = vi.fn(async (subject: Gate) => resultFor(subject, subject === root ? 'failed' : 'passed'))
  413. const results = await runGates([follower, root], 2, execute)
  414. expect(execute.mock.calls.map(([subject]) => subject.id)).toEqual(['root', 'follower'])
  415. expect(results.map(result => result.status)).toEqual(['passed', 'failed'])
  416. })
  417. it('runs an ordered follower after its predecessor is skipped', async () => {
  418. const follower = gate('follower', { after: ['dependent'] })
  419. const dependent = gate('dependent', { needs: ['root'] })
  420. const root = gate('root')
  421. const execute = vi.fn(async (subject: Gate) => resultFor(subject, subject === root ? 'failed' : 'passed'))
  422. const results = await runGates([follower, dependent, root], 2, execute)
  423. expect(execute.mock.calls.map(([subject]) => subject.id)).toEqual(['root', 'follower'])
  424. expect(results.map(result => result.status)).toEqual(['passed', 'skipped', 'failed'])
  425. })
  426. })
  427. describe('Oxlint gate', () => {
  428. it('uses the package script when no worker bound is configured', () => {
  429. const subject = withEnv('DSH_OXLINT_THREADS', undefined, () =>
  430. withPnpmEntrypoint(() => gatesForMode('ci-lint-contracts-ready')[0]))
  431. expect(subject).toMatchObject({
  432. id: 'lint',
  433. displayCommand: 'pnpm run lint:contracts-ready',
  434. command: process.execPath,
  435. args: ['/private/pnpm.cjs', 'run', 'lint:contracts-ready'],
  436. })
  437. })
  438. it('surfaces the configured worker bound on the shared package script', () => {
  439. const subject = withEnv('DSH_OXLINT_THREADS', '4', () =>
  440. withPnpmEntrypoint(() => gatesForMode('ci-lint-contracts-ready')[0]))
  441. expect(subject).toMatchObject({
  442. id: 'lint',
  443. displayCommand: 'DSH_OXLINT_THREADS=4 pnpm run lint:contracts-ready',
  444. command: process.execPath,
  445. args: ['/private/pnpm.cjs', 'run', 'lint:contracts-ready'],
  446. })
  447. })
  448. })
  449. describe('Typert contract preparation', () => {
  450. it('prepares primary source consumers once before they run', () => {
  451. const subject = withEnv('DSH_OXLINT_THREADS', undefined, () =>
  452. withPnpmEntrypoint(() => gatesForMode('ci-primary')))
  453. expect(subject.find(item => item.id === 'typert-contracts')).toMatchObject({
  454. displayCommand: 'pnpm run build:lib:host',
  455. args: ['/private/pnpm.cjs', 'run', 'build:lib:host'],
  456. })
  457. for (const [id, script] of [
  458. ['typecheck', 'typecheck:contracts-ready'],
  459. ['lint', 'lint:contracts-ready'],
  460. ['doc-typecheck', 'doc-typecheck:contracts-ready'],
  461. ] as const) {
  462. expect(subject.find(item => item.id === id)).toMatchObject({
  463. displayCommand: `pnpm run ${script}`,
  464. args: ['/private/pnpm.cjs', 'run', script],
  465. needs: ['typert-contracts'],
  466. })
  467. }
  468. expect(subject.find(item => item.id === 'build')?.needs).toEqual([
  469. 'typecheck',
  470. 'lint',
  471. 'doc-typecheck',
  472. ])
  473. })
  474. it('reuses contracts from the validated consumer build', () => {
  475. const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
  476. expect(subject.find(item => item.id === 'lint-and-duplication')).toMatchObject({
  477. displayCommand: 'pnpm run check:ci:lint:contracts-ready',
  478. args: ['/private/pnpm.cjs', 'run', 'check:ci:lint:contracts-ready'],
  479. })
  480. expect(subject.find(item => item.id === 'doc-typecheck')).toMatchObject({
  481. displayCommand: 'pnpm run doc-typecheck:contracts-ready',
  482. args: ['/private/pnpm.cjs', 'run', 'doc-typecheck:contracts-ready'],
  483. })
  484. })
  485. it('keeps standalone doc sync responsible for preparation', () => {
  486. const docTypecheck = withPnpmEntrypoint(() =>
  487. gatesForMode('doc-sync').find(item => item.id === 'doc-typecheck'))
  488. expect(docTypecheck?.displayCommand).toBe('pnpm run doc-typecheck')
  489. })
  490. })
  491. describe('Node compatibility graph', () => {
  492. it('runs the jsdom environment smoke on every advertised Node line', () => {
  493. const subject = withPnpmEntrypoint(() => gatesForMode('node-compat'))
  494. expect(subject.find(item => item.id === 'vitest-jsdom-smoke')).toMatchObject({
  495. label: 'Vitest jsdom smoke',
  496. args: [
  497. '/private/pnpm.cjs',
  498. 'exec',
  499. 'vitest',
  500. 'run',
  501. 'scripts/vitest-environment.compat.spec.ts',
  502. ],
  503. })
  504. })
  505. })
  506. describe('Node 24 lane ownership', () => {
  507. it('keeps the static lane source-only', () => {
  508. const subject = withPnpmEntrypoint(() => gatesForMode('ci-static'))
  509. expect(subject.map(item => item.id)).not.toContain('build')
  510. expect(subject.map(item => item.id)).not.toContain('doc-typecheck')
  511. })
  512. it('owns the build and orders its artifact consumers', () => {
  513. const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
  514. expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({
  515. workers: 11,
  516. source: 'ci-consumers gate count',
  517. })
  518. expect(subject.map(item => item.id)).toEqual([
  519. 'build',
  520. 'node-compat',
  521. 'publint',
  522. 'built-package-invariants',
  523. 'lint-and-duplication',
  524. 'snapshot',
  525. 'expected-output',
  526. 'web-snapshot',
  527. 'doc-typecheck',
  528. 'node-next-types',
  529. 'built-bin-smoke',
  530. ])
  531. expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build'])
  532. expect(subject.find(item => item.id === 'build')?.env).toEqual({
  533. DSH_BUILD_CLIENT_PROFILE: 'official',
  534. })
  535. expect(subject.find(item => item.id === 'node-compat')?.env).toEqual({
  536. DSH_BUILD_CLIENT_PROFILE: 'official',
  537. })
  538. expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['build'])
  539. expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants'])
  540. for (const id of [
  541. 'snapshot',
  542. 'expected-output',
  543. 'web-snapshot',
  544. 'doc-typecheck',
  545. 'node-next-types',
  546. 'built-bin-smoke',
  547. ]) {
  548. expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants'])
  549. }
  550. expect(subject.find(item => item.id === 'snapshot')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' })
  551. expect(subject.find(item => item.id === 'expected-output')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' })
  552. expect(subject.find(item => item.id === 'doc-typecheck')?.env).toEqual({
  553. DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1',
  554. })
  555. expect(subject.find(item => item.id === 'built-bin-smoke')?.args).toEqual(
  556. expect.arrayContaining([
  557. 'packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts',
  558. 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts',
  559. 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
  560. 'packages/experimental/agent-team/tests/built-lib.e2e.ts',
  561. ]),
  562. )
  563. expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({
  564. displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
  565. env: { DSH_SNAPSHOT: 'replay' },
  566. after: [
  567. 'publint',
  568. 'lint-and-duplication',
  569. 'snapshot',
  570. 'expected-output',
  571. 'doc-typecheck',
  572. 'node-next-types',
  573. 'built-bin-smoke',
  574. ],
  575. })
  576. })
  577. })
  578. describe('Linux primary graph', () => {
  579. it('adds the same compare-only web gate after built client artifacts', () => {
  580. const subject = withPnpmEntrypoint(() => gatesForMode('ci-linux-primary'))
  581. const web = subject.find(item => item.id === 'web-snapshot')
  582. expect(web).toMatchObject({
  583. displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
  584. env: { DSH_SNAPSHOT: 'replay' },
  585. needs: ['built-package-invariants'],
  586. })
  587. })
  588. })
  589. describe('gate process outcomes', () => {
  590. it('streams selected gate output without retaining it', async () => {
  591. const write = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
  592. try {
  593. const result = await runGate(gate('streamed', {
  594. args: ['-e', "process.stdout.write('live output')"],
  595. streamOutput: true,
  596. }))
  597. expect(result.status).toBe('passed')
  598. expect(result.output).toEqual([])
  599. expect(write).toHaveBeenCalledWith('live output')
  600. } finally {
  601. write.mockRestore()
  602. }
  603. })
  604. it.skipIf(process.platform === 'win32')('reports signal termination independently from exit status', async () => {
  605. const result = await runGate(gate('terminated', {
  606. args: ['-e', "process.kill(process.pid, 'SIGTERM')"],
  607. }))
  608. expect(result.status).toBe('failed')
  609. expect(result.exitCode).toBeNull()
  610. expect(result.signalCode).toBe('SIGTERM')
  611. expect(formatGateResultReason(result)).toBe('signal SIGTERM')
  612. })
  613. })
  614. describe('fail-fast scheduling', () => {
  615. it('aborts the aggregate at the first blocking failure', async () => {
  616. const slow = gate('slow')
  617. const fast = gate('fast')
  618. const dependent = gate('dependent', { needs: ['slow'] })
  619. const execute = vi.fn(async (subject: Gate, signal?: AbortSignal) => {
  620. if (subject.id === 'fast') {
  621. return new Promise<GateResult>((resolve) => {
  622. signal?.addEventListener('abort', () => {
  623. // The real runGate marks a gate the abort terminated; the drain
  624. // must then record it skipped rather than keep the failure.
  625. resolve({ ...resultFor(subject, 'failed'), aborted: true })
  626. }, { once: true })
  627. })
  628. }
  629. return resultFor(subject, subject.id === 'slow' ? 'failed' : 'passed')
  630. })
  631. const results = await runGates([slow, fast, dependent], 2, execute, () => {}, { failFast: true })
  632. expect(execute.mock.calls.map(([subject]) => subject.id)).toEqual(['slow', 'fast'])
  633. expect(results.map(result => result.status)).toEqual(['failed', 'skipped', 'skipped'])
  634. expect(results[1]).toMatchObject({
  635. status: 'skipped',
  636. error: 'aborted by fail-fast: slow failed',
  637. })
  638. expect(results[2]).toMatchObject({
  639. status: 'skipped',
  640. error: 'aborted by fail-fast: slow failed',
  641. })
  642. })
  643. it('does not abort on a non-blocking gate failure', async () => {
  644. const observational = gate('observational', { allowFailure: true })
  645. const root = gate('root')
  646. const execute = vi.fn(async (subject: Gate) => (
  647. resultFor(subject, subject.id === 'observational' ? 'failed' : 'passed')
  648. ))
  649. const results = await runGates([observational, root], 2, execute, () => {}, { failFast: true })
  650. expect(execute).toHaveBeenCalledTimes(2)
  651. expect(results.map(result => result.status)).toEqual(['failed', 'passed'])
  652. })
  653. it('runs independent gates to completion when fail-fast is disabled', async () => {
  654. const root = gate('root')
  655. const sibling = gate('sibling')
  656. const execute = vi.fn(async (subject: Gate) => (
  657. resultFor(subject, subject.id === 'root' ? 'failed' : 'passed')
  658. ))
  659. const results = await runGates([root, sibling], 2, execute, () => {}, { failFast: false })
  660. expect(execute).toHaveBeenCalledTimes(2)
  661. expect(results.map(result => result.status)).toEqual(['failed', 'passed'])
  662. })
  663. it('kills the child when the abort signal fires', async () => {
  664. const controller = new AbortController()
  665. const promise = runGate(gate('killable', { args: ['-e', 'setInterval(() => {}, 1000)'] }), controller.signal)
  666. controller.abort()
  667. const result = await promise
  668. expect(result.status).toBe('failed')
  669. expect(result.aborted).toBe(true)
  670. if (process.platform !== 'win32') expect(result.signalCode).toBe('SIGTERM')
  671. })
  672. it.skipIf(process.platform === 'win32')('marks a zero-exit child as aborted when the signal fired', async () => {
  673. const { writes, write } = captureStreamedOutput()
  674. try {
  675. const controller = new AbortController()
  676. const child = gate('traps-signal', {
  677. args: ['-e', "process.stdout.write('ready\\n'); process.on('SIGTERM', () => process.exit(0)); setInterval(() => {}, 1000)"],
  678. streamOutput: true,
  679. })
  680. const promise = runGate(child, controller.signal)
  681. // Wait for the child to register its SIGTERM trap before aborting, so
  682. // the signal is caught and the child really exits zero.
  683. const deadline = Date.now() + 5000
  684. while (!writes.join('').includes('ready') && Date.now() < deadline) {
  685. await new Promise(resolve => setTimeout(resolve, 10))
  686. }
  687. controller.abort()
  688. const result = await promise
  689. // The child trapped the signal and exited zero; the drain must not
  690. // report this gate passed, so the raw outcome carries the abort mark.
  691. expect(result.status).toBe('passed')
  692. expect(result.aborted).toBe(true)
  693. } finally {
  694. write.mockRestore()
  695. }
  696. })
  697. it.skipIf(process.platform === 'win32')('kills the whole gate process tree when the abort signal fires', async () => {
  698. const { writes, write } = captureStreamedOutput()
  699. const controller = new AbortController()
  700. let promise: Promise<GateResult> | undefined
  701. try {
  702. const script = [
  703. "const { spawn } = require('node:child_process')",
  704. // Detached, so the grandchild leads its own process group: the gate
  705. // group signal cannot reach it, and only the descendant enumeration in
  706. // treeKill does — the shape of a nested run-gates' leaf gates.
  707. "const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true })",
  708. "process.stdout.write('grandchild:' + grandchild.pid + '\\n')",
  709. 'setInterval(() => {}, 1000)',
  710. ].join(';')
  711. promise = runGate(gate('tree', { args: ['-e', script], streamOutput: true }), controller.signal)
  712. const deadline = Date.now() + 5000
  713. let pid: number | undefined
  714. while (pid === undefined && Date.now() < deadline) {
  715. const match = writes.join('').match(/grandchild:(\d+)/)
  716. if (match !== null) pid = Number(match[1])
  717. else await new Promise(resolve => setTimeout(resolve, 20))
  718. }
  719. expect(pid ?? 0).toBeGreaterThan(0)
  720. controller.abort()
  721. const result = await promise
  722. expect(result.status).toBe('failed')
  723. // The descendant enumeration signals the detached grandchild at the same
  724. // time as the group signal reaches the direct child; the direct child's
  725. // own death closes the gate pipes, so poll for the grandchild to stop
  726. // executing rather than asserting on a fixed instant.
  727. const stopDeadline = Date.now() + 5000
  728. while (!procStopped(pid!) && Date.now() < stopDeadline) {
  729. await new Promise(resolve => setTimeout(resolve, 20))
  730. }
  731. expect(procStopped(pid!)).toBe(true)
  732. } finally {
  733. // A failed wait or assertion must not leave the forever-looping detached
  734. // grandchild behind on the host: abort the gate and wait for the
  735. // process tree to settle before restoring the spy.
  736. controller.abort()
  737. await promise
  738. write.mockRestore()
  739. }
  740. })
  741. it('forwards host interruption signals to the abort path', async () => {
  742. const slow = gate('slow')
  743. const sibling = gate('sibling')
  744. const execute = vi.fn(async (subject: Gate, signal?: AbortSignal) => {
  745. if (subject.id === 'slow') {
  746. return new Promise<GateResult>((resolve) => {
  747. signal?.addEventListener('abort', () => {
  748. // A child can trap the signal and exit zero; the drain must still
  749. // record the gate skipped so the interrupted run fails.
  750. resolve({ ...resultFor(subject, 'passed'), aborted: true })
  751. }, { once: true })
  752. })
  753. }
  754. return resultFor(subject)
  755. })
  756. const promise = runGates([slow, sibling], 1, execute, () => {}, { failFast: true, forwardProcessSignals: true })
  757. // The first loop iteration starts `slow` synchronously, so its abort
  758. // listener is registered before the signal is emitted.
  759. process.emit('SIGTERM')
  760. const results = await promise
  761. expect(execute).toHaveBeenCalledOnce()
  762. expect(results.map(result => result.status)).toEqual(['skipped', 'skipped'])
  763. expect(results[0]).toMatchObject({
  764. status: 'skipped',
  765. error: 'aborted by fail-fast: host interruption',
  766. })
  767. })
  768. it('pairs host signal forwarding with fail-fast at the CLI entrypoint', () => {
  769. expect(cliGateOptions(true)).toEqual({ failFast: true, forwardProcessSignals: true })
  770. expect(cliGateOptions(false)).toEqual({ failFast: false, forwardProcessSignals: false })
  771. })
  772. it('rejects host signal forwarding without fail-fast', async () => {
  773. const execute = vi.fn(async (subject: Gate) => resultFor(subject))
  774. await expect(runGates([gate('subject')], 1, execute, () => {}, { forwardProcessSignals: true }))
  775. .rejects.toThrow('forwardProcessSignals requires failFast')
  776. expect(execute).not.toHaveBeenCalled()
  777. })
  778. it('leaves an un-aborted child running to completion', async () => {
  779. const result = await runGate(gate('settles', { args: ['-e', ''] }), new AbortController().signal)
  780. expect(result.status).toBe('passed')
  781. expect(result.aborted).toBe(false)
  782. })
  783. it.skipIf(process.platform === 'win32')('kills a detached descendant that outlived the child when the abort arrives later', async () => {
  784. const writes: string[] = []
  785. const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
  786. writes.push(String(chunk))
  787. return true
  788. })
  789. const controller = new AbortController()
  790. let promise: Promise<GateResult> | undefined
  791. try {
  792. const script = [
  793. "const { spawn } = require('node:child_process')",
  794. // Detached with inherited stdio: the grandchild leads its own process
  795. // group (the gate group signal misses it) and holds the gate's
  796. // stdout write end (so `close` stays pending past the child exit).
  797. "const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'inherit' })",
  798. "process.stdout.write('grandchild:' + grandchild.pid + '\\n')",
  799. // Outlive the first descendant-sampler tick with margin so the cache
  800. // holds the grandchild even on a loaded runner, then exit normally
  801. // before the abort arrives.
  802. "setTimeout(() => { process.stdout.write('child-exit\\n'); process.exit(0) }, 8000)",
  803. ].join(';')
  804. promise = runGate(gate('late-abort', { args: ['-e', script], streamOutput: true }), controller.signal)
  805. const pid = await waitForGrandchildPid(writes, 'child-exit', Date.now() + 10000)
  806. // terminate must not re-enumerate over the sampler cache now that the
  807. // child is gone; the detached grandchild is killed from the cached list.
  808. await abortAndExpectTreeStopped(promise, controller, pid)
  809. } finally {
  810. // A failed wait or assertion must not leave the forever-looping detached
  811. // grandchild behind on the host: abort the gate and wait for the
  812. // process tree to settle before restoring the spy.
  813. controller.abort()
  814. await promise
  815. write.mockRestore()
  816. }
  817. }, 20000)
  818. it.skipIf(process.platform === 'win32')('keeps a reparented detached descendant tracked across a sampler tick', async () => {
  819. const { writes, write } = captureStreamedOutput()
  820. const controller = new AbortController()
  821. let promise: Promise<GateResult> | undefined
  822. try {
  823. const script = [
  824. "const { spawn } = require('node:child_process')",
  825. // Wrapper spawns a detached grandchild with inherited stdio (its own
  826. // process group, holding the gate's stdout write end), prints the pid,
  827. // then exits after 7 seconds — after the first sampler tick, before
  828. // the second. From then on the grandchild is reparented and
  829. // unreachable by parent id.
  830. "const wrapper = spawn(process.execPath, ['-e', \"const { spawn } = require('node:child_process'); const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'inherit' }); process.stdout.write('grandchild:' + grandchild.pid + '\\\\n'); setTimeout(() => process.exit(0), 7000)\"], { stdio: 'inherit' })",
  831. "wrapper.on('exit', () => process.stdout.write('wrapper-exited\\n'))",
  832. // Keep the root child alive past the abort with a heartbeat so the
  833. // test can abort while it is still running.
  834. "setInterval(() => process.stdout.write('hb\\n'), 1000)",
  835. ].join(';')
  836. promise = runGate(gate('sampler-merge', { args: ['-e', script], streamOutput: true }), controller.signal)
  837. const pid = await waitForGrandchildPid(writes, 'wrapper-exited', Date.now() + 15000)
  838. // Wait past the second sampler tick (t=10) with margin: a replacing tick
  839. // would drop the reparented grandchild from the cache, after which the
  840. // abort cannot reach it. The root child keeps running throughout.
  841. const tickDeadline = Date.now() + 10000
  842. const wrapperExitedAt = Date.now()
  843. while (Date.now() - wrapperExitedAt < 5000 && Date.now() < tickDeadline) {
  844. await new Promise(resolve => setTimeout(resolve, 50))
  845. }
  846. expect(Date.now() - wrapperExitedAt).toBeGreaterThanOrEqual(5000)
  847. await abortAndExpectTreeStopped(promise, controller, pid)
  848. } finally {
  849. // A failed wait or assertion must not leave the forever-looping detached
  850. // grandchild behind on the host: abort the gate and wait for the
  851. // process tree to settle before restoring the spy.
  852. controller.abort()
  853. await promise
  854. write.mockRestore()
  855. }
  856. }, 30000)
  857. })
  858. describe('process-table parsing', () => {
  859. it('excludes the root when a parent link returns to it', () => {
  860. expect(collectDescendants(100, [[200, 100], [100, 200], [300, 200]]))
  861. .toEqual([200, 300])
  862. })
  863. it('visits duplicate and cyclic descendant links only once', () => {
  864. expect(collectDescendants(100, [
  865. [200, 100], [200, 100], [300, 100], [200, 200], [400, 200], [200, 400], [500, 300], [900, 800],
  866. ])).toEqual([200, 300, 400, 500])
  867. })
  868. it('returns no descendants for an isolated or self-parented root', () => {
  869. expect(collectDescendants(100, [])).toEqual([])
  870. expect(collectDescendants(100, [[100, 100]])).toEqual([])
  871. })
  872. it('walks a wide child set without spreading it into call arguments', () => {
  873. const children = Array.from({ length: 150_000 }, (_, i): [number, number] => [i + 3, 2])
  874. const descendants = collectDescendants(1, [[2, 1], ...children])
  875. expect(descendants).toHaveLength(children.length + 1)
  876. expect(descendants[0]).toBe(2)
  877. expect(descendants.at(-1)).toBe(150_002)
  878. })
  879. it('parses `pid ppid` rows from a POSIX ps dump', () => {
  880. expect(parsePidPpidLines(' 123 1\n456 123\n 789 456\n')).toEqual([[123, 1], [456, 123], [789, 456]])
  881. })
  882. it('parses Windows PowerShell Get-CimInstance output of the same shape', () => {
  883. expect(parsePidPpidLines(' 123 1\r\n456 123\r\n')).toEqual([[123, 1], [456, 123]])
  884. })
  885. it('drops blank and malformed lines', () => {
  886. expect(parsePidPpidLines(' 123 1\n\ncommand not found\n999 abc\n')).toEqual([[123, 1]])
  887. })
  888. })
  889. describe('Windows tree termination', () => {
  890. it('targets the root first and each captured descendant after it', () => {
  891. expect(taskkillArgs(100, [201, 302, 403])).toEqual([
  892. ['/PID', '100', '/T', '/F'],
  893. ['/PID', '201', '/T', '/F'],
  894. ['/PID', '302', '/T', '/F'],
  895. ['/PID', '403', '/T', '/F'],
  896. ])
  897. })
  898. it('terminates the root alone when no descendant was captured', () => {
  899. expect(taskkillArgs(100, [])).toEqual([['/PID', '100', '/T', '/F']])
  900. })
  901. })