Add file browser, tests, and bin entrypoint

This commit is contained in:
seb
2026-09-23 01:39:39 +02:00
parent 80bd648412
commit 9f61a05e8b
7 changed files with 764 additions and 1 deletions

148
test/compact.test.js Normal file
View File

@@ -0,0 +1,148 @@
'use strict';
/**
* Tests for invalidateStaleReadResults in src/compact.js.
*
* Run with: node --test
* (or) node --test test/compact.test.js
*
* These are pure unit tests: they build synthetic transcript arrays and never
* call a model or touch the network. Importing compact.js also loads config.js
* (dotenv + ROOT_DIR) which is harmless and needs no API key.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { invalidateStaleReadResults } from '../src/compact.js';
// A file body long enough that the replacement marker is strictly smaller.
const BODY = Array.from({ length: 40 }, (_, i) => `line ${i} of the file body`).join('\n');
// Assistant message declaring a tool call.
const call = (id, name, args) => ({
role: 'assistant',
tool_calls: [{ id, function: { name, arguments: JSON.stringify(args) } }],
});
// A read_file tool result carrying a whole file body.
const read = (id, filename, content = BODY) =>
({ role: 'tool', tool_call_id: id, content });
// A patch tool result. `applied` is the array of mutation tags the real
// applyOperations emits, e.g. ["PATCH in src/a.js"].
const patchResult = (id, applied) =>
({ role: 'tool', tool_call_id: id, content: JSON.stringify({ applied }) });
const isMarker = (content) => {
try {
const parsed = JSON.parse(content);
return typeof parsed.note === 'string' && parsed.note.includes('overwritten meanwhile');
} catch {
return false;
}
};
test('read before a later patch is invalidated', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
const n = invalidateStaleReadResults(messages);
assert.equal(n, 1);
assert.ok(isMarker(messages[1].content), 'read result replaced with marker');
assert.equal(JSON.parse(messages[1].content).filename, 'src/a.js');
});
test('read after the last patch is left intact (still matches disk)', () => {
const messages = [
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
];
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[3].content, BODY);
});
test('a file no patch ever wrote is left intact', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/untouched.js' }),
read('r1', 'src/untouched.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[1].content, BODY);
});
test('a failed patch (no applied entries) invalidates nothing', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', []), // failure => nothing actually written
];
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[1].content, BODY);
});
test('partial failure: appliedSoFar still invalidates a written file', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
{ role: 'tool', tool_call_id: 'p1', content: JSON.stringify({ appliedSoFar: ['CREATE/WRITE src/a.js'], error: 'boom' }) },
];
assert.equal(invalidateStaleReadResults(messages), 1);
assert.ok(isMarker(messages[1].content));
});
test('CREATE/WRITE tag is recognised', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/new.js' }),
read('r1', 'src/new.js'),
call('p1', 'patch', { operations: [{ file: 'src/new.js', search: null, replace: 'hi' }] }),
patchResult('p1', ['CREATE/WRITE src/new.js']),
];
assert.equal(invalidateStaleReadResults(messages), 1);
assert.ok(isMarker(messages[1].content));
});
test('path canonicalization: ./src/a.js matches src/a.js', () => {
const messages = [
call('r1', 'read_file', { filename: './src/a.js' }),
read('r1', './src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
assert.equal(invalidateStaleReadResults(messages), 1);
assert.ok(isMarker(messages[1].content));
});
test('size guard: a tiny read is never traded for a longer marker', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js', 'tiny'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[1].content, 'tiny');
});
test('idempotent: a second pass changes nothing', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
assert.equal(invalidateStaleReadResults(messages), 1);
const afterFirst = messages[1].content;
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[1].content, afterFirst);
});

146
test/file-browser.test.js Normal file
View File

@@ -0,0 +1,146 @@
'use strict';
/**
* Tests for the file-browser helpers in src/file-browser.js.
*
* Run with: node --test
* (or) node --test test/file-browser.test.js
*
* Pure unit tests except for `listEntries`/`readLines`, which read the real
* workspace (ROOT_DIR defaults to the process cwd, i.e. the repo root).
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
listEntries,
readLines,
formatMention,
normalizeRange,
windowStart,
parentOf,
baseOf,
} from '../src/file-browser.js';
// ─── mention formatting ───────────────────────────────────────────────────────
test('formatMention: whole file', () => {
assert.equal(formatMention('a/b.js'), '@a/b.js');
});
test('formatMention: single line collapses start==end', () => {
assert.equal(formatMention('a/b.js', 10, 10), '@a/b.js:10');
assert.equal(formatMention('a/b.js', 10, null), '@a/b.js:10');
});
test('formatMention: line range', () => {
assert.equal(formatMention('a/b.js', 10, 20), '@a/b.js:10-20');
});
test('formatMention: normalizes backslashes and trailing slashes', () => {
assert.equal(formatMention('a\\b.js', 1, 2), '@a/b.js:1-2');
assert.equal(formatMention('src/', 1, 2), '@src:1-2');
});
// ─── range normalization ──────────────────────────────────────────────────────
test('normalizeRange: anchor above cursor', () => {
assert.deepEqual(normalizeRange(2, 5), { start: 3, end: 6 });
});
test('normalizeRange: anchor below cursor', () => {
assert.deepEqual(normalizeRange(5, 2), { start: 3, end: 6 });
});
test('normalizeRange: single line', () => {
assert.deepEqual(normalizeRange(4, 4), { start: 5, end: 5 });
});
// ─── windowing ────────────────────────────────────────────────────────────────
test('windowStart: everything fits', () => {
assert.equal(windowStart(0, 5, 200), 0);
assert.equal(windowStart(4, 5, 200), 0);
});
test('windowStart: centers around the cursor', () => {
assert.equal(windowStart(100, 1000, 20), 90);
});
test('windowStart: clamps at the top', () => {
assert.equal(windowStart(0, 1000, 20), 0);
assert.equal(windowStart(3, 1000, 20), 0);
});
test('windowStart: clamps at the bottom', () => {
assert.equal(windowStart(999, 1000, 20), 980);
});
// ─── path helpers ─────────────────────────────────────────────────────────────
test('parentOf / baseOf', () => {
assert.equal(parentOf(''), '');
assert.equal(parentOf('src'), '');
assert.equal(parentOf('src/tools'), 'src');
assert.equal(baseOf('src/tools'), 'tools');
assert.equal(baseOf('README.md'), 'README.md');
});
// ─── listing (real workspace) ─────────────────────────────────────────────────
test('listEntries: dirs before files, dot-dirs and gitignored hidden', () => {
const entries = listEntries('');
const names = entries.map((e) => e.name);
// node_modules is hardcoded-ignored, .git is a dot-dir: both must be absent.
assert.ok(!names.includes('node_modules'), 'node_modules hidden');
assert.ok(!names.includes('.git'), '.git hidden');
// A root listing has no synthetic parent entry.
assert.ok(!names.includes('..'), 'no parent entry at root');
// src/ is a directory and README.md a file: directory must come first.
const srcIdx = names.indexOf('src');
const readmeIdx = names.indexOf('README.md');
assert.ok(srcIdx !== -1, 'src listed');
assert.ok(readmeIdx !== -1, 'README.md listed');
assert.ok(srcIdx < readmeIdx, 'directories sort before files');
const src = entries.find((e) => e.name === 'src');
assert.equal(src.type, 'd');
assert.equal(src.rel, 'src');
const readme = entries.find((e) => e.name === 'README.md');
assert.equal(readme.type, 'f');
assert.equal(readme.rel, 'README.md');
});
test('listEntries: nested listing prepends a parent entry', () => {
const entries = listEntries('src');
assert.equal(entries[0].name, '..');
assert.equal(entries[0].type, 'parent');
assert.equal(entries[0].rel, '');
assert.ok(entries.some((e) => e.name === 'fido.js' && e.type === 'f'));
});
// ─── reading (real workspace) ─────────────────────────────────────────────────
test('readLines: reads a real file into lines', () => {
const { lines, binary } = readLines('README.md');
assert.equal(binary, false);
assert.ok(Array.isArray(lines));
assert.ok(lines.length > 0);
assert.ok(lines[0].includes('FIDO'));
});
test('readLines: rejects paths that escape the workspace', () => {
const res = readLines('../secrets.txt');
assert.equal(res.error, true);
assert.deepEqual(res.lines, []);
});
test('readLines: missing file reports error, not a throw', () => {
const res = readLines('does/not/exist.js');
assert.equal(res.error, true);
assert.deepEqual(res.lines, []);
});