Files
stack/packages/bus/tests/project-files.test.mjs

130 lines
8.9 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { connect } from 'node:net';
import { join } from 'node:path';
import { fixture, git } from './spec-fixtures.mjs';
import { serve } from '../src/server.mjs';
import { Client } from '../src/client.mjs';
import { refusalGuidance } from '../src/refusals.mjs';
import { PROJECT_FILE_VERBS } from '../src/broker.mjs';
// Row 148 (#1642): the human-only verbs behind the Console's spec and docs editor.
const refusals = f => f.store.all("SELECT body FROM events WHERE kind='action.refused'").map(r => JSON.parse(r.body).code);
const state = f => ({ head: git(f.repo, 'rev-parse', 'HEAD'), status: git(f.repo, 'status', '--porcelain') });
const reject = (promise, code) => assert.rejects(Promise.resolve(promise), { code });
test('a human lists, opens and saves; the save is one commit by the business human', async t => {
const f = await fixture(t);
const listed = await f.call(f.human, 'project.files', { project: 'stack' });
assert.deepEqual(listed.files.map(x => x.path), ['docs/README.md', 'spec/north-star.md']);
const open = await f.call(f.human, 'project.file', { project: 'stack', path: 'spec/north-star.md' });
assert.deepEqual(Object.keys(open).sort(), ['at', 'author', 'commit', 'content', 'head', 'path']);
assert.equal(open.commit, f.commit); assert.equal(open.head, f.commit); assert.equal(open.author, 'jason');
assert.equal(JSON.stringify(open).includes(f.repo), false);
const saved = await f.call(f.human, 'project.file.save', { project: 'stack', path: 'spec/north-star.md', base: open.commit, content: '# Edited\n\nBy hand.\n' });
assert.deepEqual(Object.keys(saved).sort(), ['commit', 'path']);
assert.equal(git(f.repo, 'rev-parse', 'HEAD'), saved.commit);
assert.equal(git(f.repo, 'rev-list', '--count', `${f.commit}..HEAD`), '1');
assert.equal(git(f.repo, 'show', '-s', '--format=%an|%s', saved.commit), 'jason|Edit spec/north-star.md');
const again = await f.call(f.human, 'project.file', { project: 'stack', path: 'spec/north-star.md' });
assert.equal(again.content, '# Edited\n\nBy hand.\n'); assert.equal(again.commit, saved.commit);
assert.equal(git(f.repo, 'status', '--porcelain'), '');
});
test('an agent and a reader refuse every file verb and nothing is written', async t => {
const f = await fixture(t);
const reader = f.broker.bindReader({ business: 'demo' });
const before = state(f), args = { project: 'stack', path: 'spec/north-star.md', base: f.commit, content: '# Agent\n' };
for (const [cap, code] of [[f.coder, 'human-required'], [f.reviewer, 'human-required'], [reader, 'read-only']])
for (const verb of PROJECT_FILE_VERBS)
await reject(f.call(cap, verb, verb === 'project.file.save' ? args : verb === 'project.file' ? { project: 'stack', path: args.path } : { project: 'stack' }), code);
assert.deepEqual(state(f), before);
assert.equal(refusals(f).filter(c => c === 'human-required').length, 6);
assert.equal(f.store.all("SELECT 1 FROM events WHERE json_extract(body,'$.action')='record.write'").length, 0);
});
test('a stale base refuses stale-base and writes nothing', async t => {
const f = await fixture(t);
const newer = f.save('# Agent edit\n', 'coder.builder'), before = state(f);
await reject(f.call(f.human, 'project.file.save', { project: 'stack', path: 'spec/north-star.md', base: f.commit, content: '# Mine\n' }), 'stale-base');
assert.deepEqual(state(f), before);
assert.equal(git(f.repo, 'show', `HEAD:spec/north-star.md`), '# Agent edit');
assert.ok(refusals(f).includes('stale-base'));
const ok = await f.call(f.human, 'project.file.save', { project: 'stack', path: 'spec/north-star.md', base: newer, content: '# Mine\n' });
assert.match(ok.commit, /^[0-9a-f]{40}$/);
});
test('paths outside the spec and docs refuse, as do unknown projects, missing files and odd arguments', async t => {
const f = await fixture(t), before = state(f);
const save = (over = {}) => f.call(f.human, 'project.file.save', { project: 'stack', path: 'spec/north-star.md', base: f.commit, content: '# X\n', ...over });
for (const path of ['project.json', 'requirements/.gitkeep', 'spec/other.md', 'docs/sub/deep.md', '../x.md', 'docs/a.txt'])
await reject(save({ path }), 'project-record-path');
for (const path of ['project.json', 'docs/sub/deep.md'])
await reject(f.call(f.human, 'project.file', { project: 'stack', path }), 'project-read-path');
await reject(save({ path: 'docs/new.md' }), 'project-read-missing');
await reject(save({ project: 'missing' }), 'project-not-declared');
await reject(save({ base: 'abc' }), 'project-record-invalid');
await reject(save({ content: '' }), 'project-record-invalid');
for (const args of [{}, { project: 'stack' }, { project: 'stack', path: 'spec/north-star.md', base: f.commit }, { project: 'stack', path: 'spec/north-star.md', base: f.commit, content: '# X\n', extra: 1 }])
await reject(f.call(f.human, 'project.file.save', args), 'invalid-request');
await reject(f.call(f.human, 'project.files', { project: 'stack', path: 'docs/README.md' }), 'invalid-request');
await reject(f.call(f.human, 'project.file', { project: 'stack' }), 'invalid-request');
assert.deepEqual(state(f), before);
});
test('without a project adapter the file verbs refuse project-not-configured', async t => {
const f = await fixture(t, { adapter: false });
for (const verb of PROJECT_FILE_VERBS) await reject(f.call(f.human, verb, { project: 'stack' }), 'project-not-configured');
});
async function raw(path, line) {
const socket = connect(path);
let input = '';
socket.on('data', b => { input += b; });
await new Promise(resolve => { socket.on('connect', resolve); });
socket.on('error', () => {});
socket.write(line);
await new Promise(resolve => { socket.on('close', resolve); socket.on('end', resolve); });
return JSON.parse(input);
}
async function socketFixture(t) {
const f = await fixture(t), path = join(f.root, 'bus.sock');
const server = await serve({ broker: f.broker, path, authenticateHuman: () => f.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) });
t.after(() => server.close());
return { ...f, path, human: new Client({ path, human: { fixture: true } }) };
}
test('on the socket a save may carry 1 MiB of text; every other request keeps the 64 KiB limit', async t => {
const f = await socketFixture(t);
const big = 'x'.repeat(1024 * 1024), args = { project: 'stack', path: 'docs/README.md', base: (await f.human.call('project.file', { project: 'stack', path: 'docs/README.md' })).commit };
await reject(f.human.call('project.file.save', { ...args, content: big + 'y' }), 'project-record-invalid');
await reject(f.human.call('project.file.save', { ...args, content: big + 'x'.repeat(70000) }), 'request-too-large');
await reject(f.human.call('agents', { pad: 'x'.repeat(70000) }), 'request-too-large');
await reject(f.human.call('project.file', { project: 'stack', path: 'docs/README.md', pad: 'x'.repeat(70000) }), 'request-too-large');
const saved = await f.human.call('project.file.save', { ...args, content: big });
assert.equal(git(f.repo, 'show', `${saved.commit}:docs/README.md`).length, big.length);
const open = await f.human.call('project.file', { project: 'stack', path: 'docs/README.md' });
assert.equal(open.content.length, big.length);
});
test('a request that names the save verb first and another verb last is refused at 64 KiB', async t => {
const f = await socketFixture(t);
const pad = 'x'.repeat(100000);
const sneaky = `{"human":{"fixture":true},"verb":"project.file.save","verb":"agents","args":{"pad":"${pad}"}}\n`;
assert.deepEqual(await raw(f.path, sneaky), { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
const unmarked = await raw(f.path, JSON.stringify({ human: { fixture: true }, verb: 'agents', args: { pad } }) + '\n');
assert.deepEqual(unmarked, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
const over = await raw(f.path, JSON.stringify({ human: { fixture: true }, verb: 'project.file.save', args: { pad: 'x'.repeat(1024 * 1024 + 80000) } }) + '\n');
assert.deepEqual(over, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
});
test('two saves racing for one repository make one commit', async t => {
const f = await socketFixture(t);
const base = (await f.human.call('project.file', { project: 'stack', path: 'docs/README.md' })).commit;
const before = git(f.repo, 'rev-parse', 'HEAD');
const results = await Promise.allSettled(['one', 'two'].map(name =>
new Client({ path: f.path, human: { fixture: true } }).call('project.file.save', { project: 'stack', path: 'docs/README.md', base, content: `# ${name}\n` })));
assert.equal(results.filter(r => r.status === 'fulfilled').length, 1);
assert.match(results.find(r => r.status === 'rejected').reason.code, /^(project-busy|stale-base)$/);
assert.equal(git(f.repo, 'rev-list', '--count', `${before}..HEAD`), '1');
});