220 lines
13 KiB
JavaScript
220 lines
13 KiB
JavaScript
// C2-14 (#1728, row 227): the Case 2 end-to-end steps on a scratch root, driven by
|
|
// scripts/test-case2.sh. Usage: node scripts/case2/e2e.mjs <scratch dir>
|
|
//
|
|
// One process holds her remote (row 199's fake Gitea on 127.0.0.1 over TLS with a scratch CA), a bus
|
|
// on a scratch data root and the steps, because the remote's port and CA live only as long as it does.
|
|
// It prints one line per step:
|
|
// ok <step> <text>
|
|
// not ok <step> <text>: <detail>
|
|
// skip <step> waits on row <ids>: <text>
|
|
// and exits 0 when it got to the end, 1 when a step threw outside its own check.
|
|
//
|
|
// Two stand-ins, both honest about what they are:
|
|
// - The human. human-cli.mjs proves its caller is a human shell and refuses any process with an agent
|
|
// among its ancestors (packages/bus/src/human.mjs), so a seat or a reviewer running this script can't
|
|
// use it. The driver binds the human in-process with bindHuman, as the bus tests do.
|
|
// - The fake harness. No launcher and no model: the driver binds the creative instance's run on her
|
|
// thread, claims it, receives her message and answers it.
|
|
//
|
|
// Nothing here reads a real key or reaches a real host. The token is made up and lives only in the slot.
|
|
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { scratchCa, fakeGitea } from '../../packages/seat/tests/fake-gitea.mjs';
|
|
import { BASE, MARKER, git, mappingEntry } from '../../packages/library/tests/helpers.mjs';
|
|
import { startBroker } from '../../packages/bus/src/runtime.mjs';
|
|
import * as library from '../../packages/library/src/index.mjs';
|
|
|
|
const TOKEN = 'fixture-token-c2-14-made-up-0227';
|
|
const USER = 'books-bot';
|
|
const BUSINESS = 'acme';
|
|
const companion = JSON.parse(readFileSync(fileURLToPath(new URL('../../roles/companion.json', import.meta.url)), 'utf8'));
|
|
|
|
const lines = [];
|
|
const out = (line) => { lines.push(line); process.stdout.write(line + '\n'); };
|
|
// step(id, text, fn): fn returns nothing on a pass and throws on a fail.
|
|
async function step(id, text, fn) {
|
|
try { await fn(); out(`ok ${id} ${text}`); }
|
|
catch (e) { out(`not ok ${id} ${text}: ${String(e?.code ?? '')} ${String(e?.message ?? e).split('\n')[0].slice(0, 300)}`.trimEnd()); }
|
|
}
|
|
const skip = (id, rows, text) => out(`skip ${id} waits on row ${rows.join(',')}: ${text}`);
|
|
const expect = (cond, message) => { if (!cond) throw new Error(message); };
|
|
const refuses = async (promise, code) => {
|
|
try { await promise; } catch (e) { if (e?.code === code) return; throw new Error(`refused ${e?.code ?? e?.message}, expected ${code}`); }
|
|
throw new Error(`answered, expected ${code}`);
|
|
};
|
|
|
|
// Every file under `dir` that holds the token, as bytes.
|
|
function holdsToken(dir) {
|
|
for (const name of readdirSync(dir, { recursive: true })) {
|
|
const file = join(dir, name);
|
|
let st;
|
|
try { st = statSync(file); } catch { continue; }
|
|
if (st.isFile() && readFileSync(file).includes(TOKEN)) return file;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function main(root) {
|
|
// Her side: a work repo in her layout's shape, cloned bare into the fake Gitea.
|
|
const work = join(root, 'her', 'books');
|
|
mkdirSync(work, { recursive: true });
|
|
git(work, 'init', '-q', '-b', 'main');
|
|
for (const [path, text] of Object.entries(BASE)) {
|
|
mkdirSync(dirname(join(work, path)), { recursive: true });
|
|
writeFileSync(join(work, path), text);
|
|
}
|
|
git(work, 'add', '-A');
|
|
git(work, 'commit', '-qm', 'her first commit');
|
|
const first = git(work, 'rev-parse', 'HEAD');
|
|
const tls = scratchCa(join(root, 'ca'));
|
|
const gitea = await fakeGitea({ dir: join(root, 'gitea'), tls, user: USER, token: TOKEN });
|
|
const remote = gitea.repo('writer', 'books');
|
|
mkdirSync(dirname(remote), { recursive: true });
|
|
git(root, 'clone', '-q', '--bare', '--no-local', work, remote);
|
|
// Her next commit, pushed to the bare repo; `from` rewinds first, which makes a force-push.
|
|
const push = (files, { from = null } = {}) => {
|
|
if (from) git(work, 'reset', '-q', '--hard', from);
|
|
for (const [path, text] of Object.entries(files)) {
|
|
mkdirSync(dirname(join(work, path)), { recursive: true });
|
|
writeFileSync(join(work, path), text);
|
|
}
|
|
git(work, 'add', '-A');
|
|
git(work, 'commit', '-qm', 'her next commit');
|
|
git(work, 'push', '-q', '--force', remote, 'HEAD:refs/heads/main');
|
|
return git(work, 'rev-parse', 'HEAD');
|
|
};
|
|
const remoteHead = () => git(remote, 'rev-parse', 'refs/heads/main');
|
|
|
|
// Our side: a scratch data root and config dir with the slot, and a bus with one creative instance.
|
|
const dataRoot = join(root, 'data'), configDir = join(root, 'config');
|
|
mkdirSync(dataRoot, { mode: 0o700 });
|
|
mkdirSync(join(configDir, 'secrets', BUSINESS), { recursive: true, mode: 0o700 });
|
|
writeFileSync(join(configDir, 'secrets', BUSINESS, library.SLOT), TOKEN + '\n', { mode: 0o600 });
|
|
const entry = { ...mappingEntry(), remote: `${gitea.origin}/writer/books.git`, user: USER };
|
|
const business = {
|
|
id: BUSINESS, human: 'jason', arbiters: { technical: 'bean', delivery: 'bean' },
|
|
roles: { bean: { definition: 'companion', authority: companion.authority } },
|
|
definitions: { companion: { messaging: companion.messaging } },
|
|
projects: { books: entry },
|
|
};
|
|
// syncCommand's argv with the scratch CA added, as packages/library/tests/sync.test.mjs does. The
|
|
// timer's period is long, so only its pass at start and the steps' own syncs fetch.
|
|
const command = (kind, url, opts) => {
|
|
const c = library.syncCommand(kind, url, opts);
|
|
return { argv: c.argv, env: { ...c.env, GIT_SSL_CAINFO: tls.ca } };
|
|
};
|
|
const bus = await startBroker({ dataRoot, businesses: { [BUSINESS]: business }, librarySync: { command, intervalMs: 3_600_000, configDir } });
|
|
try {
|
|
const broker = bus.broker;
|
|
const human = broker.bindHuman({ business: BUSINESS, human: 'jason', via: 'cli', outsideAgent: true });
|
|
const call = (cap, verb, args = {}) => Promise.resolve().then(() => broker.request(cap, { verb, args }));
|
|
// The timer's pass at start may still hold the mirror; a step's sync waits it out.
|
|
const sync = async (args = {}) => {
|
|
for (let i = 0; ; i++) {
|
|
try { return await call(human, 'library.sync', { project: 'books', ...args }); }
|
|
catch (e) { if (e?.code !== 'library-sync-busy' || i > 100) throw e; await new Promise(r => setTimeout(r, 100)); }
|
|
}
|
|
};
|
|
const refusals = () => readRefusals(dataRoot);
|
|
|
|
// S1: her layout and the mapping that reads it.
|
|
const mapping = library.checkMapping(entry);
|
|
await step('S1a', 'her layout classifies: a character is canon, a Playground note is playground, a top-level note is unmapped', () => {
|
|
const of = p => library.classify(mapping, p);
|
|
expect(of('Book One/Characters/Mara Vell.md').scope === 'canon' && of('Book One/Characters/Mara Vell.md').type === 'character', 'character');
|
|
expect(of('Book One/Chapters/Chapter 01.md').type === 'manuscript', 'chapter');
|
|
expect(of('Book One/Playground/What If.md').scope === 'playground', 'playground');
|
|
expect(of('Inbox Note.md').scope === 'unmapped', 'inbox note');
|
|
expect(of('Second World/Chapters/01.md').universe === 'second', 'second universe');
|
|
});
|
|
skip('S1b', [283], 'a supporting folder in the mapping and the editor field');
|
|
|
|
// S2: sync from her bare repo over https, and read at the served commit.
|
|
await step('S2a', 'library.sync fetches her bare repo and serves its head', async () => {
|
|
const r = await sync();
|
|
expect(r.served === remoteHead() && r.served === first, `served ${r.served}, remote ${remoteHead()}`);
|
|
expect(r.lastError === null && r.diverged === null, 'state has an error or a divergence');
|
|
});
|
|
await step('S2b', 'library.file reads her canon and playground notes at the served commit, and refuses an unmapped one', async () => {
|
|
const canon = await call(human, 'library.file', { project: 'books', path: 'Book One/Characters/Mara Vell.md' });
|
|
expect(canon.commit === first && canon.content.includes(MARKER), 'canon note');
|
|
const play = await call(human, 'library.file', { project: 'books', path: 'Book One/Playground/What If.md' });
|
|
expect(play.content.includes(MARKER), 'playground note');
|
|
await refuses(call(human, 'library.file', { project: 'books', path: 'Inbox Note.md' }), 'project-read-path');
|
|
});
|
|
await step('S2c', 'her next commit is a fast-forward and the next sync serves it', async () => {
|
|
const next = push({ 'Book One/Places/Harbor Town.md': `---\ntype: place\n---\nSalt and tar. ${MARKER}\n` });
|
|
const r = await sync();
|
|
expect(r.served === next, `served ${r.served}, expected ${next}`);
|
|
});
|
|
await step('S2d', 'the token is in the slot only: nowhere under the data root, the mirror included', () => {
|
|
const file = holdsToken(dataRoot);
|
|
expect(file === null, `token found under the data root (${file === null ? '' : file.slice(dataRoot.length)})`);
|
|
});
|
|
|
|
// S3: she asks the creative instance in its thread; the fake harness answers.
|
|
let thread, asked;
|
|
await step('S3a', 'she makes a thread with the creative instance and asks in it', async () => {
|
|
const project = (await call(human, 'project.create', { name: 'books' })).id;
|
|
const made = await call(human, 'thread.create', { project, name: 'Book One', instance: 'bean' });
|
|
expect(made.instance === 'bean', `instance ${made.instance}`);
|
|
thread = made.id;
|
|
asked = (await call(human, 'thread.send', { thread, body: 'What colour are Mara Vell\'s eyes?' })).id;
|
|
});
|
|
await step('S3b', 'the fake harness receives her message on its thread and its reply is stored in her thread', async () => {
|
|
const run = broker.bindLaunch({ business: BUSINESS, role: 'bean', run: 'bean-c2-14', harness: 'pi', thread });
|
|
await call(run, 'role.claim');
|
|
const inbox = await call(run, 'message.receive');
|
|
expect(inbox.length === 1 && inbox[0].id === asked, `received ${inbox.length}`);
|
|
await call(run, 'message.send', { to: 'human', body: 'Grey. (Book One/Characters/Mara Vell.md)', in_reply_to: asked });
|
|
const history = await call(human, 'thread.messages', { thread });
|
|
expect(history.length === 2 && history[1].from_role === 'bean' && history[1].in_reply_to === asked,
|
|
`history ${history.map(m => m.from_role).join(',')}`);
|
|
});
|
|
skip('S3c', [216, 217], 'the reply carries cites from the lookups, the guard verifies each one at the served commit, and the store keeps them');
|
|
skip('S4', [217], 'a reply citing a playground note with "as": "canon" refuses playground-cited-as-canon');
|
|
|
|
// S5: a force-push holds as library-diverged until she accepts the new tip.
|
|
await step('S5a', 'a force-push of her branch holds as library-diverged and served stays put', async () => {
|
|
const before = (await call(human, 'library.status', { project: 'books' })).served;
|
|
const forced = push({ 'Book One/Places/Harbor Town.md': `---\ntype: place\n---\nRewritten. ${MARKER}\n` }, { from: first });
|
|
await refuses(sync(), 'library-diverged');
|
|
const status = await call(human, 'library.status', { project: 'books' });
|
|
expect(status.served === before, 'served moved');
|
|
expect(status.diverged?.commit === forced, `diverged ${JSON.stringify(status.diverged)}`);
|
|
expect(refusals().includes('library-diverged'), 'no recorded refusal');
|
|
});
|
|
await step('S5b', 'accept refuses a stale commit, then serves the new tip and clears the hold', async () => {
|
|
await refuses(sync({ accept: first }), 'library-accept-stale');
|
|
const tip = remoteHead();
|
|
const r = await sync({ accept: tip });
|
|
expect(r.served === tip && r.diverged === null, `served ${r.served}`);
|
|
});
|
|
|
|
// S6-S9: agent writes, the story-path refusal, her revert and her Console save.
|
|
skip('S6', [283, 285, 286, 288], 'a seat with a fixture grant writes a supporting doc: one commit with the trailer in the bare repo, and the notice in her thread');
|
|
skip('S7', [283, 288], 'a write to a story path refuses library-write-story-path, and the bare repo\'s ref is unchanged');
|
|
skip('S8', [288], 'her revert of the write lands as one commit');
|
|
skip('S9', [283, 288], 'her library.file.save lands as one commit with her editor as author');
|
|
// S10: the Console.
|
|
skip('S10', [220, 225], 'the Console renders the reply and the notice');
|
|
} finally {
|
|
await bus.close();
|
|
await gitea.close();
|
|
}
|
|
}
|
|
|
|
// The codes of the bus's recorded refusals, oldest first, read from its store on a read-only handle.
|
|
function readRefusals(dataRoot) {
|
|
const db = new DatabaseSync(join(dataRoot, 'bus', 'bus.sqlite'), { readOnly: true });
|
|
try { return db.prepare("SELECT body FROM events WHERE kind='action.refused' ORDER BY seq").all().map(row => JSON.parse(row.body).code); }
|
|
finally { db.close(); }
|
|
}
|
|
|
|
const root = process.argv[2];
|
|
if (!root) { process.stderr.write('usage: node scripts/case2/e2e.mjs <scratch dir>\n'); process.exit(2); }
|
|
try { await main(resolve(root)); }
|
|
catch (e) { process.stderr.write(`e2e: stopped: ${e?.code ?? ''} ${String(e?.message ?? e).split('\n')[0]}\n`); process.exitCode = 1; }
|