// REQ-PLUG-1: the loader switches on in-repo modules only, and // every refusal keeps the kind's old modules and is logged (row 131). import test from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { chmodSync, mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, unlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { LIMIT, LIMIT_V1, USAGE, tree } from "./helpers.mjs"; import { businessDoc, rolesCopy, tmp as businessRoot, writeJson } from "../../organization/tests/helpers.mjs"; import { validateBusinessDocument } from "../../organization/src/organization-config.mjs"; const GOOD = { "usage-source": ["meter"], "limit-check": ["cap"] }; // What loaded() reports for GOOD: search-source is a kind too, switched off here. const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] }; // A tree with one good module of each kind, already loaded. async function loadedTree(t, options) { const s = tree(t); s.write("usage-source", "meter", USAGE("meter")); s.write("limit-check", "cap", LIMIT("cap")); const loader = await s.loader(options); const first = await loader.load(GOOD); assert.deepEqual(first.refused, []); assert.deepEqual(loader.loaded(), GOOD_LOADED); return { ...s, loader }; } // Switch usage-source to `names`: the load must refuse with `code`, keep // ["meter"], log one entry naming the kind and `name`, and leave limit-check on. async function refusesKeepingOld(s, names, code, name, message) { const before = s.loader.logged.length; const result = await s.loader.load({ "usage-source": names, "limit-check": ["cap"] }); assert.deepEqual(s.loader.loaded(), GOOD_LOADED, `${name}: old setting kept`); assert.equal(result.refused.length, 1, name); const [entry] = result.refused; assert.equal(entry.code, code, `${name}: ${entry.message}`); assert.equal(entry.kind, "usage-source"); assert.equal(entry.name, name); assert.deepEqual(entry.kept, ["meter"]); if (message) assert.match(entry.message, message); assert.deepEqual(s.loader.logged.slice(before), [entry], `${name}: logged once`); assert.equal(s.loader.modules("usage-source")[0].readTurn(), "meter"); } test("REQ-PLUG-1 fixture modules of each kind switch on from a test organization file, no core file changed", async t => { const s = tree(t); s.write("usage-source", "fixture-usage", USAGE("fixture-usage")); s.write("limit-check", "fixture-limit", LIMIT("fixture-limit")); const root = businessRoot(); t.after(() => rmSync(root, { recursive: true, force: true })); const doc = businessDoc(root, "acme"); doc.plugins = { "usage-source": ["fixture-usage"], "limit-check": ["fixture-limit"] }; const file = writeJson(join(root, "businesses", "acme.json"), doc); const business = validateBusinessDocument(doc, file, { rolesDir: rolesCopy(root) }); const loader = await s.loader(); const result = await loader.load(business.plugins); assert.deepEqual(result.refused, []); assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] }); assert.equal(loader.modules("usage-source")[0].readTurn(), "fixture-usage"); assert.equal(loader.modules("limit-check")[0].mayLaunch(), "fixture-limit"); assert.equal(loader.modules("limit-check")[0].mustStop(), false); assert.deepEqual(loader.logged, []); }); test("REQ-PLUG-1 a name not matching ^[a-z][a-z0-9-]*$ refuses and keeps the old setting", async t => { const s = await loadedTree(t); for (const name of ["Meter", "1meter", "me_ter", "../cap", "meter/index", ".hidden", ""]) { await refusesKeepingOld(s, ["meter", name], "bad-name", name); } }); test("REQ-PLUG-1 a missing module directory or index.mjs refuses and keeps the old setting", async t => { const s = await loadedTree(t); await refusesKeepingOld(s, ["meter", "absent"], "missing", "absent", /absent is missing/); mkdirSync(join(s.pkg, "usage-source", "empty")); await refusesKeepingOld(s, ["empty"], "missing", "empty", /index\.mjs is missing/); mkdirSync(join(s.pkg, "usage-source", "dir-index", "index.mjs"), { recursive: true }); await refusesKeepingOld(s, ["dir-index"], "missing", "dir-index", /not a regular file/); }); test("REQ-PLUG-1 a symlinked module directory or index.mjs refuses and keeps the old setting", async t => { const s = await loadedTree(t); const meter = join(s.pkg, "usage-source", "meter"); symlinkSync(meter, join(s.pkg, "usage-source", "linked")); await refusesKeepingOld(s, ["linked"], "symlink", "linked", /usage-source\/linked is a symlink/); s.write("usage-source", "link-index", USAGE("x")); unlinkSync(join(s.pkg, "usage-source", "link-index", "index.mjs")); symlinkSync(join(meter, "index.mjs"), join(s.pkg, "usage-source", "link-index", "index.mjs")); await refusesKeepingOld(s, ["link-index"], "symlink", "link-index", /link-index\/index\.mjs is a symlink/); }); test("REQ-PLUG-1 a symlinked kind directory refuses and keeps the old setting", async t => { const s = await loadedTree(t); const real = join(s.pkg, "usage-real"); renameSync(join(s.pkg, "usage-source"), real); symlinkSync(real, join(s.pkg, "usage-source")); await refusesKeepingOld(s, ["meter"], "symlink", "meter", /plugins\/usage-source is a symlink/); }); test("REQ-PLUG-1 a symlinked packages/plugins refuses (run with --preserve-symlinks so the path keeps it)", async t => { const s = tree(t); s.write("usage-source", "meter", USAGE("meter")); const other = mkdtempSync(join(tmpdir(), "mosaic-plugins-link-")); t.after(() => rmSync(other, { recursive: true, force: true })); mkdirSync(join(other, "packages")); symlinkSync(s.pkg, join(other, "packages", "plugins")); symlinkSync(join(s.root,"packages","organization"), join(other,"packages","organization")); const url = new URL(`file://${join(other, "packages", "plugins", "src", "loader.mjs")}`).href; const script = `const { createLoader } = await import(${JSON.stringify(url)}); const logged = []; const loader = createLoader({ log: e => logged.push(e) }); const r = await loader.load({ "usage-source": ["meter"] }); console.log(JSON.stringify({ loaded: loader.loaded(), logged }));`; const run = spawnSync(process.execPath, ["--preserve-symlinks", "--input-type=module", "-e", script], { encoding: "utf8" }); assert.equal(run.status, 0, run.stderr); const out = JSON.parse(run.stdout); assert.deepEqual(out.loaded["usage-source"], []); assert.equal(out.logged[0].code, "symlink"); assert.match(out.logged[0].message, /packages\/plugins is a symlink/); }); test("REQ-PLUG-1 index.mjs swapped for a symlink after the lstat checks refuses at the realpath check", async t => { let swap = false; const s = await loadedTree(t, { barrier: (point, { name }) => { if (!swap || point !== "checked" || name !== "swapped") return; const index = join(s.pkg, "usage-source", "swapped", "index.mjs"); unlinkSync(index); symlinkSync(join(s.pkg, "usage-source", "meter", "index.mjs"), index); }, }); s.write("usage-source", "swapped", USAGE("swapped")); swap = true; await refusesKeepingOld(s, ["swapped"], "symlink", "swapped", /swapped\/index\.mjs resolves to .*meter\/index\.mjs/); }); // Root reads through any mode, so these run only as a normal user. const asRoot = process.getuid?.() === 0; test("REQ-PLUG-1 a path that can't be read refuses as unreadable, is logged and keeps the old setting", { skip: asRoot }, async t => { const s = await loadedTree(t); const locked = s.write("usage-source", "locked", USAGE("locked")); chmodSync(locked, 0o000); try { await refusesKeepingOld(s, ["locked"], "unreadable", "locked", /locked\/index\.mjs can't be read \(EACCES\)/); } finally { chmodSync(locked, 0o755); } }); test("REQ-PLUG-1 a path that becomes unreadable after the lstat checks refuses at either realpath", { skip: asRoot }, async t => { // `late` locks its own directory (realpath of index.mjs); `later` locks // /packages (realpath of packages/plugins). Each is restored at once. let lock = null; const s = await loadedTree(t, { barrier: (point, { name }) => { if (point !== "checked" || (name !== "late" && name !== "later")) return; lock = name === "late" ? join(s.pkg, "usage-source", "late") : join(s.root, "packages"); chmodSync(lock, 0o000); }, }); s.write("usage-source", "late", USAGE("late")); s.write("usage-source", "later", USAGE("later")); try { await refusesKeepingOld(s, ["late"], "unreadable", "late", /late\/index\.mjs can't be read \(EACCES\)/); chmodSync(lock, 0o755); // A locked /packages would refuse limit-check too, so this load names usage-source only. const before = s.loader.logged.length; const result = await s.loader.load({ "usage-source": ["later"] }); assert.deepEqual(result.refused.map(e => [e.kind, e.name, e.code]), [["usage-source", "later", "unreadable"]]); assert.match(result.refused[0].message, /packages\/plugins can't be read \(EACCES\)/); assert.deepEqual(result.refused[0].kept, ["meter"]); assert.deepEqual(s.loader.logged.slice(before), result.refused); assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] }); } finally { if (lock) chmodSync(lock, 0o755); } }); test("REQ-PLUG-1 a module that fails to load refuses and keeps the old setting", async t => { const s = await loadedTree(t); s.write("usage-source", "syntax", "export const interfaceVersion = 1;\nexport function readTurn( {\n"); await refusesKeepingOld(s, ["syntax"], "load-failed", "syntax"); s.write("usage-source", "throws", "throw new Error('boom');\n"); await refusesKeepingOld(s, ["throws"], "load-failed", "throws", /boom/); }); test("REQ-PLUG-1 a missing export refuses and keeps the old setting", async t => { const s = await loadedTree(t); s.write("usage-source", "no-read", "export const interfaceVersion = 1;\n"); await refusesKeepingOld(s, ["no-read"], "missing-export", "no-read", /doesn't export readTurn/); s.write("usage-source", "no-version", "export function readTurn() {}\n"); await refusesKeepingOld(s, ["no-version"], "missing-export", "no-version", /doesn't export interfaceVersion/); s.write("usage-source", "not-fn", "export const interfaceVersion = 1;\nexport const readTurn = 1;\n"); await refusesKeepingOld(s, ["not-fn"], "missing-export", "not-fn", /readTurn isn't a function/); // limit-check needs all three of its exports; report is version 2's (row 125). s.write("limit-check", "half", "export const interfaceVersion = 2;\nexport function mayLaunch() {}\nexport function mustStop() {}\n"); const result = await s.loader.load({ "usage-source": ["meter"], "limit-check": ["cap", "half"] }); assert.deepEqual(result.refused.map(e => [e.kind, e.name, e.code]), [["limit-check", "half", "missing-export"]]); assert.match(result.refused[0].message, /doesn't export report/); assert.deepEqual(s.loader.loaded(), GOOD_LOADED); }); test("REQ-PLUG-1 a different interface version refuses and keeps the old setting", async t => { const s = await loadedTree(t); s.write("usage-source", "v2", "export const interfaceVersion = 2;\nexport function readTurn() {}\n"); await refusesKeepingOld(s, ["v2"], "interface-version", "v2", /implements interface 2, not 1/); s.write("usage-source", "v-string", "export const interfaceVersion = '1';\nexport function readTurn() {}\n"); await refusesKeepingOld(s, ["v-string"], "interface-version", "v-string"); }); test("REQ-PLUG-1 REQ-LIMIT-3 a version 1 limit check refuses after the bump to 2, and the old setting stays", async t => { const s = await loadedTree(t); // A module written for version 1 lacks report; exports are checked before the version. s.write("limit-check", "old", LIMIT_V1("old")); // One that adds report but still says 1 refuses on the version. s.write("limit-check", "old-plus", `${LIMIT_V1("old-plus")}export function report() { return []; }\n`); for (const [name, code, message] of [["old", "missing-export", /doesn't export report/], ["old-plus", "interface-version", /implements interface 1, not 2/]]) { const result = await s.loader.load({ "usage-source": ["meter"], "limit-check": [name] }); assert.deepEqual(result.refused.map(e => [e.kind, e.name, e.code]), [["limit-check", name, code]]); assert.match(result.refused[0].message, message); assert.deepEqual(result.refused[0].kept, ["cap"]); assert.deepEqual(s.loader.loaded(), GOOD_LOADED); assert.equal(s.loader.modules("limit-check")[0].mayLaunch(), "cap"); } }); test("REQ-PLUG-1 a kind refused on its first load has nothing switched on, and the log names kind and name", async t => { const s = tree(t); s.write("usage-source", "meter", USAGE("meter")); s.write("limit-check", "cap", LIMIT("cap")); const loader = await s.loader(); // meter loads, absent refuses: the whole kind stays off, not half on. const result = await loader.load({ "usage-source": ["meter", "absent"], "limit-check": ["cap"] }); assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] }); assert.deepEqual(loader.loaded(), result.loaded); assert.deepEqual(loader.modules("usage-source"), []); assert.equal(loader.logged.length, 1); assert.equal(loader.logged[0].event, "plugin-refused"); assert.equal(loader.logged[0].kind, "usage-source"); assert.equal(loader.logged[0].name, "absent"); assert.deepEqual(loader.logged[0].kept, []); }); test("REQ-PLUG-1 the setting's shape: unknown kinds refuse alone, a bad whole setting keeps everything", async t => { const s = await loadedTree(t); let result = await s.loader.load({ ...GOOD, "made-up": ["x"] }); assert.deepEqual(result.refused.map(e => [e.kind, e.code]), [["made-up", "unknown-kind"]]); assert.deepEqual(s.loader.loaded(), GOOD_LOADED); for (const kind of ["made-up", "toString"]) { assert.throws(() => s.loader.modules(kind), e => e.code === "unknown-kind", kind); } for (const bad of [null, [], "meter"]) { result = await s.loader.load(bad); assert.deepEqual(result.refused.map(e => [e.kind, e.code]), [[null, "bad-setting"]]); assert.deepEqual(result.refused[0].kept, GOOD_LOADED); assert.deepEqual(s.loader.loaded(), GOOD_LOADED); } await refusesKeepingOld(s, "meter", "bad-setting", "meter"); await refusesKeepingOld(s, ["meter", "meter"], "bad-setting", "meter", /twice/); // An absent key switches the kind off. result = await s.loader.load({ "limit-check": ["cap"] }); assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] }); }); test("REQ-PLUG-1 loads run one at a time, so the last requested setting wins", async t => { const s = await loadedTree(t); let release; globalThis.__row131Gate = new Promise(resolve => { release = resolve; }); t.after(() => { delete globalThis.__row131Gate; }); s.write("usage-source", "slow", "await globalThis.__row131Gate;\nexport const interfaceVersion = 1;\nexport function readTurn() {}\n"); s.write("usage-source", "fast", USAGE("fast")); const first = s.loader.load({ "usage-source": ["slow"] }); const second = s.loader.load({ "usage-source": ["fast"] }); await new Promise(resolve => setTimeout(resolve, 100)); release(); await Promise.all([first, second]); assert.deepEqual(s.loader.loaded()["usage-source"], ["fast"]); }); test("REQ-PLUG-1 the loader needs a log callback", async t => { const s = tree(t); const { createLoader } = await import(s.loaderUrl); assert.throws(() => createLoader({}), /log callback/); });