run-gates.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  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-linux-primary',
  56. 'ci-static',
  57. 'ci-lint-contracts-ready',
  58. 'ci-coverage',
  59. 'ci-snapshot',
  60. 'ci-artifacts',
  61. 'ci-consumers',
  62. 'ci-windows-blocking',
  63. 'ci-windows-complete',
  64. 'ci-windows-observational',
  65. 'node-compat',
  66. 'check-all',
  67. 'doc-sync',
  68. ] as const)('constructs and executes preflight for a valid non-empty %s graph', async (mode) => {
  69. const subject = withPnpmEntrypoint(() => gatesForMode(mode))
  70. const execute = vi.fn(async (item: Gate) => resultFor(item))
  71. await expect(runGates(subject, subject.length, execute)).resolves.toHaveLength(subject.length)
  72. })
  73. it('keeps the public repository link policy in the documentation gate', () => {
  74. const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
  75. expect(ids).toContain('public-repository-links')
  76. })
  77. it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
  78. 'keeps the DSH package license policy in %s',
  79. (mode) => {
  80. const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
  81. expect(ids).toContain('dsh-package-licenses')
  82. },
  83. )
  84. it('keeps native Windows coverage blocking while portability inventory remains observational', () => {
  85. const gates = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
  86. const byId = new Map(gates.map(subject => [subject.id, subject]))
  87. expect(byId.get('coverage')?.allowFailure).not.toBe(true)
  88. expect(byId.get('coverage-exempt-heavy')?.allowFailure).not.toBe(true)
  89. expect(byId.get('duplication')?.allowFailure).toBe(true)
  90. })
  91. it.each([
  92. ['empty', [], /gate graph has no gates/],
  93. ['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/],
  94. ['unknown dependencies', [gate('subject', { needs: ['missing'] })], /depends on unknown gate "missing"/],
  95. ['cycles', [gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })], /dependency cycle: first -> second -> first/],
  96. ] as const)('rejects %s before starting a child', async (_label, invalid, message) => {
  97. const execute = vi.fn(async (subject: Gate) => resultFor(subject))
  98. await expect(runGates([...invalid], 1, execute)).rejects.toThrow(message)
  99. expect(execute).not.toHaveBeenCalled()
  100. })
  101. it('rejects an invalid worker count before starting a child', async () => {
  102. const execute = vi.fn(async (subject: Gate) => resultFor(subject))
  103. await expect(runGates([gate('subject')], 0, execute)).rejects.toThrow('max concurrency must be a positive integer')
  104. expect(execute).not.toHaveBeenCalled()
  105. })
  106. it('skips dependents after their prerequisite fails', async () => {
  107. const dependent = gate('dependent', { needs: ['root'] })
  108. const root = gate('root')
  109. const execute = vi.fn(async (subject: Gate) => resultFor(subject, 'failed'))
  110. const results = await runGates([dependent, root], 1, execute)
  111. expect(execute).toHaveBeenCalledOnce()
  112. expect(execute).toHaveBeenCalledWith(root)
  113. expect(results[0]).toMatchObject({ gate: dependent, status: 'skipped', error: 'dependency failed or skipped: root' })
  114. })
  115. })
  116. describe('Oxlint gate', () => {
  117. it('uses the package script when no worker bound is configured', () => {
  118. const subject = withEnv('DSH_OXLINT_THREADS', undefined, () =>
  119. withPnpmEntrypoint(() => gatesForMode('ci-lint-contracts-ready')[0]))
  120. expect(subject).toMatchObject({
  121. id: 'lint',
  122. displayCommand: 'pnpm run lint:contracts-ready',
  123. command: process.execPath,
  124. args: ['/private/pnpm.cjs', 'run', 'lint:contracts-ready'],
  125. })
  126. })
  127. it('surfaces the configured worker bound on the shared package script', () => {
  128. const subject = withEnv('DSH_OXLINT_THREADS', '4', () =>
  129. withPnpmEntrypoint(() => gatesForMode('ci-lint-contracts-ready')[0]))
  130. expect(subject).toMatchObject({
  131. id: 'lint',
  132. displayCommand: 'DSH_OXLINT_THREADS=4 pnpm run lint:contracts-ready',
  133. command: process.execPath,
  134. args: ['/private/pnpm.cjs', 'run', 'lint:contracts-ready'],
  135. })
  136. })
  137. })
  138. describe('Typert contract preparation', () => {
  139. it('prepares primary source consumers once before they run', () => {
  140. const subject = withEnv('DSH_OXLINT_THREADS', undefined, () =>
  141. withPnpmEntrypoint(() => gatesForMode('ci-primary')))
  142. expect(subject.find(item => item.id === 'typert-contracts')).toMatchObject({
  143. displayCommand: 'pnpm run build:lib:host',
  144. args: ['/private/pnpm.cjs', 'run', 'build:lib:host'],
  145. })
  146. for (const [id, script] of [
  147. ['typecheck', 'typecheck:contracts-ready'],
  148. ['lint', 'lint:contracts-ready'],
  149. ['doc-typecheck', 'doc-typecheck:contracts-ready'],
  150. ] as const) {
  151. expect(subject.find(item => item.id === id)).toMatchObject({
  152. displayCommand: `pnpm run ${script}`,
  153. args: ['/private/pnpm.cjs', 'run', script],
  154. needs: ['typert-contracts'],
  155. })
  156. }
  157. expect(subject.find(item => item.id === 'build')?.needs).toEqual([
  158. 'typecheck',
  159. 'lint',
  160. 'doc-typecheck',
  161. ])
  162. })
  163. it('reuses contracts from the validated consumer build', () => {
  164. const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
  165. expect(subject.find(item => item.id === 'lint-and-duplication')).toMatchObject({
  166. displayCommand: 'pnpm run check:ci:lint:contracts-ready',
  167. args: ['/private/pnpm.cjs', 'run', 'check:ci:lint:contracts-ready'],
  168. })
  169. expect(subject.find(item => item.id === 'doc-typecheck')).toMatchObject({
  170. displayCommand: 'pnpm run doc-typecheck:contracts-ready',
  171. args: ['/private/pnpm.cjs', 'run', 'doc-typecheck:contracts-ready'],
  172. })
  173. })
  174. it('keeps standalone doc sync responsible for preparation', () => {
  175. const docTypecheck = withPnpmEntrypoint(() =>
  176. gatesForMode('doc-sync').find(item => item.id === 'doc-typecheck'))
  177. expect(docTypecheck?.displayCommand).toBe('pnpm run doc-typecheck')
  178. })
  179. })
  180. describe('Node compatibility graph', () => {
  181. it('runs the jsdom environment smoke on every advertised Node line', () => {
  182. const subject = withPnpmEntrypoint(() => gatesForMode('node-compat'))
  183. expect(subject.find(item => item.id === 'vitest-jsdom-smoke')).toMatchObject({
  184. label: 'Vitest jsdom smoke',
  185. args: [
  186. '/private/pnpm.cjs',
  187. 'exec',
  188. 'vitest',
  189. 'run',
  190. 'scripts/vitest-environment.compat.spec.ts',
  191. ],
  192. })
  193. })
  194. })
  195. describe('Node 24 lane ownership', () => {
  196. it('keeps the static lane source-only', () => {
  197. const subject = withPnpmEntrypoint(() => gatesForMode('ci-static'))
  198. expect(subject.map(item => item.id)).not.toContain('build')
  199. expect(subject.map(item => item.id)).not.toContain('doc-typecheck')
  200. })
  201. it('owns the build and orders its artifact consumers', () => {
  202. const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
  203. expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({
  204. workers: 10,
  205. source: 'ci-consumers gate count',
  206. })
  207. expect(subject.map(item => item.id)).toEqual([
  208. 'build',
  209. 'node-compat',
  210. 'publint',
  211. 'built-package-invariants',
  212. 'lint-and-duplication',
  213. 'snapshot',
  214. 'web-snapshot',
  215. 'doc-typecheck',
  216. 'node-next-types',
  217. 'built-bin-smoke',
  218. ])
  219. expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build'])
  220. expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint'])
  221. expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants'])
  222. for (const id of [
  223. 'snapshot',
  224. 'web-snapshot',
  225. 'doc-typecheck',
  226. 'node-next-types',
  227. 'built-bin-smoke',
  228. ]) {
  229. expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants'])
  230. }
  231. expect(subject.find(item => item.id === 'snapshot')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' })
  232. expect(subject.find(item => item.id === 'doc-typecheck')?.env).toEqual({
  233. DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1',
  234. })
  235. expect(subject.find(item => item.id === 'built-bin-smoke')?.args).toEqual(
  236. expect.arrayContaining([
  237. 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts',
  238. 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
  239. ]),
  240. )
  241. expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({
  242. displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
  243. env: { DSH_SNAPSHOT: 'replay' },
  244. })
  245. })
  246. })
  247. describe('Linux primary graph', () => {
  248. it('adds the same compare-only web gate after built client artifacts', () => {
  249. const subject = withPnpmEntrypoint(() => gatesForMode('ci-linux-primary'))
  250. const web = subject.find(item => item.id === 'web-snapshot')
  251. expect(web).toMatchObject({
  252. displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
  253. env: { DSH_SNAPSHOT: 'replay' },
  254. needs: ['built-package-invariants'],
  255. })
  256. })
  257. })
  258. describe('gate process outcomes', () => {
  259. it.skipIf(process.platform === 'win32')('reports signal termination independently from exit status', async () => {
  260. const result = await runGate(gate('terminated', {
  261. args: ['-e', "process.kill(process.pid, 'SIGTERM')"],
  262. }))
  263. expect(result.status).toBe('failed')
  264. expect(result.exitCode).toBeNull()
  265. expect(result.signalCode).toBe('SIGTERM')
  266. expect(formatGateResultReason(result)).toBe('signal SIGTERM')
  267. })
  268. })