| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200 |
- /**
- * Integration tests: the REAL local subprocess service plus the PACKAGED
- * ripgrep binary (`@vscode/ripgrep`), exercised through `ctx.tools.execute()`.
- * These verify the WORLD — actual files on disk are discovered and grepped,
- * hostile patterns stay inert (they are plain argv elements; there is no
- * shell layer to escape), and real `rg` stderr classifies into the
- * `SEARCH_*` vocabulary. The binary ships inside the npm dependency, so the
- * suite runs on every platform without a system `rg` install; the
- * fake-service suite (tools.spec.ts) carries the coverage gate.
- */
- import { afterEach, beforeEach, describe, expect, it } from 'vitest'
- import { existsSync } from 'node:fs'
- import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- import { Context } from 'cordis'
- import { CallId } from '@deepseek-ai/dsh-llm'
- import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
- import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
- import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
- import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
- const testToolSignal = new AbortController().signal
- let dir: string
- let ctx: Context
- let callCounter = 0
- function call(name: string, args: unknown, agentObj?: object) {
- return ctx.tools.execute({
- signal: testToolSignal,
- callId: CallId(`it-${++callCounter}`),
- name,
- arguments: args,
- ...agentObj ? { agent: agentObj as never } : {},
- })
- }
- function text(result: { content: { type: string; text?: string }[] }): string {
- return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
- }
- /** The fixture workspace as a session cwd, so relative paths resolve inside `dir`. */
- const agent = () => ({ session: { header: { id: 'session-int', cwd: dir } } })
- describe('search tools over the real subprocess service + the packaged rg', () => {
- beforeEach(async () => {
- dir = await mkdtemp(join(tmpdir(), 'dsh-search-int-'))
- await mkdir(join(dir, 'src'), { recursive: true })
- await mkdir(join(dir, '.git'), { recursive: true })
- await mkdir(join(dir, 'spaced dir'), { recursive: true })
- await writeFile(join(dir, 'src', 'alpha.ts'), 'export const alpha = 1\n// TODO: refit alpha\n')
- await writeFile(join(dir, 'src', 'beta.ts'), 'export const beta = 2\n')
- await writeFile(join(dir, 'notes.md'), 'alpha appears here too\n')
- await writeFile(join(dir, '.hidden.ts'), 'export const hidden = 3\n')
- await writeFile(join(dir, '.git', 'config.ts'), 'never listed\n')
- await writeFile(join(dir, 'spaced dir', "wei'rd name.ts"), 'const inside = true\n')
- // Deterministic --sort=modified order: alpha oldest, beta newest.
- await utimes(join(dir, 'src', 'alpha.ts'), new Date(2000, 0, 1), new Date(2000, 0, 1))
- await utimes(join(dir, 'src', 'beta.ts'), new Date(2020, 0, 1), new Date(2020, 0, 1))
- ctx = new Context()
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRegistry)
- await ctx.plugin(LocalSubprocessService)
- await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true })
- })
- afterEach(async () => {
- await rm(dir, { recursive: true, force: true })
- })
- describe('glob', () => {
- it('discovers files by pattern, sorted by modification time, hidden included, .git excluded', async () => {
- const result = await call('glob', { pattern: '**/*.ts' }, agent())
- expect(result.isError).toBe(false)
- const paths = text(result).split('\n')
- expect(paths.indexOf(join('src', 'alpha.ts'))).toBeLessThan(paths.indexOf(join('src', 'beta.ts')))
- expect(paths).toContain('.hidden.ts')
- expect(paths).toContain(join('spaced dir', "wei'rd name.ts"))
- expect(paths).not.toContain(join('.git', 'config.ts'))
- expect(paths).not.toContain('notes.md')
- })
- it('scopes to a directory search root (path arg)', async () => {
- const result = await call('glob', { pattern: '*.ts', path: 'src' }, agent())
- expect(text(result).split('\n').sort()).toEqual([join('src', 'alpha.ts'), join('src', 'beta.ts')])
- })
- it('reports zero discoveries as No files found', async () => {
- expect(text(await call('glob', { pattern: '*.nomatch' }, agent()))).toBe('No files found')
- })
- it('excludes VCS internals even when the search root IS the VCS directory', async () => {
- // The prune glob alone never matches root-prefixed paths when rg is
- // rooted at .git; the paired contents glob keeps the exclusion airtight.
- expect(text(await call('glob', { pattern: '*', path: '.git' }, agent()))).toBe('No files found')
- })
- it('classifies an invalid glob as SEARCH_INVALID_PATTERN', async () => {
- const result = await call('glob', { pattern: '[' }, agent())
- expect(result.isError).toBe(true)
- expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' } })
- })
- })
- describe('grep', () => {
- it('greps a directory tree with grouped, line-numbered output', async () => {
- const result = await call('grep', { pattern: 'alpha' }, agent())
- expect(result.isError).toBe(false)
- const output = text(result)
- expect(output).toContain('Found 3 matches')
- expect(output).toContain(`${join('src', 'alpha.ts')}\nLine 1: export const alpha = 1\nLine 2: // TODO: refit alpha`)
- expect(output).toContain('notes.md\nLine 1: alpha appears here too')
- })
- it('greps a single FILE target', async () => {
- const result = await call('grep', { pattern: 'alpha', path: 'notes.md' }, agent())
- expect(text(result)).toBe('Found 1 match\n\nnotes.md\nLine 1: alpha appears here too')
- })
- it('greps a directory target with an include filter', async () => {
- const result = await call('grep', { pattern: 'alpha', path: '.', include: '*.ts' }, agent())
- const output = text(result)
- expect(output).toContain('alpha.ts')
- expect(output).not.toContain('notes.md')
- })
- it('a hostile pattern stays inert (a plain argv element, the world untouched)', async () => {
- // There is no shell layer between the argv vector and rg, so the pattern
- // is a literal regex — but the world-untouched guarantee is the shipped
- // contract, and a future shell-wrapping change must not reintroduce it.
- // The canary name carries no path so the regex stays valid on every
- // platform (a Windows path's backslashes would be regex escapes).
- const canary = join(dir, 'pwned')
- const result = await call('grep', { pattern: '$(touch pwned)' }, agent())
- expect(result.isError).toBe(false) // exit 1: found nothing, executed nothing
- expect(text(result)).toBe('No matches found')
- expect(existsSync(canary)).toBe(false)
- })
- it('a leading-dash pattern is a pattern, not a flag', async () => {
- await writeFile(join(dir, 'dashes.txt'), 'value --flag value\n')
- const result = await call('grep', { pattern: '--flag', path: 'dashes.txt' }, agent())
- expect(text(result)).toBe('Found 1 match\n\ndashes.txt\nLine 1: value --flag value')
- })
- it('classifies a real rg regex error as SEARCH_INVALID_PATTERN', async () => {
- const result = await call('grep', { pattern: '(unclosed' })
- expect(result.isError).toBe(true)
- expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } })
- })
- it('classifies a missing target as SEARCH_FAILED', async () => {
- const result = await call('grep', { pattern: 'x', path: 'no-such-dir' })
- expect(result.isError).toBe(true)
- expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } })
- })
- })
- describe('per-session cwd', () => {
- it('resolves the search in the SESSION workspace, not the process cwd', async () => {
- const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-search-session-'))
- try {
- await writeFile(join(sessionDir, 'only-here.ts'), 'const sessionFile = true\n')
- const agentObj = { session: { header: { id: 'session-int', cwd: sessionDir } } }
- const globbed = await call('glob', { pattern: '*.ts' }, agentObj)
- expect(text(globbed)).toBe('only-here.ts')
- const grepped = await call('grep', { pattern: 'sessionFile' }, agentObj)
- expect(text(grepped)).toContain('only-here.ts\nLine 1: const sessionFile = true')
- } finally {
- await rm(sessionDir, { recursive: true, force: true })
- }
- })
- })
- describe('pre-dispatch cancellation and spawn failures', () => {
- it('a pre-aborted registry call is ABORTED_BEFORE_DISPATCH', async () => {
- const controller = new AbortController()
- controller.abort()
- const result = await ctx.tools.execute({
- callId: CallId(`it-${++callCounter}`),
- name: 'grep',
- arguments: { pattern: 'x' },
- signal: controller.signal,
- })
- expect(result.isError).toBe(true)
- expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } })
- })
- it('an unusable session cwd (spawn failure) is SEARCH_FAILED', async () => {
- const gone = join(dir, 'deleted-session-dir')
- const result = await call('glob', { pattern: '*' }, { session: { header: { id: 'session-int', cwd: gone } } })
- expect(result.isError).toBe(true)
- expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } })
- expect(text(result)).toContain('could not start')
- })
- })
- })
|