// 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); });