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.
384 lines
24 KiB
JavaScript
384 lines
24 KiB
JavaScript
// E2 (#1873, P1-3, org ruling 6): the WebUI's task board, hire form and autonomy panel from a
|
|
// terminal. Each test sends the CLI verb and the page's own request for the same input, and checks
|
|
// that both reach the same bus verb with the same arguments and get the same answer, a refusal
|
|
// included. Hire and autonomy run the real scripts/mosaic against an in-process dev-owner Console
|
|
// whose organization transports are fakes, so no bus host starts; tasks run against a real broker
|
|
// bound to the person, as the human transport binds it.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { request } from 'node:http';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs';
|
|
import { processState } from '../../seat/src/process-state.mjs';
|
|
import { startServer } from '../../webui/src/serve.mjs';
|
|
import { createDevOwner } from '../../webui/src/dev-owner.mjs';
|
|
import { BusReadError } from '../../webui/src/bus.mjs';
|
|
import { parseTaskWrite } from '../../webui/src/workspace-tasks.mjs';
|
|
import { close } from '../../webui/tests/fixture.mjs';
|
|
import { main } from '../src/cli.mjs';
|
|
import { CliError } from '../src/errors.mjs';
|
|
import { orgCommand } from '../src/org.mjs';
|
|
import { broker, humanEnv, io } from './helpers.mjs';
|
|
|
|
const REPO = new URL('../../../', import.meta.url).pathname;
|
|
const MOSAIC = join(REPO, 'scripts/mosaic');
|
|
const FORBIDDEN = [7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350];
|
|
const SUBJECT = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
|
|
const SHA = 'a'.repeat(64);
|
|
|
|
async function instance(t) {
|
|
const home = mkdtempSync(join(tmpdir(), 'e2-'));
|
|
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-e2test/config.json') });
|
|
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'e2test'], { env, encoding: 'utf8' });
|
|
assert.equal(setup.status, 0, setup.stderr);
|
|
const ctx = { configDir: join(home, '.config/mosaic-e2test'), dataRoot: join(home, '.mosaic-e2test'), instance: 'e2test', env };
|
|
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
|
|
t.after(() => rmSync(home, { recursive: true, force: true }));
|
|
return { ctx, env };
|
|
}
|
|
function mosaic(env, args, timeout = 60000) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(MOSAIC, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
let stdout = '', stderr = '';
|
|
child.stdout.setEncoding('utf8').on('data', c => { stdout += c; });
|
|
child.stderr.setEncoding('utf8').on('data', c => { stderr += c; });
|
|
const timer = setTimeout(() => child.kill('SIGKILL'), timeout);
|
|
child.on('error', reject);
|
|
child.on('close', status => { clearTimeout(timer); resolve({ status, stdout, stderr }); });
|
|
});
|
|
}
|
|
function recordConsole(ctx, url) {
|
|
const dir = join(ctx.dataRoot, 'console');
|
|
mkdirSync(dir, { recursive: true, mode: 0o700 }); chmodSync(dir, 0o700);
|
|
const file = join(dir, 'console.json');
|
|
writeFileSync(file, JSON.stringify({ pid: process.pid, startTime: processState(process.pid).startTime, url }), { mode: 0o600 }); chmodSync(file, 0o600);
|
|
}
|
|
// The page's own request: a same-origin fetch from the Console's page.
|
|
function page(port, method, path, body) {
|
|
return new Promise((resolve, reject) => {
|
|
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
const headers = { host: `127.0.0.1:${port}`, ...(payload === undefined ? {} : { origin: `http://127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) }) };
|
|
request({ host: '127.0.0.1', port, method, path, headers }, res => {
|
|
let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; });
|
|
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
|
|
}).on('error', reject).end(payload);
|
|
});
|
|
}
|
|
// The bus's revision is a 64-hex digest; REV(n) stands in for the nth.
|
|
const REV = n => n.toString(16).padStart(64, '0');
|
|
const LOOP = { 'cross-role': 'in the loop', merge: 'in the loop', policy: 'in the loop', authority: 'in the loop' };
|
|
// One organization, alpha, whose bus transports record each call. `send` false is a Console
|
|
// started without --send.
|
|
function organizations({ send = true } = {}) {
|
|
const calls = [];
|
|
let state = { revision: REV(3), levels: { ...LOOP }, autonomy: { notices: false } };
|
|
const org = {
|
|
busHealthy: true,
|
|
send: send ? async () => ({}) : null,
|
|
decisionWrite: async (verb, args, opts) => {
|
|
calls.push({ verb, args, opts });
|
|
if (verb === 'hire.templates.console') return { templates: [{ name: 'worker', sha256: SHA }] };
|
|
if (args.name === 'blocked') throw new BusReadError('definition-not-hireable');
|
|
return { id: `hire-${calls.length}`, op: 'resolved', choice: 'yes' };
|
|
},
|
|
requestRead: async (verb, args, opts) => { calls.push({ verb, args, opts }); return structuredClone(state); },
|
|
autonomyWrite: async (verb, args, opts) => {
|
|
calls.push({ verb, args, opts });
|
|
if (verb === 'autonomy.review.console') return { reviewed: args.decisionClass };
|
|
if (args.revision !== state.revision) throw new BusReadError('revision-conflict');
|
|
// E2E6 fix 7: the bus refuses policy out of the loop here; the route forwards the code alone.
|
|
if (args.autonomy.policy === 'out of the loop') throw new BusReadError('autonomy-precondition');
|
|
const { notices, reverseWindowHours, authorityResolver, ...levels } = args.autonomy;
|
|
state = { revision: REV(parseInt(state.revision, 16) + 1), levels, autonomy: { notices, reverseWindowHours, ...(authorityResolver ? { authorityResolver } : {}) } };
|
|
return { revision: state.revision, transaction: `tx-${parseInt(state.revision, 16)}` };
|
|
},
|
|
};
|
|
return { calls, organizations: { list: async () => [{ id: 'alpha' }], resolve: async id => (id === 'alpha' ? org : null) } };
|
|
}
|
|
async function consoleFor(t, ctx, options) {
|
|
const f = organizations(options);
|
|
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), organizations: f.organizations, log: () => {} });
|
|
t.after(() => close(server));
|
|
const port = server.address().port;
|
|
assert.ok(!FORBIDDEN.includes(port));
|
|
recordConsole(ctx, `http://127.0.0.1:${port}/`);
|
|
return { ...f, port, server };
|
|
}
|
|
|
|
const HIRE = ['--name', 'ada', '--template', 'worker', '--harness', 'pi', '--model', 'glm', '--reason', 'more hands'];
|
|
const GUARDED = [
|
|
['org', 'role-templates', 'alpha'],
|
|
['org', 'hire', 'alpha', ...HIRE],
|
|
['org', 'change', 'alpha', ...HIRE],
|
|
['org', 'fire', 'alpha', '--name', 'ada', '--reason', 'done'],
|
|
['org', 'autonomy', 'alpha'],
|
|
['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--merge', 'near'],
|
|
['org', 'autonomy', 'alpha', 'review', 'merge'],
|
|
];
|
|
|
|
test('hire and autonomy verbs: usage errors first (exit 4), then refused inside an agent run before any config is read', async () => {
|
|
// The config path is not an instance config, so a read would refuse with another message.
|
|
const env = { ...humanEnv({ MOSAIC_CONFIG: '/nonexistent/e2/config.json' }), CLAUDECODE: '1' };
|
|
for (const args of GUARDED) {
|
|
const r = await mosaic(env, args);
|
|
assert.equal(r.status, 3, `${args.join(' ')}: ${r.stderr}`);
|
|
assert.match(r.stderr, /outside any agent run \(found CLAUDECODE/, args.join(' '));
|
|
assert.equal(r.stdout, '');
|
|
}
|
|
for (const args of [
|
|
['org', 'hire', 'alpha', '--name', 'ada'],
|
|
['org', 'fire', 'alpha', '--name', 'ada', '--reason', 'done', '--model', 'glm'],
|
|
['org', 'hire', 'alpha', ...HIRE, '--level', 'project'],
|
|
['org', 'autonomy', 'alpha', 'set', '--merge', 'near'],
|
|
['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--merge', 'sideways'],
|
|
['org', 'autonomy', 'alpha', 'set', '--revision', '3', '--merge', 'near'],
|
|
['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--reverse-window-hours', '200'],
|
|
['org', 'autonomy', 'alpha', 'review', 'everything'],
|
|
['org', 'role-templates', 'Not An Id'],
|
|
]) {
|
|
const r = await mosaic(env, args);
|
|
assert.equal(r.status, 4, `${args.join(' ')}: ${r.stderr}`);
|
|
assert.doesNotMatch(r.stderr, /agent run/);
|
|
}
|
|
// This process's own environment counts too, as for org archive|delete: the marker is only in
|
|
// process.env here, never in io.env.
|
|
const saved = process.env.CLAUDECODE;
|
|
process.env.CLAUDECODE = '1';
|
|
try {
|
|
for (const args of GUARDED) {
|
|
const x = io(humanEnv({ MOSAIC_CONFIG: '/nonexistent/e2/config.json' }));
|
|
delete x.env.CLAUDECODE;
|
|
await assert.rejects(orgCommand(args.slice(1), x, {}), e => e instanceof CliError && e.exitCode === 3 && /outside any agent run/.test(e.message), args.join(' '));
|
|
}
|
|
} finally { if (saved === undefined) delete process.env.CLAUDECODE; else process.env.CLAUDECODE = saved; }
|
|
// The default instance is a one-line refusal (exit 3), not an uncaught error.
|
|
const d = io(humanEnv());
|
|
delete d.env.MOSAIC_CONFIG;
|
|
await assert.rejects(orgCommand(['autonomy', 'alpha'], d, {}), e => e instanceof CliError && e.exitCode === 3 && /requires a named instance.*--dev-local-owner/.test(e.message));
|
|
});
|
|
|
|
test('role-templates, hire, change and fire send the hire form\'s request and get its answer, refusals included', { timeout: 120000 }, async t => {
|
|
const { ctx, env } = await instance(t);
|
|
const c = await consoleFor(t, ctx);
|
|
const opts = { subject: SUBJECT, business: 'alpha' };
|
|
|
|
let r = await mosaic(env, ['org', 'role-templates', 'alpha', '--json']);
|
|
assert.equal(r.status, 0, r.stderr);
|
|
const pageTemplates = await page(c.port, 'POST', '/api/orgs/alpha/decisions/hire/templates', { business: 'alpha' });
|
|
assert.deepEqual(JSON.parse(r.stdout), pageTemplates.body);
|
|
assert.deepEqual(c.calls.splice(0), [{ verb: 'hire.templates.console', args: {}, opts }, { verb: 'hire.templates.console', args: {}, opts }]);
|
|
r = await mosaic(env, ['org', 'role-templates', 'alpha']);
|
|
assert.equal(r.stdout, `worker ${SHA.slice(0, 12)}\n`);
|
|
c.calls.splice(0);
|
|
|
|
// The form's body for this input (bus.js hireSubmit): the template it read, by name and sha256.
|
|
const form = (op, name, extra = {}) => ({ op, level: 'org', name, reason: 'more hands', ...extra, ...(op === 'fire' ? {} : { role: { template: 'worker', sha256: SHA }, route: { harness: 'pi', model: 'glm' } }) });
|
|
for (const op of ['hire', 'change']) {
|
|
r = await mosaic(env, ['org', op, 'alpha', ...HIRE, '--json']);
|
|
assert.equal(r.status, 0, r.stderr);
|
|
const cli = c.calls.splice(0);
|
|
assert.deepEqual(cli.map(x => x.verb), ['hire.templates.console', 'hire.start.console']);
|
|
const pageAnswer = await page(c.port, 'POST', '/api/orgs/alpha/decisions/hire/start', { business: 'alpha', hire: form(op, 'ada') });
|
|
const pageCall = c.calls.splice(0);
|
|
assert.deepEqual(cli[1], pageCall[0]);
|
|
assert.deepEqual(cli[1].args, form(op, 'ada'));
|
|
assert.equal(pageAnswer.status, 200);
|
|
// Each decision gets its own id; the rest of the answer is the page's.
|
|
assert.deepEqual({ ...JSON.parse(r.stdout), id: 'x' }, { ...pageAnswer.body, id: 'x' });
|
|
}
|
|
r = await mosaic(env, ['org', 'fire', 'alpha', '--name', 'ada', '--reason', 'more hands', '--level', 'project', '--project', 'site']);
|
|
assert.equal(r.status, 0, r.stderr);
|
|
assert.match(r.stdout, /^recorded decision hire-1: fire ada \(resolved yes\)\n$/);
|
|
assert.deepEqual(c.calls.splice(0), [{ verb: 'hire.start.console', args: { op: 'fire', level: 'project', name: 'ada', reason: 'more hands', project: 'site' }, opts }]);
|
|
|
|
// A bus refusal reaches the terminal with the Console's code and message, as the form shows it.
|
|
r = await mosaic(env, ['org', 'hire', 'alpha', ...HIRE.map(a => (a === 'ada' ? 'blocked' : a))]);
|
|
const pageRefusal = await page(c.port, 'POST', '/api/orgs/alpha/decisions/hire/start', { business: 'alpha', hire: form('hire', 'blocked') });
|
|
assert.equal(pageRefusal.status, 409);
|
|
assert.equal(r.status, 3, r.stderr);
|
|
assert.ok(r.stderr.includes(`hire refused (definition-not-hireable): ${pageRefusal.body.message}`), r.stderr);
|
|
c.calls.splice(0);
|
|
// A template the Console did not offer is refused before anything is raised.
|
|
r = await mosaic(env, ['org', 'hire', 'alpha', ...HIRE.map(a => (a === 'worker' ? 'ghost' : a))]);
|
|
assert.equal(r.status, 2, r.stderr);
|
|
assert.match(r.stderr, /no role template "ghost"/);
|
|
assert.deepEqual(c.calls.splice(0).map(x => x.verb), ['hire.templates.console']);
|
|
|
|
// A Console without --send: the page's refusal, and nothing reaches the bus.
|
|
await close(c.server);
|
|
const ro = await consoleFor(t, ctx, { send: false });
|
|
r = await mosaic(env, ['org', 'hire', 'alpha', ...HIRE]);
|
|
const pageRo = await page(ro.port, 'POST', '/api/orgs/alpha/decisions/hire/templates', { business: 'alpha' });
|
|
assert.equal(r.status, 3, r.stderr);
|
|
assert.match(r.stderr, new RegExp(`hire refused \\(${pageRo.body.error}\\)`));
|
|
assert.equal(pageRo.body.error, 'read-only');
|
|
assert.deepEqual(ro.calls, []);
|
|
// An organization the Console does not know is refused as the page is.
|
|
r = await mosaic(env, ['org', 'role-templates', 'beta']);
|
|
assert.equal(r.status, 3, r.stderr);
|
|
assert.match(r.stderr, /role-templates refused \(org-unavailable\)/);
|
|
});
|
|
|
|
test('autonomy reads, saves with the panel\'s revision check and records a review through the panel\'s route', { timeout: 120000 }, async t => {
|
|
const { ctx, env } = await instance(t);
|
|
const c = await consoleFor(t, ctx);
|
|
const opts = { subject: SUBJECT };
|
|
|
|
let r = await mosaic(env, ['org', 'autonomy', 'alpha', '--json']);
|
|
assert.equal(r.status, 0, r.stderr);
|
|
assert.deepEqual(JSON.parse(r.stdout), (await page(c.port, 'GET', '/api/orgs/alpha/autonomy')).body);
|
|
r = await mosaic(env, ['org', 'autonomy', 'alpha']);
|
|
assert.equal(r.stdout, `revision ${REV(3)}\ncross-role: in the loop\nmerge: in the loop\npolicy: in the loop\nauthority: in the loop\nnotices: off\nreverse window: not set (the page offers 24 hours)\nauthority resolver: none\n`);
|
|
c.calls.splice(0);
|
|
|
|
// The panel's save for "merge near the loop" (autonomy.js): every class as shown, notices, the
|
|
// window it offers (24) and no resolver.
|
|
const panel = { ...LOOP, merge: 'near the loop', notices: false, reverseWindowHours: 24 };
|
|
// Imported here so that the other tests still run, and fail on their own, on a base without it.
|
|
const { autonomyDocument } = await import('../src/org-owner.mjs');
|
|
assert.deepEqual(autonomyDocument({ revision: REV(3), levels: LOOP, autonomy: { notices: false } }, { merge: 'near the loop' }), panel);
|
|
// An emptied resolver field: the panel leaves it out of the save, and so does the terminal.
|
|
assert.deepEqual(autonomyDocument({ revision: REV(3), levels: LOOP, autonomy: { notices: false, authorityResolver: 'ops' } }, { authorityResolver: '' }), { ...LOOP, notices: false, reverseWindowHours: 24 });
|
|
assert.equal(autonomyDocument({ revision: REV(3), levels: LOOP, autonomy: { notices: false, authorityResolver: 'ops' } }, {}).authorityResolver, 'ops');
|
|
r = await mosaic(env, ['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--merge', 'near', '--json']);
|
|
assert.equal(r.status, 0, r.stderr);
|
|
assert.deepEqual(JSON.parse(r.stdout), { revision: REV(4), transaction: 'tx-4', writable: true });
|
|
assert.deepEqual(c.calls.splice(0), [{ verb: 'autonomy.get.console', args: {}, opts }, { verb: 'autonomy.set.console', args: { revision: REV(3), autonomy: panel }, opts }]);
|
|
// The page's save of the same document at revision 4 lands the same way.
|
|
const pageSave = await page(c.port, 'POST', '/api/orgs/alpha/autonomy', { revision: REV(4), autonomy: panel });
|
|
assert.deepEqual(pageSave.body, { revision: REV(5), transaction: 'tx-5', writable: true });
|
|
assert.deepEqual(c.calls.splice(0), [{ verb: 'autonomy.set.console', args: { revision: REV(4), autonomy: panel }, opts }]);
|
|
|
|
// A stale revision is the panel's revision-conflict, and nothing changes.
|
|
r = await mosaic(env, ['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--policy', 'near']);
|
|
const pageStale = await page(c.port, 'POST', '/api/orgs/alpha/autonomy', { revision: REV(3), autonomy: panel });
|
|
assert.deepEqual(pageStale, { status: 409, body: { error: 'revision-conflict' } });
|
|
assert.equal(r.status, 3, r.stderr);
|
|
assert.match(r.stderr, /autonomy set refused \(revision-conflict\): the organization changed; read it again with mosaic org autonomy ID/);
|
|
assert.equal((await page(c.port, 'GET', '/api/orgs/alpha/autonomy')).body.revision, REV(5));
|
|
c.calls.splice(0);
|
|
|
|
// E2E6 fix 7: an autonomy-precondition the route sends without a reason still names the one check the
|
|
// terminal can see from its read and the document it sent (notices off while a class moves away),
|
|
// and the general guidance becomes the next line.
|
|
r = await mosaic(env, ['org', 'autonomy', 'alpha', 'set', '--revision', REV(5), '--policy', 'out']);
|
|
assert.equal(r.status, 3, r.stderr);
|
|
assert.match(r.stderr, /^mosaic: autonomy set refused \(autonomy-precondition\): owner notices are off; a class near the loop, or one moving away from it, needs them on \(--notices on\)$/m);
|
|
assert.match(r.stderr, /^mosaic: next: this move needs a separate resolver/m);
|
|
const { preconditionSeen } = await import('../src/org-owner.mjs');
|
|
const shown = { levels: { ...LOOP, merge: 'near the loop' } };
|
|
assert.equal(preconditionSeen(shown, { ...LOOP, merge: 'near the loop', notices: true }), undefined);
|
|
assert.equal(preconditionSeen(shown, { ...LOOP, notices: false }), undefined, 'moving back toward the loop needs no notices');
|
|
assert.match(preconditionSeen(shown, { ...LOOP, merge: 'near the loop', notices: false }), /^owner notices are off/);
|
|
assert.match(preconditionSeen({ levels: LOOP }, { ...LOOP, authority: 'out of the loop', notices: false }), /^owner notices are off/);
|
|
c.calls.splice(0);
|
|
|
|
r = await mosaic(env, ['org', 'autonomy', 'alpha', 'review', 'merge']);
|
|
assert.equal(r.status, 0, r.stderr);
|
|
assert.equal(r.stdout, 'recorded your trail review for merge on alpha\n');
|
|
await page(c.port, 'POST', '/api/orgs/alpha/autonomy/review', { decisionClass: 'merge' });
|
|
const [cli, pageReview] = c.calls.splice(0);
|
|
assert.deepEqual(cli, pageReview);
|
|
assert.deepEqual(cli, { verb: 'autonomy.review.console', args: { decisionClass: 'merge' }, opts });
|
|
|
|
// Without --send the read still answers and both writes are the page's read-only refusal.
|
|
await close(c.server);
|
|
const ro = await consoleFor(t, ctx, { send: false });
|
|
r = await mosaic(env, ['org', 'autonomy', 'alpha', '--json']);
|
|
assert.equal(r.status, 0, r.stderr);
|
|
assert.equal(JSON.parse(r.stdout).writable, false);
|
|
for (const args of [['set', '--revision', REV(3), '--merge', 'near'], ['review', 'merge']]) {
|
|
r = await mosaic(env, ['org', 'autonomy', 'alpha', ...args]);
|
|
assert.equal(r.status, 3, r.stderr);
|
|
assert.match(r.stderr, /refused \(read-only\): start the Console with --send/);
|
|
}
|
|
assert.deepEqual((await page(ro.port, 'POST', '/api/orgs/alpha/autonomy/review', { decisionClass: 'merge' })).body, { error: 'read-only' });
|
|
assert.deepEqual(ro.calls.map(x => x.verb), ['autonomy.get.console', 'autonomy.get.console']);
|
|
});
|
|
|
|
// mosaic tasks create|edit|archive with the person's transport over a real broker.
|
|
async function run(argv, s) {
|
|
const x = io();
|
|
try { await main(argv, x, { system: { dataRoot: s.dataRoot }, transport: s.transport }); return { code: 0, out: x.out.text, err: '' }; }
|
|
catch (e) { if (!(e instanceof CliError)) throw e; return { code: e.exitCode, out: x.out.text, err: e.message }; }
|
|
}
|
|
|
|
test('tasks create, edit and archive send the board\'s writes to the bus as the person', async t => {
|
|
const b = broker(t);
|
|
const s = { ...b, dataRoot: mkdtempSync(join(tmpdir(), 'e2t-')) };
|
|
t.after(() => rmSync(s.dataRoot, { recursive: true, force: true }));
|
|
// The board's request for the same input, through the board's own check.
|
|
const board = body => { const w = parseTaskWrite({ org: 'demo', ...body }); return { verb: w.verb, args: w.args }; };
|
|
|
|
let r = await run(['tasks', 'create', '--title', 'Plan the store', '--requirement', 'REQ-TASK-1', '--assignees', 'coder', '--priority', '2', '--business', 'demo', '--json'], s);
|
|
assert.equal(r.code, 0, r.err);
|
|
const created = JSON.parse(r.out);
|
|
assert.deepEqual(s.calls.splice(0).map(({ verb, args }) => ({ verb, args })), [board({ op: 'create', title: 'Plan the store', requirement: 'REQ-TASK-1', assignees: ['coder'], priority: 2 })]);
|
|
assert.deepEqual({ ...created, digest: 'd' }, { ok: true, org: 'demo', op: 'create', task_ref: 'task:1', digest: 'd' });
|
|
const row = () => s.read('tasks').find(x => x.task_ref === 'task:1');
|
|
assert.equal(created.digest, row().digest);
|
|
assert.equal(row().fields.title, 'Plan the store');
|
|
assert.deepEqual(row().fields.assignees, ['coder']);
|
|
|
|
r = await run(['tasks', 'edit', 'task:1', '--expect', created.digest, '--state', 'in-progress', '--due', 'none', '--business', 'demo'], s);
|
|
assert.equal(r.code, 0, r.err);
|
|
assert.equal(r.out, `changed task:1, digest ${row().digest}\n`);
|
|
assert.deepEqual(s.calls.splice(0).map(({ verb, args }) => ({ verb, args })), [board({ op: 'edit', task_ref: 'task:1', expect: created.digest, state: 'in-progress', due_date: null })]);
|
|
assert.equal(row().fields.state, 'in-progress');
|
|
|
|
// A stale digest is the board's task-conflict, and nothing is written.
|
|
const before = row();
|
|
r = await run(['tasks', 'archive', 'task:1', '--expect', created.digest, '--business', 'demo'], s);
|
|
assert.equal(r.code, 2, r.err);
|
|
assert.match(r.err, /^tasks archive refused \(task-conflict\): the task changed after you read it/);
|
|
assert.deepEqual(row(), before);
|
|
s.calls.splice(0);
|
|
|
|
r = await run(['tasks', 'archive', 'task:1', '--expect', before.digest, '--business', 'demo'], s);
|
|
assert.equal(r.code, 0, r.err);
|
|
assert.deepEqual(s.calls.splice(0).map(({ verb, args }) => ({ verb, args })), [board({ op: 'archive', task_ref: 'task:1', expect: before.digest })]);
|
|
assert.match(r.out, /^archived task:1, digest [0-9a-f]{64}\n$/);
|
|
|
|
// What the board refuses with 400 the terminal refuses as usage (exit 4), with the board's words,
|
|
// before anything is sent.
|
|
for (const [argv, body] of [
|
|
[['tasks', 'create', '--title', 'x', '--business', 'demo'], { op: 'create', title: 'x' }],
|
|
[['tasks', 'edit', 'task:1', '--expect', 'nope', '--title', 'y', '--business', 'demo'], { op: 'edit', task_ref: 'task:1', expect: 'nope', title: 'y' }],
|
|
[['tasks', 'archive', 'task:1', '--business', 'demo'], { op: 'archive', task_ref: 'task:1' }],
|
|
]) {
|
|
let boardError;
|
|
try { parseTaskWrite({ org: 'demo', ...body }); } catch (e) { boardError = e; }
|
|
assert.equal(boardError?.status, 400, JSON.stringify(body));
|
|
r = await run(argv, s);
|
|
assert.equal(r.code, 4, argv.join(' '));
|
|
assert.ok(r.err.includes(boardError.message), `${r.err} / ${boardError.message}`);
|
|
}
|
|
assert.deepEqual(s.calls, []);
|
|
// Terminal-only slips the board cannot make are usage too: an option-looking word is not a value,
|
|
// and the organization is named (exit 4 even inside an agent run).
|
|
for (const [argv, env] of [
|
|
[['tasks', 'create', '--title', '--json', '--requirement', 'REQ-TASK-1', '--business', 'demo'], humanEnv()],
|
|
[['tasks', 'create', '--title', 'x', '--requirement', 'REQ-TASK-1'], { ...humanEnv(), CLAUDECODE: '1' }],
|
|
]) {
|
|
const y = io(env);
|
|
await assert.rejects(main(argv, y, { system: { dataRoot: s.dataRoot }, transport: s.transport }), e => e instanceof CliError && e.exitCode === 4, argv.join(' '));
|
|
}
|
|
assert.deepEqual(s.calls, []);
|
|
// A marker only in this process's own environment counts too.
|
|
const savedMarker = process.env.CLAUDECODE;
|
|
process.env.CLAUDECODE = '1';
|
|
try {
|
|
const y = io(humanEnv());
|
|
delete y.env.CLAUDECODE;
|
|
await assert.rejects(main(['tasks', 'create', '--title', 'x', '--requirement', 'REQ-TASK-1', '--business', 'demo'], y, { system: { dataRoot: s.dataRoot }, transport: s.transport }), e => e instanceof CliError && e.exitCode === 3 && /outside any agent run/.test(e.message));
|
|
} finally { if (savedMarker === undefined) delete process.env.CLAUDECODE; else process.env.CLAUDECODE = savedMarker; }
|
|
assert.deepEqual(s.calls, []);
|
|
// Inside an agent run the write is refused before anything is sent.
|
|
const x = io({ ...humanEnv(), CLAUDECODE: '1' });
|
|
await assert.rejects(main(['tasks', 'create', '--title', 'x', '--requirement', 'REQ-TASK-1', '--business', 'demo'], x, { system: { dataRoot: s.dataRoot }, transport: s.transport }), e => e instanceof CliError && e.exitCode === 3);
|
|
assert.deepEqual(s.calls, []);
|
|
});
|