row 227 (C2-14, #1728): scripts/test-case2.sh and its driver scripts/case2/e2e.mjs, end-to-end on a scratch root. Runs the steps on refactor (layout, sync over the fake Gitea, her thread and a fake-harness reply, diverged hold and accept); each step that waits on an unlanded row prints a skip naming it (216, 217, 220, 225, 283, 285, 286, 288). Unrun: the slot is Darkwing's (sage-3581)

This commit is contained in:
jason.woltje committed 2026-10-23 14:03:11 -05:00
1 parent 45dca69f6e
commit 2d1efa0def
3 files changed
+406

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
// 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; }
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Case 2 end-to-end acceptance on a scratch root (C2-14, #1728, row 227).
#
# Her layout in a bare repo behind a fake Gitea on 127.0.0.1 (TLS, scratch CA),
# a bus on a scratch data root with one creative instance, and the steps of the
# C2-14 brief in order. scripts/case2/e2e.mjs runs the steps in one process,
# since the remote lives only as long as that process does, and prints one line
# per step. This script checks its syntax, runs it, and counts the lines.
#
# No live host, no real model, no network past 127.0.0.1, no real key. The
# human is an in-process stand-in: human-cli.mjs refuses a caller with an agent
# among its ancestors, so a seat can't drive it. The creative instance's run is
# a fake harness bound by the driver, with no launcher.
#
# A step that waits on a row not yet on refactor prints a skip line naming the
# row, and counts as neither pass nor fail. When the row lands, its step gets
# written in the driver and its skip line goes.
#
# scripts/test-case2.sh full run
# NO_COLOR=1 scripts/test-case2.sh plain output
set -uo pipefail
cd "$(dirname "$0")/.."
SANDBOX="$(mktemp -d)"
trap 'rm -rf "$SANDBOX"' EXIT
PASS=0
FAIL=0
SKIP=0
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
C_OK=$'\033[0;32m'; C_FAIL=$'\033[0;31m'; C_RESET=$'\033[0m'
else
C_OK=""; C_FAIL=""; C_RESET=""
fi
check() {
if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $1"; else FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $1"; fi
}
DRIVER="scripts/case2/e2e.mjs"
# The steps the driver must print, in order. A missing or extra line fails.
STEPS="S1a S1b S2a S2b S2c S2d S3a S3b S3c S4 S5a S5b S6 S7 S8 S9 S10"
echo "toolchain: node $(node --version), git $(git --version | cut -d' ' -f3), openssl $(openssl version | cut -d' ' -f2)"
echo
# --- syntax ---
node --check "$DRIVER" >/dev/null 2>&1
check "syntax: $DRIVER" $?
# --- the steps ---
# The driver's own state stays in the sandbox. Git reads no global or system
# config, so a caller's hooks, signing or credential helpers can't reach it.
OUT="$SANDBOX/steps.txt"
ERR="$SANDBOX/stderr.txt"
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 \
node "$DRIVER" "$SANDBOX/root" >"$OUT" 2>"$ERR"
RC=$?
check "driver ran to the end (exit $RC)" "$RC"
if [ "$RC" -ne 0 ]; then
# The driver prints codes and first lines only; never git's stderr with a token in it.
grep '^e2e: ' "$ERR" | head -5
fi
seen=""
while IFS= read -r line; do
case "$line" in
"ok "*)
id="${line#ok }"; id="${id%% *}"
check "${line#ok }" 0 ;;
"not ok "*)
id="${line#not ok }"; id="${id%% *}"
check "${line#not ok }" 1 ;;
"skip "*)
id="${line#skip }"; id="${id%% *}"
SKIP=$((SKIP+1)); echo "skip ${line#skip }" ;;
*)
id=""
check "driver line not understood: ${line:0:200}" 1 ;;
esac
[ -n "$id" ] && seen="$seen $id"
done <"$OUT"
[ "${seen# }" = "$STEPS" ]
check "the driver printed each step once, in order" $?
# --- the token ---
# The made-up token is in the slot and nowhere else the bus writes. The driver
# checks the data root; this checks its output too.
! grep -rqF 'fixture-token-c2-14-made-up-0227' "$OUT" "$ERR"
check "the fixture token is in no driver output" $?
echo
echo "selftest: $PASS passed, $FAIL failed, $SKIP skipped (each skip names the row it waits on)"
[ "$FAIL" -eq 0 ]