fix(plugin): install the client build's deps with npm ci

npm install reuses whatever the cache and an earlier run left behind and still
reports success, so a truncated cache entry produced a node_modules that was
missing typescript's lib/*.d.ts — the build then died with TS2318, every global
type undefined. npm ci starts from an empty tree and verifies each tarball
against the lockfile's integrity hash. Falls back to install when the copied
client has no usable lockfile.
This commit is contained in:
2026-09-08 15:43:20 +07:00
parent bede2b5813
commit dab01b7f2b
2 changed files with 94 additions and 3 deletions
+69
View File
@@ -339,3 +339,72 @@ describe("plugin-open / plugin-bootstrap require cycle", () => {
assert.equal(typeof require("../../scripts/plugin-open").buildClient, "function");
});
});
describe("buildClient dependency install", () => {
const { buildClient } = require("../../scripts/plugin-open");
// A fake plugin root holding just enough of a client/ to copy.
function fakeRoot() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-root-"));
fs.mkdirSync(path.join(root, "client"));
fs.writeFileSync(path.join(root, "client", "package.json"), "{}");
return root;
}
// Records every npm invocation; `fails` names the subcommands that exit non-zero.
// A successful "run build" writes the dist/ the real Vite build would produce.
function fakeExec(calls, fails = []) {
return (_cmd, args, cwd) => {
calls.push(args.join(" "));
if (fails.includes(args[0])) return false;
if (args[0] === "run") fs.mkdirSync(path.join(cwd, "dist"), { recursive: true });
return true;
};
}
it("installs with `npm ci`, which cannot reuse a half-populated node_modules", () => {
// Regression: `npm install` reported success against a truncated cache
// entry, and the build then died with TS2318 — typescript's lib/*.d.ts
// missing, so every global type (Array, Boolean, …) was undefined.
resetRuntime();
const calls = [];
const result = buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls) });
assert.equal(result, "built");
assert.equal(calls[0].startsWith("ci"), true, `expected npm ci first, got ${calls[0]}`);
assert.equal(
calls.some((c) => c.startsWith("install")),
false,
"npm install must not run once ci succeeded"
);
});
it("falls back to `npm install` when ci refuses the lockfile", () => {
resetRuntime();
const calls = [];
const result = buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls, ["ci"]) });
assert.equal(result, "built");
assert.deepEqual(
calls.map((c) => c.split(" ")[0]),
["ci", "install", "run"]
);
});
it("fails without building when neither install path works", () => {
resetRuntime();
const calls = [];
const result = buildClient({
rt: RT,
root: fakeRoot(),
exec: fakeExec(calls, ["ci", "install"]),
});
assert.equal(result, "failed");
assert.equal(
calls.some((c) => c.startsWith("run")),
false,
"the build must not run on a broken dependency tree"
);
});
});