- An --as target that lands on another pack item's name refuses invalid-request (definitions and workflows); the preview no longer shows two adds for one path. - A workflow step role naming one of the pack's definitions follows its --as rename, so an inline definition renamed at preview applies. - GET /api/board stays the Board read; only POST /api/board is the pack board write. - Tombstones key on pack id, origin and project: a saved template reusing a shipped id is another pack. - An apply that adds nothing and proposes what the last apply proposed writes nothing, so retries do not fill the 256 packs entries. - mosaic org pack list refuses inside an agent run like the other words. - Tests pin coder-1's credential lines, skills.notEnabled and the member's unsuffixed pack.apply and board.set refusals; the CONSOLE_VERBS pins in two bus tests name the five pack verbs.
100 lines
5.8 KiB
JavaScript
100 lines
5.8 KiB
JavaScript
// Starter packs SP4: `mosaic org pack|board|workflow`. Each word goes through the organization's bus as
|
|
// its human; pack apply writes the packs entry, and workflow remove takes out only that workflow.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { join } from 'node:path';
|
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
import { main } from '../src/cli.mjs';
|
|
import { ORG_USAGE, PACKS_USAGE } from '../src/org.mjs';
|
|
import { bootConfig, loadSystem } from '../src/config.mjs';
|
|
import { BusError } from '../../bus/src/broker.mjs';
|
|
import { startBroker } from '../../bus/src/runtime.mjs';
|
|
import { fixture, io, tmp } from './helpers.mjs';
|
|
|
|
const FLOW = (title) => ({ description: `${title}.`, steps: [{ title, state: 'todo', roles: ['human'] }] });
|
|
|
|
async function setup(t) {
|
|
const root = tmp(t);
|
|
const f = fixture(root, 'acme');
|
|
const file = join(root, 'config/businesses/acme.json');
|
|
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
|
|
const b = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
|
|
t.after(() => b.close());
|
|
const human = b.broker.bindHuman({ business: 'acme', human: f.doc.human, via: 'cli', outsideAgent: true });
|
|
const calls = [];
|
|
// The human transport's shape: a bus refusal arrives as its code.
|
|
const deps = { system: { dataRoot: f.dataRoot }, transport: () => async (verb, args = {}) => {
|
|
calls.push([verb, Object.keys(args).sort()]);
|
|
try { return structuredClone(await b.broker.request(human, { verb, args })); }
|
|
catch (e) { if (!(e instanceof BusError)) throw e; throw Object.assign(new Error(e.code), { code: e.code }); }
|
|
} };
|
|
const run = async (args, env = f.env) => { const x = io(env); await main(['org', ...args], x, deps); return JSON.parse(x.out.text); };
|
|
const doc = () => JSON.parse(readFileSync(file, 'utf8'));
|
|
return { root, f, file, calls, run, doc };
|
|
}
|
|
|
|
test('SP4 mosaic org pack: list reads the shipped packs, preview writes nothing, apply with its digest writes the packs entry', async (t) => {
|
|
const s = await setup(t);
|
|
assert.ok(ORG_USAGE.includes(PACKS_USAGE.slice('mosaic org '.length)));
|
|
const listed = await s.run(['pack', 'list']);
|
|
assert.ok(listed.packs.some((p) => p.id === 'coding' && p.origin === 'shipped'));
|
|
assert.deepEqual(s.calls, [], 'pack list reads the data root; it asks no bus');
|
|
|
|
const before = readFileSync(s.file);
|
|
const preview = await s.run(['pack', 'preview', 'acme', 'coding']);
|
|
assert.match(preview.digest, /^[0-9a-f]{64}$/);
|
|
assert.deepEqual(readFileSync(s.file), before, 'preview writes nothing');
|
|
|
|
await assert.rejects(s.run(['pack', 'apply', 'acme', 'coding']), (e) => e.exitCode === 4 && /--digest is required/.test(e.message));
|
|
await assert.rejects(s.run(['pack', 'apply', 'acme', 'coding', '--digest', '0'.repeat(64)]), (e) => e.code === 'pack-plan-stale' && /preview again/.test(e.message));
|
|
assert.deepEqual(readFileSync(s.file), before);
|
|
|
|
const applied = await s.run(['pack', 'apply', 'acme', 'coding', '--digest', preview.digest]);
|
|
assert.equal(applied.pack, 'coding');
|
|
const packs = s.doc().packs;
|
|
assert.equal(packs.length, 1);
|
|
assert.equal(packs[0].id, 'coding');
|
|
assert.equal(packs[0].origin, 'shipped');
|
|
assert.deepEqual(s.calls.map(([verb]) => verb), ['pack.preview', 'pack.apply', 'pack.apply']);
|
|
assert.deepEqual(s.calls[2][1], ['digest', 'pack']);
|
|
});
|
|
|
|
test('SP4 mosaic org workflow remove takes out only that workflow; board set changes only the board', async (t) => {
|
|
const s = await setup(t);
|
|
const spec = join(s.root, 'flow.json');
|
|
for (const name of ['one', 'two']) {
|
|
writeFileSync(spec, JSON.stringify(FLOW(name)));
|
|
await s.run(['workflow', 'set', 'acme', name, spec]);
|
|
}
|
|
writeFileSync(spec, JSON.stringify({ columns: [{ state: 'todo', label: 'Backlog' }, { state: 'in-progress', label: 'Building' }, { state: 'in-review', label: 'Review' }, { state: 'blocked', label: 'Blocked' }, { state: 'done', label: 'Merged' }], layout: 'kanban' }));
|
|
await s.run(['board', 'set', 'acme', spec]);
|
|
const before = s.doc();
|
|
assert.deepEqual(Object.keys(before.workflows).sort(), ['one', 'two']);
|
|
|
|
await s.run(['workflow', 'remove', 'acme', 'one']);
|
|
const after = s.doc();
|
|
assert.deepEqual(after.workflows, { two: before.workflows.two });
|
|
const { workflows: _a, ...restBefore } = before;
|
|
const { workflows: _b, ...restAfter } = after;
|
|
assert.deepEqual(restAfter, restBefore, 'nothing but the one workflow changed');
|
|
assert.deepEqual(s.calls.at(-1), ['workflow.set', ['name', 'revision', 'workflow']]);
|
|
|
|
// A file that is not JSON is named without its text; an unknown word prints the usage.
|
|
writeFileSync(spec, '{"title": fixture-not-json');
|
|
await assert.rejects(s.run(['workflow', 'set', 'acme', 'three', spec]), (e) => e.exitCode === 2 && !e.message.includes('fixture-not-json'));
|
|
await assert.rejects(s.run(['workflow', 'rename', 'acme', 'two']), (e) => e.exitCode === 4 && e.message === PACKS_USAGE);
|
|
});
|
|
|
|
test('SP4 an agent shell refuses every pack, board and workflow word, pack list too, before it reads a file or calls the bus', async (t) => {
|
|
const s = await setup(t);
|
|
const agent = { ...s.f.env, MOSAIC_RUN_ID: 'run-1' };
|
|
const spec = join(s.root, 'flow.json');
|
|
writeFileSync(spec, JSON.stringify(FLOW('one')));
|
|
const before = readFileSync(s.file);
|
|
for (const args of [['pack', 'list'], ['pack', 'preview', 'acme', 'coding'], ['pack', 'apply', 'acme', 'coding', '--digest', 'a'.repeat(64)], ['board', 'set', 'acme', spec], ['workflow', 'set', 'acme', 'one', spec], ['workflow', 'remove', 'acme', 'one']]) {
|
|
await assert.rejects(s.run(args, agent), (e) => e.exitCode === 3 && /human shell/.test(e.message), args.join(' '));
|
|
}
|
|
assert.deepEqual(s.calls, []);
|
|
assert.deepEqual(readFileSync(s.file), before);
|
|
});
|