detect-pr-changes.test.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  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. }
  15. const SCRIPT_PATH = join(import.meta.dir, 'detect-pr-changes.ts')
  16. const WORKFLOW_PATH = join(import.meta.dir, '..', '..', '.github', 'workflows', 'pr-checks.yml')
  17. type CliResult = {
  18. exitCode: number
  19. stderr: string
  20. stdout: string
  21. }
  22. async function runCli(input: string, outputPath?: string): Promise<CliResult> {
  23. const env = { ...process.env }
  24. if (outputPath === undefined) delete env.GITHUB_OUTPUT
  25. else env.GITHUB_OUTPUT = outputPath
  26. const subprocess = Bun.spawn([process.execPath, 'run', SCRIPT_PATH], {
  27. env,
  28. stderr: 'pipe',
  29. stdin: new Blob([input]),
  30. stdout: 'pipe',
  31. })
  32. const [exitCode, stderr, stdout] = await Promise.all([
  33. subprocess.exited,
  34. new Response(subprocess.stderr).text(),
  35. new Response(subprocess.stdout).text(),
  36. ])
  37. return { exitCode, stderr, stdout }
  38. }
  39. async function withTempDir<T>(run: (directory: string) => Promise<T>): Promise<T> {
  40. const directory = await mkdtemp(join(tmpdir(), 'detect-pr-changes-'))
  41. try {
  42. return await run(directory)
  43. } finally {
  44. await rm(directory, { force: true, recursive: true })
  45. }
  46. }
  47. describe('classifyChangedPaths', () => {
  48. test('detects eval changes', () => {
  49. expect(classifyChangedPaths(['evals/framework/src/index.ts'])).toEqual({
  50. ...NO_CHANGES,
  51. 'has-evals': true,
  52. })
  53. })
  54. test('detects docs changes', () => {
  55. expect(classifyChangedPaths(['docs/maintenance/guide.md'])).toEqual({
  56. ...NO_CHANGES,
  57. 'has-docs': true,
  58. })
  59. })
  60. test('detects workflow changes', () => {
  61. expect(classifyChangedPaths(['.github/workflows/pr-checks.yml'])).toEqual({
  62. ...NO_CHANGES,
  63. 'has-workflows': true,
  64. })
  65. })
  66. test('detects mixed changes', () => {
  67. expect(
  68. classifyChangedPaths([
  69. 'evals/framework/package.json',
  70. 'docs/README.md',
  71. '.github/workflows/release.yml',
  72. 'src/index.ts',
  73. ]),
  74. ).toEqual({
  75. 'has-evals': true,
  76. 'has-docs': true,
  77. 'has-workflows': true,
  78. })
  79. })
  80. test('returns false for every category when paths do not match', () => {
  81. expect(classifyChangedPaths(['README.md', 'scripts/check.ts'])).toEqual(NO_CHANGES)
  82. })
  83. test('returns false for every category when input is empty', () => {
  84. expect(classifyChangedPaths([])).toEqual(NO_CHANGES)
  85. })
  86. test('rejects near-prefix paths', () => {
  87. expect(
  88. classifyChangedPaths([
  89. 'evals-old/test.ts',
  90. 'docs.md',
  91. '.github/workflows-old/check.yml',
  92. 'nested/evals/test.ts',
  93. ]),
  94. ).toEqual(NO_CHANGES)
  95. })
  96. })
  97. describe('parseChangedPaths', () => {
  98. test('preserves path identity and removes empty NUL records', () => {
  99. expect(parseChangedPaths(' evals/test.ts \0\0docs/雪\n$HOME; file.md\0')).toEqual([
  100. ' evals/test.ts ',
  101. 'docs/雪\n$HOME; file.md',
  102. ])
  103. })
  104. })
  105. describe('formatGitHubOutput', () => {
  106. test('formats newline-delimited GitHub outputs', () => {
  107. expect(
  108. formatGitHubOutput({
  109. 'has-evals': true,
  110. 'has-docs': false,
  111. 'has-workflows': true,
  112. }),
  113. ).toBe('has-evals=true\nhas-docs=false\nhas-workflows=true\n')
  114. })
  115. })
  116. describe('CLI', () => {
  117. test('appends outputs to GITHUB_OUTPUT', async () => {
  118. await withTempDir(async (directory) => {
  119. const outputPath = join(directory, 'github-output')
  120. await writeFile(outputPath, 'existing=value\n')
  121. const result = await runCli('evals/test.ts\0', outputPath)
  122. expect(result).toEqual({ exitCode: 0, stderr: '', stdout: '' })
  123. expect(await readFile(outputPath, 'utf8')).toBe(
  124. 'existing=value\nhas-evals=true\nhas-docs=false\nhas-workflows=false\n',
  125. )
  126. })
  127. })
  128. test('writes false outputs for empty stdin', async () => {
  129. await withTempDir(async (directory) => {
  130. const outputPath = join(directory, 'github-output')
  131. const result = await runCli('', outputPath)
  132. expect(result.exitCode).toBe(0)
  133. expect(await readFile(outputPath, 'utf8')).toBe(
  134. 'has-evals=false\nhas-docs=false\nhas-workflows=false\n',
  135. )
  136. })
  137. })
  138. test('fails clearly when GITHUB_OUTPUT is missing', async () => {
  139. const result = await runCli('evals/test.ts\0')
  140. expect(result.exitCode).not.toBe(0)
  141. expect(result.stderr).toContain('GITHUB_OUTPUT is required but was not set')
  142. })
  143. test('writes an error to stderr and exits nonzero for an unwritable target', async () => {
  144. await withTempDir(async (directory) => {
  145. const outputPath = join(directory, 'output-directory')
  146. await mkdir(outputPath)
  147. const result = await runCli('docs/guide.md\0', outputPath)
  148. expect(result.exitCode).not.toBe(0)
  149. expect(result.stderr).toContain('Unable to append change detection outputs to GITHUB_OUTPUT')
  150. })
  151. })
  152. test('handles NUL-delimited Unicode and metacharacter filenames without changing identity', async () => {
  153. await withTempDir(async (directory) => {
  154. const outputPath = join(directory, 'github-output')
  155. const paths = [
  156. 'evals/ leading and trailing .ts ',
  157. 'docs/雪\n$HOME;$(touch never).md',
  158. '.github/workflows/[check]& weird.yml',
  159. ]
  160. const result = await runCli(`${paths.join('\0')}\0`, outputPath)
  161. expect(result.exitCode).toBe(0)
  162. expect(await readFile(outputPath, 'utf8')).toBe(
  163. 'has-evals=true\nhas-docs=true\nhas-workflows=true\n',
  164. )
  165. })
  166. })
  167. test('does not trim leading whitespace into a matching path', async () => {
  168. await withTempDir(async (directory) => {
  169. const outputPath = join(directory, 'github-output')
  170. const result = await runCli(' evals/not-under-evals.ts\0', outputPath)
  171. expect(result.exitCode).toBe(0)
  172. expect(await readFile(outputPath, 'utf8')).toContain('has-evals=false\n')
  173. })
  174. })
  175. })
  176. describe('PR checks workflow contract', () => {
  177. test('uses the NUL-delimited detector and exact output names', async () => {
  178. const workflow = await readFile(WORKFLOW_PATH, 'utf8')
  179. expect(workflow).toContain('git diff --name-only -z')
  180. expect(workflow).toMatch(/git diff --name-only -z[^\n]*\|[\s\S]*bun run scripts\/validation\/detect-pr-changes\.ts/)
  181. expect(workflow).toContain('has-evals: ${{ steps.filter.outputs.has-evals }}')
  182. expect(workflow).toContain('has-docs: ${{ steps.filter.outputs.has-docs }}')
  183. expect(workflow).toContain('has-workflows: ${{ steps.filter.outputs.has-workflows }}')
  184. expect(workflow).not.toMatch(/steps\.filter\.outputs\.(evals|docs|workflows)(?:\s|})/)
  185. expect(workflow).toContain('oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6')
  186. expect(workflow).toContain('bun-version: 1.3.14')
  187. })
  188. test('requires successful change detection before reporting overall success', async () => {
  189. const workflow = await readFile(WORKFLOW_PATH, 'utf8')
  190. const overallStatus = workflow.slice(workflow.indexOf('# Overall status'))
  191. expect(overallStatus).toContain('needs.check-changes.result }}" == "success"')
  192. expect(overallStatus).toContain('needs.check-changes.outputs.has-evals }}" != "true"')
  193. expect(overallStatus).toContain('exit 1')
  194. })
  195. })