130 lines
8.9 KiB
JavaScript
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');
|
|
});
|