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.
83 lines
4.9 KiB
JavaScript
83 lines
4.9 KiB
JavaScript
// Sage's row 39 gate check, not part of the candidate: the S4 host boots the
|
|
// S3 adapter through the real process.mjs when bootConfig emits trackers.
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { Client } from "../../bus/src/client.mjs";
|
|
import { bootConfig, loadSystem } from "../src/config.mjs";
|
|
import { startHost, startTimeOf } from "../src/host.mjs";
|
|
import { writeJson } from "../../organization/tests/helpers.mjs";
|
|
import { FakeVikunja } from "../../tasks/src/fake.mjs";
|
|
import { SCOPES } from "../../tasks/tests/world.mjs";
|
|
import { fixture, tmp } from "./helpers.mjs";
|
|
|
|
test('C2-04: a library-only business with tracker.baseUrl boots without a project tracker', async t => {
|
|
const { mappingEntry } = await import('../../library/tests/helpers.mjs');
|
|
const f = fixture(tmp(t), 'acme', doc => { doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } }; doc.projects = { books: mappingEntry() }; return doc; });
|
|
const warnings = [];
|
|
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env, warn: text => warnings.push(text) });
|
|
assert.equal(boot.trackers, undefined);
|
|
assert.ok(warnings.some(text => text.includes('no project file sets tracker.project')));
|
|
const host = await startHost({ boot, business: 'acme', log: () => {} });
|
|
t.after(() => host.close(0));
|
|
assert.equal(await host.close(0), 0);
|
|
});
|
|
|
|
test("bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja", async (t) => {
|
|
const { mappingEntry } = await import('../../library/tests/helpers.mjs');
|
|
const root = tmp(t);
|
|
const fake = new FakeVikunja();
|
|
const srv = await fake.listen();
|
|
t.after(() => srv.close());
|
|
const owner = fake.user("svc-acme");
|
|
const bots = {};
|
|
for (const r of ["sync", "pm", "cto", "coder", "reviewer"]) bots[r] = fake.user(`bot-acme-${r}`, { owner });
|
|
const project = fake.project("Acme", owner);
|
|
for (const r of ["pm", "cto", "coder", "reviewer"]) fake.share(project, bots[r], 1);
|
|
fake.share(project, bots.sync, 0);
|
|
fake.install(project);
|
|
|
|
const f = fixture(root, "acme", (doc) => {
|
|
doc.projects = { books: mappingEntry(), ...doc.projects };
|
|
doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
|
|
doc.vars["tracker.baseUrl"] = srv.url;
|
|
doc.tracker.sync.botId = bots.sync;
|
|
// The chief of staff (row 119) has no tracker bot.
|
|
for (const r of Object.keys(doc.roles)) if (doc.roles[r].tracker) doc.roles[r].tracker.botId = bots[r];
|
|
return doc;
|
|
});
|
|
const put = (file, token) => {
|
|
writeFileSync(file, token + "\n");
|
|
chmodSync(file, 0o600);
|
|
};
|
|
put(f.doc.tracker.sync.credentials.vikunja.file, fake.token(bots.sync, SCOPES.sync));
|
|
for (const r of Object.keys(f.doc.roles)) if (f.doc.roles[r].credentials?.vikunja) put(f.doc.roles[r].credentials.vikunja.file, fake.token(bots[r], r === "pm" ? SCOPES.pm : SCOPES.worker));
|
|
writeJson(join(root, "project", ".mosaic", "project.json"), { projectVersion: 1, id: "stack", vars: { "tracker.project": project } });
|
|
|
|
mkdirSync(f.dataRoot, { recursive: true, mode: 0o700 });
|
|
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
|
|
assert.deepEqual(boot.trackers, { acme: { baseUrl: srv.url, project, pollSeconds: 60, reconcileMinutes: 60, descendants: false } });
|
|
const host = await startHost({ boot, business: "acme", log: () => {} });
|
|
t.after(() => host.close(0));
|
|
|
|
const launch = await host.bindLaunch({ business: "acme", role: "pm", run: "pm-run", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
|
|
const pm = new Client({ path: host.path, cap: launch.cap });
|
|
await pm.call("role.claim");
|
|
let code;
|
|
const end = Date.now() + 15000;
|
|
do {
|
|
code = await pm.call("task.close", {}).then(() => "ok", (e) => e.code);
|
|
if (code !== "tracker-starting") break;
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
} while (Date.now() < end);
|
|
console.log(`task.close {} answered: ${code}; fake saw ${fake.requests.length} requests, first ${fake.requests.slice(0, 3).map((r) => `${r.method} ${r.path} ${r.status}`).join(", ")}`);
|
|
assert.ok(fake.requests.some((r) => r.path === "/info"), "the adapter called the fake");
|
|
assert.doesNotMatch(code, /^(tracker-|credential-|scope-too-broad)/, "the adapter is ready, so the verb fails on its own arguments");
|
|
const before = fake.requests.length;
|
|
const missing = await pm.call("task.close", { task_ref: `vikunja:${project}/999`, verdict: "gate check" }).then(() => "ok", (e) => e.code);
|
|
console.log(`task.close on a missing task answered: ${missing}; it made ${fake.requests.slice(before).map((r) => `${r.method} ${r.path} ${r.status}`).join(", ")}`);
|
|
assert.ok(fake.requests.length > before, "a well-formed verb reached Vikunja through the adapter");
|
|
assert.equal(await host.close(0), 0);
|
|
});
|