run-gates.spec.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  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. describe('gate graph validation', () => {
  42. it.each([
  43. 'ci-primary',
  44. 'ci-static',
  45. 'ci-lint',
  46. 'ci-coverage',
  47. 'ci-snapshot',
  48. 'ci-artifacts',
  49. 'ci-consumers',
  50. 'ci-windows-blocking',
  51. 'ci-windows-complete',
  52. 'ci-windows-observational',
  53. 'node-compat',
  54. 'check-all',
  55. 'doc-sync',
  56. ] as const)('constructs and executes preflight for a valid non-empty %s graph', async (mode) => {
  57. const subject = withPnpmEntrypoint(() => gatesForMode(mode))
  58. const execute = vi.fn(async (item: Gate) => resultFor(item))
  59. await expect(runGates(subject, subject.length, execute)).resolves.toHaveLength(subject.length)
  60. })
  61. it.each([
  62. ['empty', [], /gate graph has no gates/],
  63. ['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/],
  64. ['unknown dependencies', [gate('subject', { needs: ['missing'] })], /depends on unknown gate "missing"/],
  65. ['cycles', [gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })], /dependency cycle: first -> second -> first/],
  66. ] as const)('rejects %s before starting a child', async (_label, invalid, message) => {
  67. const execute = vi.fn(async (subject: Gate) => resultFor(subject))
  68. await expect(runGates([...invalid], 1, execute)).rejects.toThrow(message)
  69. expect(execute).not.toHaveBeenCalled()
  70. })
  71. it('rejects an invalid worker count before starting a child', async () => {
  72. const execute = vi.fn(async (subject: Gate) => resultFor(subject))
  73. await expect(runGates([gate('subject')], 0, execute)).rejects.toThrow('max concurrency must be a positive integer')
  74. expect(execute).not.toHaveBeenCalled()
  75. })
  76. it('skips dependents after their prerequisite fails', async () => {
  77. const dependent = gate('dependent', { needs: ['root'] })
  78. const root = gate('root')
  79. const execute = vi.fn(async (subject: Gate) => resultFor(subject, 'failed'))
  80. const results = await runGates([dependent, root], 1, execute)
  81. expect(execute).toHaveBeenCalledOnce()
  82. expect(execute).toHaveBeenCalledWith(root)
  83. expect(results[0]).toMatchObject({ gate: dependent, status: 'skipped', error: 'dependency failed or skipped: root' })
  84. })
  85. })
  86. describe('Node 24 consumer graph', () => {
  87. it('owns the seven-command pool and orders restored-artifact consumers', () => {
  88. const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
  89. expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({
  90. workers: 7,
  91. source: 'ci-consumers gate count',
  92. })
  93. expect(subject.map(item => item.id)).toEqual([
  94. 'lint-and-duplication',
  95. 'node-compat',
  96. 'snapshot',
  97. 'publint',
  98. 'node-next-types',
  99. 'built-package-invariants',
  100. 'built-bin-smoke',
  101. ])
  102. expect(subject.find(item => item.id === 'publint')?.needs).toBeUndefined()
  103. expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint'])
  104. expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants'])
  105. for (const id of ['snapshot', 'node-next-types', 'built-bin-smoke']) {
  106. expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants'])
  107. }
  108. expect(subject.find(item => item.id === 'snapshot')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' })
  109. })
  110. })
  111. describe('gate process outcomes', () => {
  112. it.skipIf(process.platform === 'win32')('reports signal termination independently from exit status', async () => {
  113. const result = await runGate(gate('terminated', {
  114. args: ['-e', "process.kill(process.pid, 'SIGTERM')"],
  115. }))
  116. expect(result.status).toBe('failed')
  117. expect(result.exitCode).toBeNull()
  118. expect(result.signalCode).toBe('SIGTERM')
  119. expect(formatGateResultReason(result)).toBe('signal SIGTERM')
  120. })
  121. })