Files
stack/packages/plugins/tests/loader.test.mjs
T
jason.woltjeandClaude Opus 5.5 eaeb5ebf2b plugins: the plug-in layer, kind registry and loader (row 131, #1626)
packages/plugins holds the kind registry and the loader for in-repo
plug-in modules (REQ-PLUG-1). The first two kinds are usage-source and
limit-check (REQ-LIMIT-4). The business file's plugins key turns modules
on per kind, through plugin-settings.mjs and step 5's write path
(REQ-ROLE-3). A refused load is logged and keeps the kind's old modules.
Any filesystem error other than ENOENT or ENOTDIR refuses as unreadable.
The organization writer validates the bytes it writes as well as the
document. stack-copy ships packages/plugins. Nothing is wired into a
host process yet.

Candidate 466c835f7dce79d20c7ab74c197a52b70e7fdeb0efae0456222dc6d0a633f219,
Dewey packet 80797a539 (round 2), base 0bdd573ff. Filbert approved
round 2 in 28568 and Darkwing in 28570. Landing gate at f7d249634: 24 suites green, 0 failed.
14 package suites passed 1586 tests (plugins 21, business 92,
bus 171, cli 195, webui 112). The 10 script suites passed. Follow-up:
P19 task 293, coded writer refusals.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:57:54 -05:00

266 lines
14 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, USAGE, tree } from "./helpers.mjs";
import { businessDoc, rolesCopy, tmp as businessRoot, writeJson } from "../../business/tests/helpers.mjs";
import { validateBusinessDocument } from "../../business/src/business.mjs";
const GOOD = { "usage-source": ["meter"], "limit-check": ["cap"] };
// 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);
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, `${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);
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"));
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": [] });
} 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 both of its exports.
s.write("limit-check", "half", "export const interfaceVersion = 1;\nexport function mayLaunch() {}\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.deepEqual(s.loader.loaded(), GOOD);
});
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 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"] });
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);
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);
assert.deepEqual(s.loader.loaded(), GOOD);
}
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"] });
});
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/);
});