detect-pr-changes.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. import { describe, expect, test } from 'bun:test'
  2. import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import {
  6. classifyChangedPaths,
  7. formatGitHubOutput,
  8. parseChangedPaths,
  9. } from './detect-pr-changes'
  10. const NO_CHANGES = {
  11. 'has-evals': false,
  12. 'has-docs': false,
  13. 'has-workflows': false,
  14. 'has-packages': false,
  15. }
  16. const SCRIPT_PATH = join(import.meta.dir, 'detect-pr-changes.ts')
  17. const WORKFLOW_PATH = join(import.meta.dir, '..', '..', '.github', 'workflows', 'pr-checks.yml')
  18. const PACKAGES_WORKFLOW_PATH = join(import.meta.dir, '..', '..', '.github', 'workflows', 'packages-checks.yml')
  19. type CliResult = {
  20. exitCode: number
  21. stderr: string
  22. stdout: string
  23. }
  24. async function runCli(input: string, outputPath?: string): Promise<CliResult> {
  25. const env = { ...process.env }
  26. if (outputPath === undefined) delete env.GITHUB_OUTPUT
  27. else env.GITHUB_OUTPUT = outputPath
  28. const subprocess = Bun.spawn([process.execPath, 'run', SCRIPT_PATH], {
  29. env,
  30. stderr: 'pipe',
  31. stdin: new Blob([input]),
  32. stdout: 'pipe',
  33. })
  34. const [exitCode, stderr, stdout] = await Promise.all([
  35. subprocess.exited,
  36. new Response(subprocess.stderr).text(),
  37. new Response(subprocess.stdout).text(),
  38. ])
  39. return { exitCode, stderr, stdout }
  40. }
  41. async function withTempDir<T>(run: (directory: string) => Promise<T>): Promise<T> {
  42. const directory = await mkdtemp(join(tmpdir(), 'detect-pr-changes-'))
  43. try {
  44. return await run(directory)
  45. } finally {
  46. await rm(directory, { force: true, recursive: true })
  47. }
  48. }
  49. describe('classifyChangedPaths', () => {
  50. test('detects eval changes', () => {
  51. expect(classifyChangedPaths(['evals/framework/src/index.ts'])).toEqual({
  52. ...NO_CHANGES,
  53. 'has-evals': true,
  54. })
  55. })
  56. test('detects docs changes', () => {
  57. expect(classifyChangedPaths(['docs/maintenance/guide.md'])).toEqual({
  58. ...NO_CHANGES,
  59. 'has-docs': true,
  60. })
  61. })
  62. test('detects workflow changes', () => {
  63. expect(classifyChangedPaths(['.github/workflows/pr-checks.yml'])).toEqual({
  64. ...NO_CHANGES,
  65. 'has-workflows': true,
  66. })
  67. })
  68. test('detects packages changes', () => {
  69. expect(classifyChangedPaths(['packages/cli/src/index.ts'])).toEqual({
  70. ...NO_CHANGES,
  71. 'has-packages': true,
  72. })
  73. expect(classifyChangedPaths(['packages/compatibility-layer/package.json'])).toEqual({
  74. ...NO_CHANGES,
  75. 'has-packages': true,
  76. })
  77. expect(classifyChangedPaths(['packages/plugin-abilities/src/index.ts'])).toEqual({
  78. ...NO_CHANGES,
  79. 'has-packages': true,
  80. })
  81. })
  82. test('detects shared workspace dependency changes', () => {
  83. for (const path of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']) {
  84. expect(classifyChangedPaths([path])).toEqual({
  85. ...NO_CHANGES,
  86. 'has-evals': true,
  87. 'has-packages': true,
  88. })
  89. }
  90. })
  91. test('detects package automation changes', () => {
  92. for (const path of [
  93. '.github/dependabot.yml',
  94. '.github/workflows/packages-checks.yml',
  95. 'scripts/validation/detect-pr-changes.ts',
  96. 'scripts/validation/detect-pr-changes.test.ts',
  97. ]) {
  98. expect(classifyChangedPaths([path])).toEqual({
  99. ...NO_CHANGES,
  100. 'has-packages': true,
  101. ...(path.startsWith('.github/workflows/') ? { 'has-workflows': true } : {}),
  102. })
  103. }
  104. })
  105. test('detects mixed changes', () => {
  106. expect(
  107. classifyChangedPaths([
  108. 'evals/framework/package.json',
  109. 'docs/README.md',
  110. '.github/workflows/release.yml',
  111. 'packages/cli/package.json',
  112. 'src/index.ts',
  113. ]),
  114. ).toEqual({
  115. 'has-evals': true,
  116. 'has-docs': true,
  117. 'has-workflows': true,
  118. 'has-packages': true,
  119. })
  120. })
  121. test('returns false for every category when paths do not match', () => {
  122. expect(classifyChangedPaths(['README.md', 'scripts/check.ts'])).toEqual(NO_CHANGES)
  123. })
  124. test('returns false for every category when input is empty', () => {
  125. expect(classifyChangedPaths([])).toEqual(NO_CHANGES)
  126. })
  127. test('rejects near-prefix paths', () => {
  128. expect(
  129. classifyChangedPaths([
  130. 'evals-old/test.ts',
  131. 'docs.md',
  132. '.github/workflows-old/check.yml',
  133. 'nested/evals/test.ts',
  134. 'packages-old/cli/index.ts',
  135. 'packages.json',
  136. 'nested/packages/cli/index.ts',
  137. ]),
  138. ).toEqual(NO_CHANGES)
  139. })
  140. })
  141. describe('parseChangedPaths', () => {
  142. test('preserves path identity and removes empty NUL records', () => {
  143. expect(parseChangedPaths(' evals/test.ts \0\0docs/雪\n$HOME; file.md\0')).toEqual([
  144. ' evals/test.ts ',
  145. 'docs/雪\n$HOME; file.md',
  146. ])
  147. })
  148. })
  149. describe('formatGitHubOutput', () => {
  150. test('formats newline-delimited GitHub outputs', () => {
  151. expect(
  152. formatGitHubOutput({
  153. 'has-evals': true,
  154. 'has-docs': false,
  155. 'has-workflows': true,
  156. 'has-packages': false,
  157. }),
  158. ).toBe('has-evals=true\nhas-docs=false\nhas-workflows=true\nhas-packages=false\n')
  159. })
  160. })
  161. describe('CLI', () => {
  162. test('appends outputs to GITHUB_OUTPUT', async () => {
  163. await withTempDir(async (directory) => {
  164. const outputPath = join(directory, 'github-output')
  165. await writeFile(outputPath, 'existing=value\n')
  166. const result = await runCli('evals/test.ts\0', outputPath)
  167. expect(result).toEqual({ exitCode: 0, stderr: '', stdout: '' })
  168. expect(await readFile(outputPath, 'utf8')).toBe(
  169. 'existing=value\nhas-evals=true\nhas-docs=false\nhas-workflows=false\nhas-packages=false\n',
  170. )
  171. })
  172. })
  173. test('writes false outputs for empty stdin', async () => {
  174. await withTempDir(async (directory) => {
  175. const outputPath = join(directory, 'github-output')
  176. const result = await runCli('', outputPath)
  177. expect(result.exitCode).toBe(0)
  178. expect(await readFile(outputPath, 'utf8')).toBe(
  179. 'has-evals=false\nhas-docs=false\nhas-workflows=false\nhas-packages=false\n',
  180. )
  181. })
  182. })
  183. test('fails clearly when GITHUB_OUTPUT is missing', async () => {
  184. const result = await runCli('evals/test.ts\0')
  185. expect(result.exitCode).not.toBe(0)
  186. expect(result.stderr).toContain('GITHUB_OUTPUT is required but was not set')
  187. })
  188. test('writes an error to stderr and exits nonzero for an unwritable target', async () => {
  189. await withTempDir(async (directory) => {
  190. const outputPath = join(directory, 'output-directory')
  191. await mkdir(outputPath)
  192. const result = await runCli('docs/guide.md\0', outputPath)
  193. expect(result.exitCode).not.toBe(0)
  194. expect(result.stderr).toContain('Unable to append change detection outputs to GITHUB_OUTPUT')
  195. })
  196. })
  197. test('handles NUL-delimited Unicode and metacharacter filenames without changing identity', async () => {
  198. await withTempDir(async (directory) => {
  199. const outputPath = join(directory, 'github-output')
  200. const paths = [
  201. 'evals/ leading and trailing .ts ',
  202. 'docs/雪\n$HOME;$(touch never).md',
  203. '.github/workflows/[check]& weird.yml',
  204. 'packages/cli/src/ spaced 雪.ts',
  205. ]
  206. const result = await runCli(`${paths.join('\0')}\0`, outputPath)
  207. expect(result.exitCode).toBe(0)
  208. expect(await readFile(outputPath, 'utf8')).toBe(
  209. 'has-evals=true\nhas-docs=true\nhas-workflows=true\nhas-packages=true\n',
  210. )
  211. })
  212. })
  213. test('does not trim leading whitespace into a matching path', async () => {
  214. await withTempDir(async (directory) => {
  215. const outputPath = join(directory, 'github-output')
  216. const result = await runCli(' evals/not-under-evals.ts\0', outputPath)
  217. expect(result.exitCode).toBe(0)
  218. expect(await readFile(outputPath, 'utf8')).toContain('has-evals=false\n')
  219. })
  220. })
  221. })
  222. describe('PR checks workflow contract', () => {
  223. test('uses the NUL-delimited detector and exact output names', async () => {
  224. const workflow = await readFile(WORKFLOW_PATH, 'utf8')
  225. expect(workflow).toContain('git diff --name-only -z')
  226. expect(workflow).toMatch(/git diff --name-only -z[^\n]*\|[\s\S]*bun run scripts\/validation\/detect-pr-changes\.ts/)
  227. expect(workflow).toContain('has-evals: ${{ steps.filter.outputs.has-evals }}')
  228. expect(workflow).toContain('has-docs: ${{ steps.filter.outputs.has-docs }}')
  229. expect(workflow).toContain('has-workflows: ${{ steps.filter.outputs.has-workflows }}')
  230. expect(workflow).not.toMatch(/steps\.filter\.outputs\.(evals|docs|workflows)(?:\s|})/)
  231. expect(workflow).toContain('oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6')
  232. expect(workflow).toContain('bun-version: 1.3.14')
  233. })
  234. test('requires successful change detection before reporting overall success', async () => {
  235. const workflow = await readFile(WORKFLOW_PATH, 'utf8')
  236. const overallStatus = workflow.slice(workflow.indexOf('# Overall status'))
  237. expect(overallStatus).toContain('needs.check-changes.result }}" == "success"')
  238. expect(overallStatus).toContain('needs.check-changes.outputs.has-evals }}" != "true"')
  239. expect(overallStatus).toContain('exit 1')
  240. })
  241. })
  242. describe('Packages checks workflow contract', () => {
  243. test('uses the NUL-delimited detector and the has-packages output', async () => {
  244. const workflow = await readFile(PACKAGES_WORKFLOW_PATH, 'utf8')
  245. expect(workflow).toContain('git diff --name-only -z')
  246. expect(workflow).toMatch(/git diff --name-only -z[^\n]*\|[\s\S]*bun run scripts\/validation\/detect-pr-changes\.ts/)
  247. expect(workflow).toContain('has-packages: ${{ steps.filter.outputs.has-packages }}')
  248. expect(workflow).toContain('oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6')
  249. expect(workflow).toContain('bun-version: 1.3.14')
  250. })
  251. test('gates every package check job on the has-packages flag', async () => {
  252. const workflow = await readFile(PACKAGES_WORKFLOW_PATH, 'utf8')
  253. expect(workflow).toContain('cli-checks:')
  254. expect(workflow).toContain('compatibility-layer-checks:')
  255. expect(workflow).toContain('plugin-abilities-checks:')
  256. const gates = workflow.match(/needs\.check-changes\.outputs\.has-packages == 'true'/g) ?? []
  257. expect(gates.length).toBeGreaterThanOrEqual(3)
  258. })
  259. test('triggers on packages/** pull request changes', async () => {
  260. const workflow = await readFile(PACKAGES_WORKFLOW_PATH, 'utf8')
  261. expect(workflow).toContain('pull_request:')
  262. expect(workflow).toContain("- 'packages/**'")
  263. })
  264. })