287 lines
16 KiB
JavaScript
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": [], 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
|
|
// <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": [], 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/);
|
|
});
|