Files
stack/packages/cli/tests/tester-reads.test.mjs
jason.woltje a8d9eecf65 organization: move packages/business to packages/organization (rename r2 stage B1)
git mv packages/business -> packages/organization (@mosaic/organization). The
old package stays as one-line re-export shims of each moved module, with
src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs
forwarding to the moved executable. BusinessError is OrganizationConfigError
(one constructor, name kept BusinessError); OrganizationError stays distinct.
The bus adapter gains organization-context.mjs (busOrganization), with
business.mjs (busBusiness) as the same function.

Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh
COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure
walker (organization-imports.mjs, both roots required) move to the new path.
v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage
A contract tests stay where they were and pass as before.
2026-11-09 11:47:48 -06:00

214 lines
11 KiB
JavaScript

// Row 175 (#1673, Q173, mos-0870 N2, mos-0872): the CLI's tester forms. The reads print what the
// human's forms print (since row 177 `threads --tester` lists the tester's own), every write form
// outside W1-W7 refuses --tester before any call, and `project init --tester` checks the project
// area, binds the tester, then commits as `tester`.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { main } from "../src/cli.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { systemFor, writeJson } from "../../organization/tests/helpers.mjs";
import { fixture, humanEnv, io, tmp, orgFixture } from "./helpers.mjs";
const none = { authority: { withinRole: ["message.send"], crossRole: [] } };
const inAgent = (env) => io({ ...env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" });
const bus = (vars = { "bus.tester": true }) => ({
acme: {
id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, ...(vars ? { vars } : {}),
roles: { coder: { definition: "coder", ...none }, assistant: { definition: "assistant", ...none } },
},
});
// The runtime in this process behind stand-in transports, as instances.test.mjs.
async function setup(t) {
const root = tmp(t, "mosaic-cli-tester-reads-");
const f = fixture(root, "acme");
const rt = await startBroker({ dataRoot: f.dataRoot, businesses: bus(), organization: { dir: join(root, "config"), rolesDir: f.roles, system: systemFor(root) } });
t.after(() => rt.close());
const human = rt.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true });
const tester = rt.broker.bindTester({ business: "acme" });
const calls = { human: [], tester: [] };
const via = (cap, list) => (business) => async (verb, args = {}) => {
list.push(verb);
try {
return structuredClone(rt.broker.request(cap, { verb, args }));
} catch (e) {
if (!(e instanceof BusError)) throw e;
const error = new Error(e.code);
error.code = e.code;
throw error;
}
};
const deps = { system: { dataRoot: f.dataRoot }, transport: via(human, calls.human), testerTransport: via(tester, calls.tester), talkPollMs: 5 };
const call = (verb, args = {}) => rt.broker.request(human, { verb, args });
return { root, f, rt, human, tester, calls, deps, call };
}
const READS = [
["usage"],
["names"],
["limits"],
["plugins"],
// Row 177 (#1675): `threads` left this list; the tester's lists its own threads (below).
["project", "list"],
];
test("row 175: each read with --tester inside an agent prints the human form's output, and refuses without --tester", async (t) => {
const s = await setup(t);
s.call("plugins.set", { revision: s.call("plugins").revision, plugins: { channel: ["cli", "webui"] } });
s.call("name.set", { instance: "coder", name: "rex" });
const project = s.call("project.create", { name: "alpha" }).id;
s.call("thread.create", { project, name: "one" });
const forms = [...READS.map((argv) => [...argv, "--business", "acme"]), ["business", "instances", "acme"]];
for (const argv of forms) {
const name = argv.slice(0, 2).join(" ");
const h = io(s.f.env);
await main(argv, h, s.deps);
const humanCalls = s.calls.human.length;
const testerCalls = s.calls.tester.length;
const x = inAgent(s.f.env);
await main([...argv, "--tester"], x, s.deps);
assert.equal(x.out.text, h.out.text, name);
assert.ok(x.out.text.length > 0, name);
assert.equal(s.calls.human.length, humanCalls, `${name}: the tester form made no human call`);
assert.ok(s.calls.tester.length > testerCalls, `${name}: the tester form called the tester transport`);
await assert.rejects(main(argv, inAgent(s.f.env), s.deps), (e) => e.exitCode === 3, `${name} without --tester`);
assert.equal(s.calls.human.length, humanCalls, `${name}: the refusal came before any call`);
}
// Row 177 (#1675): the tester's threads are its own, so the human's "one" is not in its list.
const testerThreads = async () => {
const x = inAgent(s.f.env);
await main(["threads", "--business", "acme", "--json", "--tester"], x, s.deps);
return JSON.parse(x.out.text).map((r) => r.name);
};
assert.deepEqual(await testerThreads(), []);
s.rt.broker.request(s.tester, { verb: "thread.create", args: { project, name: "t-one" } });
assert.deepEqual(await testerThreads(), ["t-one"]);
const h = io(s.f.env);
await main(["threads", "--business", "acme", "--json"], h, s.deps);
assert.deepEqual(JSON.parse(h.out.text).map((r) => r.name), ["one"]);
await assert.rejects(main(["threads", "--business", "acme"], inAgent(s.f.env), s.deps), (e) => e.exitCode === 3, "threads without --tester");
});
test("row 175: --tester on limits set, reset and migrate and every write form outside W1-W7 exits 4 before any call", async (t) => {
const s = await setup(t);
for (const argv of [
["limits", "set", "--file", "patch.json", "--business", "acme"],
["limits", "reset", "total", "--business", "acme"],
["limits", "migrate", "--file", "org.json", "--models", "map.json"],
["decide", "d1", "yes", "--business", "acme"],
["inbox", "--read", "abcdefgh", "--business", "acme"],
["inbox", "--read-all", "--business", "acme"],
// Row 184 (#1682): the thread verbs and talk and follow --thread left this list
// (tests/tester-threads.test.mjs).
["stop", "run-1"],
["launches", "off", "--business", "acme"],
["launches", "on", "--business", "acme"],
["launches", "list"],
["bus", "start", "acme"],
["bus", "stop"],
["project", "check", "acme", "stack"],
["project", "init", "acme", "stack", "--tester"],
]) {
const x = inAgent(s.f.env);
await assert.rejects(main([...argv, "--tester"], x, s.deps), (e) => e.exitCode === 4, argv.join(" "));
}
assert.deepEqual(s.calls, { human: [], tester: [] });
assert.equal(existsSync(join(s.f.dataRoot, "projects")), false);
});
// W7 through the real tester transport: the CLI runs as its own process, so the tester-cli.mjs it
// spawns can reach the runtime in this one.
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
function mosaic(args, env) {
const clean = Object.fromEntries(Object.entries(env).filter(([k]) => !/^MOSAIC_BUS_(TESTER|CLI)_NONCE$/.test(k)));
return new Promise((resolve, reject) => {
const child = spawn(MOSAIC, args, { env: clean, timeout: 60000 });
let out = "";
let err = "";
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (err += d));
child.on("error", reject);
child.on("close", (status) => resolve({ status, out, err }));
});
}
// sage-1081: these six fell through to the seat CLI, which refused with its own usage, so W1
// (`plugins set`) and W4 (`name`) failed through the wrapper. Each now reaches packages/cli.
test("row 175: scripts/mosaic sends plugins, name, names, follow, threads and thread to packages/cli, not the seat CLI", (t) => {
const root = tmp(t, "mosaic-cli-route-");
const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") };
for (const [args, message] of [
[["plugins", "x"], /^mosaic: mosaic plugins \[--business ID\]/],
[["name"], /^mosaic: name takes an instance and a name\n/],
[["names", "x"], /^mosaic: names takes no arguments\n/],
[["follow", "x"], /^mosaic: follow takes no arguments\n/],
[["threads", "x"], /^mosaic: threads takes no arguments\n/],
[["thread"], /^mosaic: usage:\n mosaic thread create /],
]) {
const r = spawnSync(MOSAIC, args, { env, encoding: "utf8" });
assert.equal(r.status, 4, args.join(" "));
assert.match(r.stderr, message, args.join(" "));
assert.doesNotMatch(r.stderr, /mosaic launch </, args.join(" "));
}
});
const listing = (dir) => readdirSync(dir, { recursive: true }).map(String).sort();
async function w7(t, vars, edit) {
const { root, f, org } = await orgFixture(t, "acme", edit);
const rt = await startBroker({ dataRoot: org.root, businesses: bus(vars) });
t.after(() => rt.close());
return { root, f, agent: { ...f.env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } };
}
test("row 175 W7: project init --tester makes the repository under the project area, committed by tester", async (t) => {
const { f, agent } = await w7(t);
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent);
assert.equal(r.status, 0, r.err);
const repo = join(f.dataRoot, "projects", "acme", "stack");
assert.match(r.out, new RegExp(`^initialized ${repo}\ncommit [0-9a-f]{40}\n$`));
const log = spawnSync("git", ["-C", repo, "log", "--format=%an|%cn"], { encoding: "utf8" });
assert.equal(log.stdout, "tester|tester\n");
// Without --tester the same init inside an agent refuses, as before.
const again = await mosaic(["project", "init", "acme", "stack"], agent);
assert.equal(again.status, 3);
assert.match(again.err, /outside any agent run/);
});
test("row 175 W7: a repo outside the project area refuses tester-project-area before any bus call, and nothing changes", async (t) => {
// The switch is off: a bind would refuse tester-off (exit 3), so exit 2 shows the area check ran first.
const { f, agent } = await w7(t, null, (doc) => {
doc.projects.stack.repo = join(doc.projects.stack.root, "..", "records");
return doc;
});
const records = f.doc.projects.stack.repo;
const data = listing(f.dataRoot);
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent);
assert.equal(r.status, 2, r.err);
assert.match(r.err, /tester-project-area/);
assert.equal(existsSync(records), false);
assert.deepEqual(listing(f.dataRoot), data);
});
test("row 175 W7: the tester bind refuses tester-off and tester-required before any directory", async (t) => {
const off = await w7(t, null);
const data = listing(off.f.dataRoot);
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], off.agent);
assert.equal(r.status, 3, r.err);
assert.match(r.err, /tester-off/);
assert.deepEqual(listing(off.f.dataRoot), data);
assert.equal(existsSync(join(off.f.dataRoot, "projects")), false);
const on = await w7(t);
const before = listing(on.f.dataRoot);
for (const marker of ["MOSAIC_RUN_ID", "MOSAIC_BUS_CAP", "MOSAIC_AGENT_RUN"]) {
const under = await mosaic(["project", "init", "acme", "stack", "--tester"], { ...on.agent, [marker]: "x" });
assert.equal(under.status, 3, `${marker}: ${under.err}`);
assert.match(under.err, /tester-required/, marker);
}
assert.deepEqual(listing(on.f.dataRoot), before);
assert.equal(existsSync(join(on.f.dataRoot, "projects")), false);
});