SPB Git

Repository Public

files.test.ts 20.8 KB 379 lines typescript

1import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from 'node:fs';2import path from 'node:path';3import { afterEach, beforeEach, describe, expect, it } from 'vitest';4import { unifiedDiff } from './diff.js';5import { searchWorkspace } from './files.js';6import { finalizeToolOutput, sha256Hex, TOOLS, applyFileStateDelta } from './index.js';7import { makeFixture, type Fixture } from './testutil.js';89let fx: Fixture;10beforeEach(() => {11  fx = makeFixture();12});13afterEach(() => fx.cleanup());14const T = (name: string) => TOOLS[name]!;15const w = (rel: string, content: string | Buffer) => {16  mkdirSync(path.dirname(path.join(fx.ws, rel)), { recursive: true });17  writeFileSync(path.join(fx.ws, rel), content);18};19const r = (rel: string) => readFileSync(path.join(fx.ws, rel), 'utf8');2021describe('read_file', () => {22  it('returns numbered lines and records the sha in fileStateOut', async () => {23    w('a.txt', 'one\ntwo\nthree\n');24    const res = await fx.call(T('read_file'), { path: 'a.txt', offset: null, limit: null });25    expect(res.ok).toBe(true);26    expect(res.output).toBe('     1\tone\n     2\ttwo\n     3\tthree');27    expect(res.fileStateOut).toEqual({ 'a.txt': sha256Hex('one\ntwo\nthree\n') });28    expect(fx.state()).toEqual({ 'a.txt': sha256Hex('one\ntwo\nthree\n') });29  });30  it('supports line ranges and tells how to continue', async () => {31    w('b.txt', Array.from({ length: 10 }, (_, i) => `L${i + 1}`).join('\n'));32    const res = await fx.call(T('read_file'), { path: 'b.txt', offset: 4, limit: 2 });33    expect(res.output).toBe('     4\tL4\n     5\tL5\n[lines 4-5 of 10; continue with offset 6]');34    const past = await fx.call(T('read_file'), { path: 'b.txt', offset: 50 });35    expect(past.ok).toBe(false);36    expect(past.output).toMatch(/only 10 lines/);37  });38  it('accepts omitted optional fields as well as nulls', async () => {39    w('c.txt', 'x\n');40    expect((await fx.call(T('read_file'), { path: 'c.txt' })).ok).toBe(true);41  });42  it('refuses missing files, directories, binaries, files over 2 MiB and paths outside', async () => {43    mkdirSync(path.join(fx.ws, 'dir'));44    w('bin.dat', Buffer.from([0x89, 0x50, 0x00, 0x01]));45    w('latin1.txt', Buffer.from([0x63, 0x61, 0x66, 0xe9]));46    w('big.txt', 'x'.repeat(2 * 1024 * 1024 + 1));47    for (const [p, re] of [48      ['nope.txt', /not found/i], ['dir', /directory/], ['bin.dat', /binary/], ['latin1.txt', /binary|UTF-8/], ['big.txt', /2 MiB/],49      ['/etc/passwd', /outside the workspace/], ['../x', /"\.\."/],50    ] as const) {51      const res = await fx.call(T('read_file'), { path: p });52      expect(res.ok, p).toBe(false);53      expect(res.output, p).toMatch(re);54      expect(res.fileStateOut, p).toBeUndefined();55    }56  });57  it('handles an empty file', async () => {58    w('empty', '');59    const res = await fx.call(T('read_file'), { path: 'empty' });60    expect(res.ok).toBe(true);61    expect(res.output).toMatch(/empty/);62  });63});6465describe('list_dir', () => {66  it('lists entries with type markers, sorted', async () => {67    w('b.txt', 'hello');68    mkdirSync(path.join(fx.ws, 'adir'));69    symlinkSync('b.txt', path.join(fx.ws, 'link'));70    w('.hidden', '');71    const res = await fx.call(T('list_dir'), { path: null });72    expect(res.ok).toBe(true);73    expect(res.output).toBe('.:\n.hidden  (0 bytes)\nadir/\nb.txt  (5 bytes)\nlink@');74  });75  it('errors on files, missing dirs and escapes', async () => {76    w('f', 'x');77    expect((await fx.call(T('list_dir'), { path: 'f' })).output).toMatch(/is a file/);78    expect((await fx.call(T('list_dir'), { path: 'nope' })).ok).toBe(false);79    expect((await fx.call(T('list_dir'), { path: '/' })).output).toMatch(/outside/);80    mkdirSync(path.join(fx.ws, 'e'));81    expect((await fx.call(T('list_dir'), { path: 'e' })).output).toMatch(/empty/);82  });83});8485describe('search', () => {86  beforeEach(() => {87    w('src/alpha.ts', 'export const alpha = 1;\n// TODO: fix\n');88    w('src/beta.py', 'def beta():\n    return "Alpha"\n');89    w('docs/notes.md', 'nothing here\n');90    w('.git/config', 'alpha = hidden\n');91    w('bin.dat', Buffer.from([0, 1, 2, 97, 108, 112, 104, 97]));92  });93  for (const forceFallback of [false, true]) {94    const label = forceFallback ? 'fallback walker' : 'ripgrep';95    it(`content search (${label})`, async () => {96      const res = await searchWorkspace({ pattern: 'alpha', mode: 'content' }, fx.ctx, { forceFallback });97      expect(res.ok).toBe(true);98      expect(res.output).toContain('src/alpha.ts:1:export const alpha = 1;');99      expect(res.output).not.toContain('.git/config');100      expect(res.output).not.toContain('bin.dat');101      expect(res.output).not.toContain('beta.py');102      const ci = await searchWorkspace({ pattern: 'alpha', mode: 'content', caseInsensitive: true, glob: '*.py' }, fx.ctx, { forceFallback });103      expect(ci.output).toContain('src/beta.py:2:');104      expect(ci.output).not.toContain('alpha.ts');105      const none = await searchWorkspace({ pattern: 'zzz-not-there', mode: 'content' }, fx.ctx, { forceFallback });106      expect(none.ok).toBe(true);107      expect(none.output).toMatch(/No matches/);108    });109    it(`name search (${label})`, async () => {110      const frag = await searchWorkspace({ pattern: 'ALPHA', mode: 'name' }, fx.ctx, { forceFallback });111      expect(frag.output.trim()).toBe('src/alpha.ts');112      const glob = await searchWorkspace({ pattern: '*.py', mode: 'name', path: 'src' }, fx.ctx, { forceFallback });113      expect(glob.output.trim()).toBe('src/beta.py');114    });115    it(`does not follow symlinks out of the workspace (${label})`, async () => {116      mkdirSync(path.join(fx.root, 'outside'));117      writeFileSync(path.join(fx.root, 'outside', 'secret.txt'), 'alpha secret\n');118      symlinkSync(path.join(fx.root, 'outside'), path.join(fx.ws, 'out'));119      const res = await searchWorkspace({ pattern: 'alpha secret', mode: 'content' }, fx.ctx, { forceFallback });120      expect(res.output).toMatch(/No matches/);121      const direct = await searchWorkspace({ pattern: 'alpha', mode: 'content', path: 'out' }, fx.ctx, { forceFallback });122      expect(direct.ok).toBe(false);123      expect(direct.output).toMatch(/symbolic link/);124    });125  }126  it('reports an invalid regex as an error (ripgrep)', async () => {127    const res = await fx.call(T('search'), { pattern: '(unclosed', mode: 'content' });128    expect(res.ok).toBe(false);129    expect(res.output).toMatch(/search failed/);130  });131  it('treats a pattern starting with a dash as a pattern, not an option', async () => {132    w('opt.txt', 'value --pre=evil here\n');133    const res = await fx.call(T('search'), { pattern: '--pre=evil', mode: 'content' });134    expect(res.ok).toBe(true);135    expect(res.output).toContain('opt.txt:1:');136  });137  it('truncates at 200 results', async () => {138    for (let i = 0; i < 10; i++) w(`many/f${i}.txt`, Array.from({ length: 40 }, () => 'needle').join('\n'));139    const res = await fx.call(T('search'), { pattern: 'needle', mode: 'content' });140    expect(res.output).toMatch(/results truncated at 200/);141    expect(res.output.split('\n').filter((l) => l.includes(':needle')).length).toBe(200);142  });143});144145describe('write_file', () => {146  it('creates files (with parent directories) and returns a diff', async () => {147    const res = await fx.call(T('write_file'), { path: 'new/dir/a.txt', content: 'hello\nworld\n' });148    expect(res.ok).toBe(true);149    expect(res.output).toBe('Created new/dir/a.txt (2 lines, 12 bytes).');150    expect(r('new/dir/a.txt')).toBe('hello\nworld\n');151    expect(res.diff).toBe('--- /dev/null\n+++ b/new/dir/a.txt\n@@ -0,0 +1,2 @@\n+hello\n+world\n');152    expect(fx.state()['new/dir/a.txt']).toBe(sha256Hex('hello\nworld\n'));153    expect(readdirSync(path.join(fx.ws, 'new/dir'))).toEqual(['a.txt']); // no temp file left154  });155  it('lets a run overwrite what it wrote or read, and refuses unread or changed files', async () => {156    await fx.call(T('write_file'), { path: 'a.txt', content: 'v1\n' });157    const again = await fx.call(T('write_file'), { path: 'a.txt', content: 'v2\n' });158    expect(again.ok).toBe(true);159    expect(again.output).toMatch(/^Overwrote/);160    expect(again.diff).toContain('-v1\n+v2');161162    w('other.txt', 'pre-existing\n');163    const unread = await fx.call(T('write_file'), { path: 'other.txt', content: 'x' });164    expect(unread.ok).toBe(false);165    expect(unread.output).toMatch(/already exists.*read_file/s);166    expect(r('other.txt')).toBe('pre-existing\n');167168    w('a.txt', 'changed behind our back\n');169    const stale = await fx.call(T('write_file'), { path: 'a.txt', content: 'v3\n' });170    expect(stale.ok).toBe(false);171    expect(stale.output).toMatch(/changed on disk/);172    await fx.call(T('read_file'), { path: 'a.txt' });173    expect((await fx.call(T('write_file'), { path: 'a.txt', content: 'v3\n' })).ok).toBe(true);174  });175  it('is idempotent: same content again is a no-op even with an empty file state (replayed step)', async () => {176    w('a.txt', 'same\n');177    const res = await T('write_file').run({ path: 'a.txt', content: 'same\n' }, { ...fx.ctx, fileState: {} });178    expect(res.ok).toBe(true);179    expect(res.output).toMatch(/no change/);180    expect(res.fileStateOut).toEqual({ 'a.txt': sha256Hex('same\n') });181  });182  it('preserves the file mode and replaces symlinks/hard links instead of writing through them', async () => {183    w('run.sh', '#!/bin/sh\n');184    chmodSync(path.join(fx.ws, 'run.sh'), 0o755);185    await fx.call(T('read_file'), { path: 'run.sh' });186    await fx.call(T('write_file'), { path: 'run.sh', content: '#!/bin/sh\necho hi\n' });187    expect(statSync(path.join(fx.ws, 'run.sh')).mode & 0o777).toBe(0o755);188  });189  it('refuses protected paths, escapes, directories and oversized content', async () => {190    mkdirSync(path.join(fx.ws, 'd'));191    w('file', 'x');192    for (const [p, re] of [193      ['.git/config', /protected/], ['.git/hooks/pre-commit', /protected/], ['AGENTS.md', /protected/], ['CLAUDE.md', /protected/], ['.aos/x', /protected/],194      ['.zshrc', /protected/], ['/tmp/aos-outside-file', /outside/], ['../escape', /"\.\."/], ['d', /directory/], ['file/child', /parent of this path is a file/],195    ] as const) {196      const res = await fx.call(T('write_file'), { path: p, content: 'evil' });197      expect(res.ok, p).toBe(false);198      expect(res.output, p).toMatch(re);199    }200    expect(existsSync(path.join(fx.ws, '.git'))).toBe(false);201    expect(existsSync(path.join(fx.ws, '.aos'))).toBe(false);202    expect(existsSync(path.join(fx.ws, 'AGENTS.md'))).toBe(false);203    expect(existsSync('/tmp/aos-outside-file')).toBe(false);204    const big = await fx.call(T('write_file'), { path: 'big', content: 'x'.repeat(2 * 1024 * 1024 + 1) });205    expect(big.output).toMatch(/2 MiB/);206  });207});208209describe('edit_file', () => {210  it('replaces a unique string after a read and returns a diff', async () => {211    w('a.ts', 'const a = 1;\nconst b = 2;\nconst c = 3;\n');212    await fx.call(T('read_file'), { path: 'a.ts' });213    const res = await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'const b = 2;', newString: 'const b = 20;', replaceAll: null });214    expect(res.ok).toBe(true);215    expect(res.output).toBe('Edited a.ts: 1 replacement.');216    expect(r('a.ts')).toBe('const a = 1;\nconst b = 20;\nconst c = 3;\n');217    expect(res.diff).toBe('--- a/a.ts\n+++ b/a.ts\n@@ -1,3 +1,3 @@\n const a = 1;\n-const b = 2;\n+const b = 20;\n const c = 3;\n');218    // the edit refreshed the state: a second edit works without re-reading219    expect((await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'const c = 3;', newString: '' })).ok).toBe(true);220    expect(r('a.ts')).toBe('const a = 1;\nconst b = 20;\n\n');221  });222  it('staleness: never read ⇒ refused with instructions', async () => {223    w('a.ts', 'x = 1\n');224    const res = await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'x = 1', newString: 'x = 2' });225    expect(res.ok).toBe(false);226    expect(res.output).toMatch(/have not read a\.ts.*read_file/s);227    expect(r('a.ts')).toBe('x = 1\n');228  });229  it('staleness: changed on disk since the read ⇒ refused; re-reading unblocks', async () => {230    w('a.ts', 'x = 1\n');231    await fx.call(T('read_file'), { path: 'a.ts' });232    w('a.ts', 'x = 1\ny = 5\n'); // e.g. a formatter run by the shell233    const res = await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'x = 1', newString: 'x = 2' });234    expect(res.ok).toBe(false);235    expect(res.output).toMatch(/changed on disk.*Read it again/s);236    expect(r('a.ts')).toBe('x = 1\ny = 5\n');237    await fx.call(T('read_file'), { path: 'a.ts', offset: 1, limit: 1 }); // a partial read counts238    expect((await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'x = 1', newString: 'x = 2' })).ok).toBe(true);239    expect(r('a.ts')).toBe('x = 2\ny = 5\n');240  });241  it('the file state is a plain serialisable value that survives a replay', async () => {242    w('a.ts', 'x = 1\n');243    const read = await fx.call(T('read_file'), { path: 'a.ts' });244    // what the engine journals and folds on replay:245    const journaled = JSON.parse(JSON.stringify(read.fileStateOut)) as Record<string, string>;246    const rebuilt = applyFileStateDelta(applyFileStateDelta(undefined, journaled), { 'gone.txt': null });247    expect(rebuilt).toEqual({ 'a.ts': sha256Hex('x = 1\n') });248    const res = await T('edit_file').run({ path: 'a.ts', oldString: 'x = 1', newString: 'x = 3' }, { ...fx.ctx, fileState: rebuilt, idemKey: 'k1' });249    expect(res.ok).toBe(true);250    expect(applyFileStateDelta(rebuilt, res.fileStateOut)).toEqual({ 'a.ts': sha256Hex('x = 3\n') });251  });252  it('a re-executed edit step (crash after the write, before the journal) is recognised, a different step is not', async () => {253    w('a.ts', 'x = 1\n');254    const read = await fx.call(T('read_file'), { path: 'a.ts' });255    const stateBefore = applyFileStateDelta(undefined, read.fileStateOut);256    const input = { path: 'a.ts', oldString: 'x = 1', newString: 'x = 2' };257    const first = await T('edit_file').run(input, { ...fx.ctx, fileState: stateBefore, idemKey: 'step-7' });258    expect(first.ok).toBe(true);259    // the worker died before recording `first`: same step, same idemKey, same (old) file state260    const second = await T('edit_file').run(input, { ...fx.ctx, fileState: stateBefore, idemKey: 'step-7' });261    expect(second.ok).toBe(true);262    expect(second.output).toMatch(/already applied/);263    expect(second.fileStateOut).toEqual(first.fileStateOut);264    expect(r('a.ts')).toBe('x = 2\n');265    const other = await T('edit_file').run(input, { ...fx.ctx, fileState: stateBefore, idemKey: 'step-8' });266    expect(other.ok).toBe(false);267    expect(other.output).toMatch(/changed on disk/);268  });269  it('fails when the string is absent, ambiguous, identical or the file is missing', async () => {270    w('a.ts', 'foo\nfoo\nbar\n');271    await fx.call(T('read_file'), { path: 'a.ts' });272    const absent = await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'nope', newString: 'x' });273    expect(absent.output).toMatch(/not found in a\.ts/);274    const ambiguous = await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'foo', newString: 'x' });275    expect(ambiguous.output).toMatch(/occurs 2 times/);276    const same = await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'foo', newString: 'foo' });277    expect(same.output).toMatch(/identical/);278    const missing = await fx.call(T('edit_file'), { path: 'zzz.ts', oldString: 'a', newString: 'b' });279    expect(missing.output).toMatch(/not found/i);280    for (const x of [absent, ambiguous, same, missing]) expect(x.ok).toBe(false);281    expect(r('a.ts')).toBe('foo\nfoo\nbar\n');282    const all = await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'foo', newString: '$& $1 baz', replaceAll: true });283    expect(all.output).toBe('Edited a.ts: 2 replacements.');284    expect(r('a.ts')).toBe('$& $1 baz\n$& $1 baz\nbar\n'); // replacement is literal, no $-patterns285  });286  it('treats "$" sequences literally in single replacements too', async () => {287    w('a.ts', 'price\n');288    await fx.call(T('read_file'), { path: 'a.ts' });289    await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'price', newString: "$' $` $$ $&" });290    expect(r('a.ts')).toBe("$' $` $$ $&\n");291  });292  it('refuses protected paths and symlink escapes even when "read"', async () => {293    w('.git/config', '[core]\n');294    w('AGENTS.md', 'rules\n');295    writeFileSync(path.join(fx.root, 'outside.txt'), 'outside\n');296    symlinkSync(path.join(fx.root, 'outside.txt'), path.join(fx.ws, 'link'));297    await fx.call(T('read_file'), { path: '.git/config' });298    await fx.call(T('read_file'), { path: 'AGENTS.md' });299    expect((await fx.call(T('edit_file'), { path: '.git/config', oldString: '[core]', newString: '[evil]' })).output).toMatch(/protected/);300    expect((await fx.call(T('edit_file'), { path: 'AGENTS.md', oldString: 'rules', newString: 'no rules' })).output).toMatch(/protected/);301    expect((await fx.call(T('edit_file'), { path: 'link', oldString: 'outside', newString: 'pwned' })).output).toMatch(/symbolic link/);302    expect((await fx.call(T('write_file'), { path: 'link', content: 'pwned' })).output).toMatch(/symbolic link/);303    expect((await fx.call(T('read_file'), { path: 'link' })).output).toMatch(/symbolic link/);304    expect(readFileSync(path.join(fx.root, 'outside.txt'), 'utf8')).toBe('outside\n');305    expect(r('.git/config')).toBe('[core]\n');306    expect(r('AGENTS.md')).toBe('rules\n');307  });308});309310describe('unifiedDiff', () => {311  it('is empty for identical texts and bounded for large ones', () => {312    expect(unifiedDiff('a', 'x\n', 'x\n')).toBe('');313    const a = Array.from({ length: 5000 }, (_, i) => `line ${i}`).join('\n');314    const b = Array.from({ length: 5000 }, (_, i) => `LINE ${i}`).join('\n');315    const d = unifiedDiff('big', a, b, 2000);316    expect(d.length).toBeLessThan(2100);317    expect(d).toMatch(/diff truncated/);318  });319  it('emits separate hunks with correct headers', () => {320    const a = Array.from({ length: 30 }, (_, i) => `l${i + 1}`);321    const b = [...a];322    b[1] = 'changed2';323    b[25] = 'changed26';324    b.splice(10, 0, 'inserted');325    const d = unifiedDiff('f', a.join('\n') + '\n', b.join('\n') + '\n');326    expect(d).toContain('@@ -1,5 +1,5 @@\n l1\n-l2\n+changed2\n l3');327    expect(d).toContain('@@ -8,6 +8,7 @@\n l8\n l9\n l10\n+inserted\n l11');328    expect(d).toContain('@@ -23,7 +24,7 @@');329    expect(d).toContain('-l26\n+changed26');330  });331  it('reports a trailing-newline-only change', () => {332    expect(unifiedDiff('f', 'a', 'a\n')).toMatch(/trailing newline/);333  });334});335336describe('output offload and read_artifact', () => {337  it('passes short output through', async () => {338    const out = await finalizeToolOutput('shell', 'x'.repeat(12_000), fx.ctx);339    expect(out).toEqual({ content: 'x'.repeat(12_000), artifact: null });340    expect(fx.artifacts.size).toBe(0);341  });342  it('offloads long output: head 6000 + tail 3000 + note, artifact readable by pages', async () => {343    const lines = Array.from({ length: 3000 }, (_, i) => `row ${String(i + 1).padStart(5, '0')} ${'#'.repeat(20)}`);344    const full = lines.join('\n');345    const out = await finalizeToolOutput('shell', full, fx.ctx);346    expect(out.artifact).not.toBeNull();347    const name = out.artifact!.name;348    expect(name).toMatch(/^shell-[0-9a-f]{16}\.txt$/);349    expect(out.artifact).toEqual({ name, sha256: sha256Hex(full), size: Buffer.byteLength(full) });350    expect(out.content.startsWith(full.slice(0, 6000))).toBe(true);351    expect(out.content).toContain(full.slice(-3000));352    expect(out.content).toContain(`read_artifact with {"name":"${name}"`);353    expect(out.content.length).toBeLessThan(10_000);354    expect(fx.artifacts.get(name)?.toString()).toBe(full);355    // deterministic (replay-safe)356    expect((await finalizeToolOutput('shell', full, fx.ctx)).content).toBe(out.content);357358    const page = await fx.call(T('read_artifact'), { name, offset: 1500, limit: 3 });359    expect(page.ok).toBe(true);360    expect(page.output).toBe(`  1500\t${lines[1499]}\n  1501\t${lines[1500]}\n  1502\t${lines[1501]}\n[artifact ${name}: lines 1500-1502 of 3000; continue with offset 1503]`);361    const wide = await fx.call(T('read_artifact'), { name, offset: null, limit: 2000 });362    expect(wide.output.length).toBeLessThanOrEqual(12_000); // never re-offloaded363    expect(wide.output).toMatch(/continue with offset \d+\]$/);364    const viaEngine = await fx.call(T('read_artifact'), { name: 'custom-name', offset: null, limit: null }, { getArtifact: async (n) => (n === 'custom-name' ? Buffer.from('a\nb\n') : null) });365    expect(viaEngine.output).toContain('     1\ta\n     2\tb');366  });367  it('read_artifact errors: unknown name, traversal, past the end', async () => {368    for (const name of ['nope-0000000000000000.txt', '../../etc/passwd', 'shell-zzzz.txt']) {369      const res = await fx.call(T('read_artifact'), { name });370      expect(res.ok).toBe(false);371      expect(res.output).toMatch(/not found/);372    }373    const out = await finalizeToolOutput('git_diff', 'y\n'.repeat(7000), fx.ctx);374    const past = await fx.call(T('read_artifact'), { name: out.artifact!.name, offset: 999_999 });375    expect(past.ok).toBe(false);376    expect(past.output).toMatch(/past the end/);377  });378});379
Files main
Menu

Theme