Files
stack/packages/cli/tests/org-packs.test.mjs
jason.woltje 1ba3288bec fix(packs): SP4 review round, --as collisions, step roles, the board route and repeat applies (#1873)
- 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.
2026-11-12 17:03:56 -06:00

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