run-gates.spec.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. import { describe, expect, it, vi } from 'vitest'
  2. import {
  3. defaultConcurrency,
  4. formatGateResultReason,
  5. gatesForMode,
  6. runGate,
  7. runGates,
  8. type Gate,
  9. type GateResult,
  10. } from './run-gates.ts'
  11. function gate(id: string, options: Partial<Gate> = {}): Gate {
  12. return {
  13. id,
  14. label: id,
  15. displayCommand: `run ${id}`,
  16. command: process.execPath,
  17. args: ['-e', ''],
  18. ...options,
  19. }
  20. }
  21. function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): GateResult {
  22. return {
  23. gate: subject,
  24. status,
  25. durationMs: 10,
  26. output: [],
  27. exitCode: status === 'passed' ? 0 : 1,
  28. signalCode: null,
  29. }
  30. }
  31. function withPnpmEntrypoint<T>(action: () => T): T {
  32. const previous = process.env.npm_execpath
  33. process.env.npm_execpath = '/private/pnpm.cjs'
  34. try {
  35. return action()
  36. } finally {
  37. if (previous === undefined) Reflect.deleteProperty(process.env, 'npm_execpath')
  38. else process.env.npm_execpath = previous
  39. }
  40. }
  41. function withEnv<T>(name: string, value: string | undefined, action: () => T): T {
  42. const previous = process.env[name]
  43. if (value === undefined) Reflect.deleteProperty(process.env, name)
  44. else process.env[name] = value
  45. try {
  46. return action()
  47. } finally {
  48. if (previous === undefined) Reflect.deleteProperty(process.env, name)
  49. else process.env[name] = previous
  50. }
  51. }
  52. describe('gate graph validation', () => {
  53. it.each([
  54. 'ci-primary',
  55. 'ci-static',
  56. 'ci-lint',
  57. 'ci-coverage',
  58. 'ci-snapshot',
  59. 'ci-artifacts',
  60. 'ci-consumers',
  61. 'ci-windows-blocking',
  62. 'ci-windows-complete',
  63. 'ci-windows-observational',
  64. 'node-compat',
  65. 'check-all',
  66. 'doc-sync',
  67. ] as const)('constructs and executes preflight for a valid non-empty %s graph', async (mode) => {
  68. const subject = withPnpmEntrypoint(() => gatesForMode(mode))
  69. const execute = vi.fn(async (item: Gate) => resultFor(item))
  70. await expect(runGates(subject, subject.length, execute)).resolves.toHaveLength(subject.length)
  71. })
  72. it.each([
  73. ['empty', [], /gate graph has no gates/],
  74. ['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/],
  75. ['unknown dependencies', [gate('subject', { needs: ['missing'] })], /depends on unknown gate "missing"/],
  76. ['cycles', [gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })], /dependency cycle: first -> second -> first/],
  77. ] as const)('rejects %s before starting a child', async (_label, invalid, message) => {
  78. const execute = vi.fn(async (subject: Gate) => resultFor(subject))
  79. await expect(runGates([...invalid], 1, execute)).rejects.toThrow(message)
  80. expect(execute).not.toHaveBeenCalled()
  81. })
  82. it('rejects an invalid worker count before starting a child', async () => {
  83. const execute = vi.fn(async (subject: Gate) => resultFor(subject))
  84. await expect(runGates([gate('subject')], 0, execute)).rejects.toThrow('max concurrency must be a positive integer')
  85. expect(execute).not.toHaveBeenCalled()
  86. })
  87. it('skips dependents after their prerequisite fails', async () => {
  88. const dependent = gate('dependent', { needs: ['root'] })
  89. const root = gate('root')
  90. const execute = vi.fn(async (subject: Gate) => resultFor(subject, 'failed'))
  91. const results = await runGates([dependent, root], 1, execute)
  92. expect(execute).toHaveBeenCalledOnce()
  93. expect(execute).toHaveBeenCalledWith(root)
  94. expect(results[0]).toMatchObject({ gate: dependent, status: 'skipped', error: 'dependency failed or skipped: root' })
  95. })
  96. })
  97. describe('Oxlint gate', () => {
  98. it('uses the package script when no worker bound is configured', () => {
  99. const subject = withEnv('DSH_OXLINT_THREADS', undefined, () =>
  100. withPnpmEntrypoint(() => gatesForMode('ci-lint')[0]))
  101. expect(subject).toMatchObject({
  102. id: 'lint',
  103. displayCommand: 'pnpm run lint',
  104. command: process.execPath,
  105. args: ['/private/pnpm.cjs', 'run', 'lint'],
  106. })
  107. })
  108. it('surfaces the configured worker bound on the shared package script', () => {
  109. const subject = withEnv('DSH_OXLINT_THREADS', '4', () =>
  110. withPnpmEntrypoint(() => gatesForMode('ci-lint')[0]))
  111. expect(subject).toMatchObject({
  112. id: 'lint',
  113. displayCommand: 'DSH_OXLINT_THREADS=4 pnpm run lint',
  114. command: process.execPath,
  115. args: ['/private/pnpm.cjs', 'run', 'lint'],
  116. })
  117. })
  118. })
  119. describe('Node 24 consumer graph', () => {
  120. it('owns the seven-command pool and orders restored-artifact consumers', () => {
  121. const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
  122. expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({
  123. workers: 7,
  124. source: 'ci-consumers gate count',
  125. })
  126. expect(subject.map(item => item.id)).toEqual([
  127. 'lint-and-duplication',
  128. 'node-compat',
  129. 'snapshot',
  130. 'publint',
  131. 'node-next-types',
  132. 'built-package-invariants',
  133. 'built-bin-smoke',
  134. ])
  135. expect(subject.find(item => item.id === 'publint')?.needs).toBeUndefined()
  136. expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint'])
  137. expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants'])
  138. for (const id of ['snapshot', 'node-next-types', 'built-bin-smoke']) {
  139. expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants'])
  140. }
  141. expect(subject.find(item => item.id === 'snapshot')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' })
  142. })
  143. })
  144. describe('gate process outcomes', () => {
  145. it.skipIf(process.platform === 'win32')('reports signal termination independently from exit status', async () => {
  146. const result = await runGate(gate('terminated', {
  147. args: ['-e', "process.kill(process.pid, 'SIGTERM')"],
  148. }))
  149. expect(result.status).toBe('failed')
  150. expect(result.exitCode).toBeNull()
  151. expect(result.signalCode).toBe('SIGTERM')
  152. expect(formatGateResultReason(result)).toBe('signal SIGTERM')
  153. })
  154. })