Files
jason.woltje 2a34603a2e test(plugins): loader maps list the starter-pack kind (#1873 SP1)
Adding a kind adds its key to every loaded() map. Outside the SP1 owner
list; kept as its own commit so it can be dropped or moved.
2026-11-12 15:38:01 -06:00

287 lines
16 KiB
JavaScript

// 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": [], "starter-pack": [], 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": [], "starter-pack": [], 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
// <root>/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 <root>/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": [], "starter-pack": [], 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": [], "starter-pack": [], 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": [], "starter-pack": [], 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/);
});