Files
stack/packages/cli/tests/org-templates.test.mjs
jason.woltje 23e504f331 fix(cli,bus,organization): E2E6 lane f6c, refusal lines name the next step (#1873)
1. Stale bus host lock refusals from bus status, start, stop and restart
   name mosaic bus recover ORG.
2. org definitions: unknown org, unknown or malformed name and bad id give
   one refusal or usage line, no stack trace, no host path.
3. Pack, board and workflow refusals print their hint as template save does;
   tested with the real CliError transport shape.
4. console start and stop clear a console/start.lock whose named supervisor
   (pid and start time) is gone; a live or unnamed holder refuses.
5. Template save keeps free text verbatim and writes no placeholders; the
   preview lists each field that mentions an org-specific name.
6. A request the shim never sent (no socket, connect refused) exits 5 and
   the CLI reports bus-not-running with the start and recover line.
7. autonomy-precondition carries the check that failed, including on the
   hire paths.
8. Saving under an existing template name refuses template-exists with a
   next step; refusal table entry added, channel-proof pins moved.
2026-11-13 20:14:21 -06:00

79 lines
5.4 KiB
JavaScript

// Starter packs SP8: `mosaic org template save|list`. Each word goes through the organization's bus as
// its human; save --preview writes nothing, save --digest writes the previewed JSON under the owner's
// account, and template render keeps its own words.
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, lstatSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { main } from '../src/cli.mjs';
import { ORG_USAGE, TEMPLATE_USAGE } from '../src/org.mjs';
import { bootConfig, loadSystem } from '../src/config.mjs';
import { CliError } from '../src/errors.mjs';
import { busExit } from '../src/transport.mjs';
import { BusError } from '../../bus/src/broker.mjs';
import { startBroker } from '../../bus/src/runtime.mjs';
import { fixture, io, tmp } from './helpers.mjs';
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 = [];
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 })); }
// What humanTransport throws for a bus refusal: a CliError named by the code alone.
catch (e) { if (!(e instanceof BusError)) throw e; throw Object.assign(new CliError(e.code, busExit(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 saved = (name) => join(boot.organization.dir, 'templates', f.doc.human, `${name}.json`);
return { root, f, file, calls, run, saved };
}
test('SP8 mosaic org template save: --preview writes nothing, --digest writes the previewed JSON, a second save refuses, list shows it', async (t) => {
const s = await setup(t);
assert.ok(ORG_USAGE.includes(TEMPLATE_USAGE.slice('mosaic org '.length)));
const before = readFileSync(s.file);
const preview = await s.run(['template', 'save', 'acme', 'acme-shape', '--preview']);
assert.match(preview.digest, /^[0-9a-f]{64}$/);
assert.ok(Array.isArray(preview.freeText) && Array.isArray(preview.mentions) && !('replacements' in preview));
assert.ok(!existsSync(s.saved('acme-shape')), 'preview writes no file');
const out = await s.run(['template', 'save', 'acme', 'acme-shape', '--digest', preview.digest]);
assert.deepEqual(out, { name: 'acme-shape', scope: 'org', digest: preview.digest });
assert.equal(readFileSync(s.saved('acme-shape'), 'utf8'), preview.json);
assert.equal(lstatSync(s.saved('acme-shape')).mode & 0o777, 0o600);
assert.deepEqual(readFileSync(s.file), before, 'save writes nothing to the organization file');
await assert.rejects(s.run(['template', 'save', 'acme', 'acme-shape', '--digest', preview.digest]), (e) => e.code === 'template-exists' && e.exitCode === 2
&& e.message === 'template-exists: a template of this name is already saved for your account; nothing was written'
&& /under another name/.test(e.next) && /mosaic org template list acme/.test(e.next));
await assert.rejects(s.run(['template', 'save', 'acme', 'other', '--digest', preview.digest]), (e) => e.code === 'pack-plan-stale' && /preview again/.test(e.message));
const listed = await s.run(['template', 'list', 'acme']);
assert.deepEqual(listed.templates.map((x) => x.name), ['acme-shape']);
assert.deepEqual(s.calls.map(([verb, keys]) => [verb, keys.join(',')]), [
['template.save', 'name,preview'], ['template.save', 'digest,name'], ['template.save', 'digest,name'], ['template.save', 'digest,name'], ['template.list', ''],
]);
});
test('SP8 template words: a bad shape prints the usage; an agent shell refuses before the bus is called; template render keeps its words', async (t) => {
const s = await setup(t);
for (const args of [['template', 'save', 'acme', 'x'], ['template', 'save', 'acme', 'x', '--preview', '--digest', 'a'.repeat(64)], ['template', 'save', 'acme'], ['template', 'list'], ['template', 'list', 'acme', '--preview']]) {
await assert.rejects(s.run(args), (e) => e.exitCode === 4 && e.message === TEMPLATE_USAGE, args.join(' '));
}
await assert.rejects(s.run(['template', 'save', 'acme', 'x', '--force']), (e) => e.exitCode === 4 && /unknown option --force/.test(e.message));
await assert.rejects(s.run(['template', 'save', 'acme', '..', '--preview']), (e) => e.code === 'invalid-request');
const agent = { ...s.f.env, MOSAIC_RUN_ID: 'run-1' };
const calls = s.calls.length;
for (const args of [['template', 'save', 'acme', 'x', '--preview'], ['template', 'save', 'acme', 'x', '--digest', 'a'.repeat(64)], ['template', 'list', 'acme']]) {
await assert.rejects(s.run(args, agent), (e) => e.exitCode === 3 && /human shell/.test(e.message), args.join(' '));
}
assert.equal(s.calls.length, calls);
// template render is still routed to its own words (it needs --output).
await assert.rejects(s.run(['template', 'render', 'acme']), (e) => e.exitCode === 4 && /--output/.test(e.message));
});