integration.spec.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. /**
  2. * Integration tests: the REAL local bash executor (`dsh-bash-local`) plus a
  3. * REAL ripgrep binary, exercised through `ctx.tools.execute()`. These verify
  4. * the WORLD — actual files on disk are discovered and grepped, hostile
  5. * patterns stay inert in a real shell, and real `rg` stderr classifies into
  6. * the `SEARCH_*` vocabulary. The whole suite self-skips when `rg` is not on
  7. * PATH (a CI accommodation mirroring the keyless e2e skip); the fake-executor
  8. * suite (tools.spec.ts) carries the coverage gate.
  9. */
  10. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  11. import { spawnSync } from 'node:child_process'
  12. import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
  13. import { tmpdir } from 'node:os'
  14. import { join } from 'node:path'
  15. import { Context } from 'cordis'
  16. import { CallId } from '@deepseek-ai/dsh-llm'
  17. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  18. import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
  19. import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
  20. import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
  21. const testToolSignal = new AbortController().signal
  22. const hasRg = spawnSync('rg', ['--version'], { encoding: 'utf8' }).status === 0
  23. let dir: string
  24. let ctx: Context
  25. let callCounter = 0
  26. function call(name: string, args: unknown, agentObj?: object) {
  27. return ctx.tools.execute({
  28. signal: testToolSignal,
  29. callId: CallId(`it-${++callCounter}`),
  30. name,
  31. arguments: args,
  32. ...agentObj ? { agent: agentObj as never } : {},
  33. })
  34. }
  35. function text(result: { content: { type: string; text?: string }[] }): string {
  36. return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
  37. }
  38. describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () => {
  39. beforeEach(async () => {
  40. dir = await mkdtemp(join(tmpdir(), 'dsh-search-int-'))
  41. await mkdir(join(dir, 'src'), { recursive: true })
  42. await mkdir(join(dir, '.git'), { recursive: true })
  43. await mkdir(join(dir, 'spaced dir'), { recursive: true })
  44. await writeFile(join(dir, 'src', 'alpha.ts'), 'export const alpha = 1\n// TODO: refit alpha\n')
  45. await writeFile(join(dir, 'src', 'beta.ts'), 'export const beta = 2\n')
  46. await writeFile(join(dir, 'notes.md'), 'alpha appears here too\n')
  47. await writeFile(join(dir, '.hidden.ts'), 'export const hidden = 3\n')
  48. await writeFile(join(dir, '.git', 'config.ts'), 'never listed\n')
  49. await writeFile(join(dir, 'spaced dir', "wei'rd \"name\".ts"), 'const inside = true\n')
  50. // Deterministic --sort=modified order: alpha oldest, beta newest.
  51. await utimes(join(dir, 'src', 'alpha.ts'), new Date(2000, 0, 1), new Date(2000, 0, 1))
  52. await utimes(join(dir, 'src', 'beta.ts'), new Date(2020, 0, 1), new Date(2020, 0, 1))
  53. ctx = new Context()
  54. await ctx.plugin(SystemPrompt)
  55. await ctx.plugin(ToolRegistry)
  56. await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 })
  57. await ctx.plugin(ToolFsSearch)
  58. })
  59. afterEach(async () => {
  60. await rm(dir, { recursive: true, force: true })
  61. })
  62. describe('glob', () => {
  63. it('discovers files by pattern, sorted by modification time, hidden included, .git excluded', async () => {
  64. const result = await call('glob', { pattern: '**/*.ts' })
  65. expect(result.isError).toBe(false)
  66. const paths = text(result).split('\n')
  67. expect(paths.indexOf('src/alpha.ts')).toBeLessThan(paths.indexOf('src/beta.ts'))
  68. expect(paths).toContain('.hidden.ts')
  69. expect(paths).toContain("spaced dir/wei'rd \"name\".ts")
  70. expect(paths).not.toContain('.git/config.ts')
  71. expect(paths).not.toContain('notes.md')
  72. })
  73. it('scopes to a directory search root (path arg)', async () => {
  74. const result = await call('glob', { pattern: '*.ts', path: 'src' })
  75. expect(text(result).split('\n').sort()).toEqual(['src/alpha.ts', 'src/beta.ts'])
  76. })
  77. it('reports zero discoveries as No files found', async () => {
  78. expect(text(await call('glob', { pattern: '*.nomatch' }))).toBe('No files found')
  79. })
  80. it('excludes VCS internals even when the search root IS the VCS directory', async () => {
  81. // The prune glob alone never matches root-prefixed paths when rg is
  82. // rooted at .git; the paired contents glob keeps the exclusion airtight.
  83. expect(text(await call('glob', { pattern: '*', path: '.git' }))).toBe('No files found')
  84. })
  85. it('classifies an invalid glob as SEARCH_INVALID_PATTERN', async () => {
  86. const result = await call('glob', { pattern: '[' })
  87. expect(result.isError).toBe(true)
  88. expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' } })
  89. })
  90. })
  91. describe('grep', () => {
  92. it('greps a directory tree with grouped, line-numbered output', async () => {
  93. const result = await call('grep', { pattern: 'alpha' })
  94. expect(result.isError).toBe(false)
  95. const output = text(result)
  96. expect(output).toContain('Found 3 matches')
  97. expect(output).toContain('src/alpha.ts\nLine 1: export const alpha = 1\nLine 2: // TODO: refit alpha')
  98. expect(output).toContain('notes.md\nLine 1: alpha appears here too')
  99. })
  100. it('greps a single FILE target', async () => {
  101. const result = await call('grep', { pattern: 'alpha', path: 'notes.md' })
  102. expect(text(result)).toBe('Found 1 match\n\nnotes.md\nLine 1: alpha appears here too')
  103. })
  104. it('greps a directory target with an include filter', async () => {
  105. const result = await call('grep', { pattern: 'alpha', path: '.', include: '*.ts' })
  106. const output = text(result)
  107. expect(output).toContain('alpha.ts')
  108. expect(output).not.toContain('notes.md')
  109. })
  110. it('a hostile pattern stays inert (no command substitution, the world untouched)', async () => {
  111. const canary = join(dir, 'pwned')
  112. const result = await call('grep', { pattern: `$(touch ${canary})` })
  113. expect(result.isError).toBe(false) // exit 1: found nothing, executed nothing
  114. expect(text(result)).toBe('No matches found')
  115. expect(spawnSync('test', ['-e', canary]).status).not.toBe(0)
  116. })
  117. it('a leading-dash pattern is a pattern, not a flag', async () => {
  118. await writeFile(join(dir, 'dashes.txt'), 'value --flag value\n')
  119. const result = await call('grep', { pattern: '--flag', path: 'dashes.txt' })
  120. expect(text(result)).toBe('Found 1 match\n\ndashes.txt\nLine 1: value --flag value')
  121. })
  122. it('classifies a real rg regex error as SEARCH_INVALID_PATTERN', async () => {
  123. const result = await call('grep', { pattern: '(unclosed' })
  124. expect(result.isError).toBe(true)
  125. expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } })
  126. })
  127. it('classifies a missing target as SEARCH_FAILED', async () => {
  128. const result = await call('grep', { pattern: 'x', path: 'no-such-dir' })
  129. expect(result.isError).toBe(true)
  130. expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } })
  131. })
  132. })
  133. describe('per-session cwd', () => {
  134. it('resolves the search in the SESSION workspace, not the executor config cwd', async () => {
  135. const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-search-session-'))
  136. try {
  137. await writeFile(join(sessionDir, 'only-here.ts'), 'const sessionFile = true\n')
  138. const agentObj = { session: { header: { id: 'session-int', cwd: sessionDir } } }
  139. const globbed = await call('glob', { pattern: '*.ts' }, agentObj)
  140. expect(text(globbed)).toBe('only-here.ts')
  141. const grepped = await call('grep', { pattern: 'sessionFile' }, agentObj)
  142. expect(text(grepped)).toContain('only-here.ts\nLine 1: const sessionFile = true')
  143. } finally {
  144. await rm(sessionDir, { recursive: true, force: true })
  145. }
  146. })
  147. })
  148. describe('pre-dispatch cancellation and bash-start failures', () => {
  149. it('a pre-aborted registry call is ABORTED_BEFORE_DISPATCH', async () => {
  150. const controller = new AbortController()
  151. controller.abort()
  152. const result = await ctx.tools.execute({
  153. callId: CallId(`it-${++callCounter}`),
  154. name: 'grep',
  155. arguments: { pattern: 'x' },
  156. signal: controller.signal,
  157. })
  158. expect(result.isError).toBe(true)
  159. expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } })
  160. })
  161. it('an unusable session cwd (spawn failure) is SEARCH_FAILED', async () => {
  162. const gone = join(dir, 'deleted-session-dir')
  163. const result = await call('glob', { pattern: '*' }, { session: { header: { id: 'session-int', cwd: gone } } })
  164. expect(result.isError).toBe(true)
  165. expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } })
  166. expect(text(result)).toContain('could not start')
  167. })
  168. })
  169. })