`main refuses to run without a repo` called `main(undefined, …)` so the default parameter would fire, then asserted it throws. The default reads `process.env.GITHUB_REPOSITORY` — which GitHub Actions sets on every run — so the assertion held only where the variable happened to be unset. It passed locally and failed CI, the one environment where the default is always populated. The test now clears the variable and restores it, and a second test covers the other half of the default: with it set, `main(undefined, …)` uses it instead of throwing. Between them the default's behavior is pinned in both environments rather than inherited from whichever one is running. Verified both ways: 14 pass with the variable unset and with it set, and the full `test:scripts` suite is 411/411 under the CI environment shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWMJENjs1mExXE5XPJTEei Signed-off-by: cliffhall <cliff@futurescale.com>
268 lines
9.2 KiB
JavaScript
268 lines
9.2 KiB
JavaScript
// Tests for dependency-refresh.mjs (#2229) — both the pure parsing/formatting
|
|
// helpers and `main()`'s orchestration, the latter driven through the injected
|
|
// spawn function so no `npm` or `gh` process is ever started.
|
|
//
|
|
// `main()` is covered rather than left to `workflow_dispatch` because a
|
|
// production trigger is not a test (Copilot): the helper-only suite this
|
|
// replaced passed while a non-zero `npm outdated` exit reported a clean sweep.
|
|
// Run via `npm run test:scripts`.
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
buildClearedBody,
|
|
buildIssueBody,
|
|
main,
|
|
parseOutdated,
|
|
INSTALLS,
|
|
ISSUE_MARKER,
|
|
} from "./dependency-refresh.mjs";
|
|
|
|
/**
|
|
* A fake `spawnSync` that answers by command shape and records every call.
|
|
*
|
|
* @param {object} opts
|
|
* @param {Record<string,unknown>} [opts.outdated] parsed `npm outdated` payload, per install dir
|
|
* @param {number} [opts.outdatedStatus] exit status for every `npm outdated`
|
|
* @param {Array<{number:number,body:string}>} [opts.existing] what `gh issue list` returns
|
|
* @param {string|null} [opts.milestone] what the milestone lookup returns
|
|
*/
|
|
function fakeSpawn({
|
|
outdated = {},
|
|
outdatedStatus,
|
|
existing = [],
|
|
milestone = "v2.6.0",
|
|
} = {}) {
|
|
const calls = [];
|
|
const fn = (cmd, args, opts) => {
|
|
calls.push({ cmd, args, cwd: opts?.cwd });
|
|
if (cmd === "npm") {
|
|
const payload = outdated[opts.cwd] ?? {};
|
|
const found = Object.keys(payload).length > 0;
|
|
return {
|
|
// Real `npm outdated` exits 1 precisely when it found something.
|
|
status: outdatedStatus ?? (found ? 1 : 0),
|
|
stdout: found ? JSON.stringify(payload) : "",
|
|
stderr: outdatedStatus ? "ENOTFOUND registry.npmjs.org" : "",
|
|
};
|
|
}
|
|
if (args[0] === "issue" && args[1] === "list")
|
|
return { status: 0, stdout: JSON.stringify(existing), stderr: "" };
|
|
if (args[0] === "api")
|
|
return {
|
|
status: 0,
|
|
stdout: milestone ? `${milestone}\n` : "",
|
|
stderr: "",
|
|
};
|
|
if (args[0] === "issue" && args[1] === "create")
|
|
return {
|
|
status: 0,
|
|
stdout: "https://github.com/o/r/issues/9\n",
|
|
stderr: "",
|
|
};
|
|
if (args[0] === "issue" && args[1] === "edit")
|
|
return { status: 0, stdout: "", stderr: "" };
|
|
throw new Error(`unexpected spawn: ${cmd} ${args.join(" ")}`);
|
|
};
|
|
fn.calls = calls;
|
|
return fn;
|
|
}
|
|
|
|
const ghCall = (spawn, verb) =>
|
|
spawn.calls.find((c) => c.cmd === "gh" && c.args[1] === verb);
|
|
|
|
/** Silence main()'s progress logging; returns the captured lines. */
|
|
function captureLog(run) {
|
|
const lines = [];
|
|
const original = console.log;
|
|
console.log = (...a) => lines.push(a.join(" "));
|
|
try {
|
|
run();
|
|
} finally {
|
|
console.log = original;
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
test("parseOutdated returns [] for empty npm-outdated output", () => {
|
|
assert.deepEqual(parseOutdated(""), []);
|
|
assert.deepEqual(parseOutdated("{}"), []);
|
|
});
|
|
|
|
test("parseOutdated normalizes and sorts entries by name", () => {
|
|
const json = JSON.stringify({
|
|
zod: { current: "3.0.0", wanted: "3.1.0", latest: "4.0.0" },
|
|
ajv: { current: "8.0.0", wanted: "8.0.0", latest: "8.1.0" },
|
|
});
|
|
assert.deepEqual(parseOutdated(json), [
|
|
{ name: "ajv", current: "8.0.0", wanted: "8.0.0", latest: "8.1.0" },
|
|
{ name: "zod", current: "3.0.0", wanted: "3.1.0", latest: "4.0.0" },
|
|
]);
|
|
});
|
|
|
|
test("parseOutdated falls back when a field is missing", () => {
|
|
const json = JSON.stringify({ pkg: { current: "1.0.0" } });
|
|
assert.deepEqual(parseOutdated(json), [
|
|
{ name: "pkg", current: "1.0.0", wanted: "1.0.0", latest: "?" },
|
|
]);
|
|
});
|
|
|
|
test("buildIssueBody returns null when every install is up to date", () => {
|
|
assert.equal(
|
|
buildIssueBody([
|
|
{ label: "root", packages: [] },
|
|
{ label: "clients/web", packages: [] },
|
|
]),
|
|
null,
|
|
);
|
|
});
|
|
|
|
test("buildIssueBody starts with the idempotency marker and skips empty installs", () => {
|
|
const body = buildIssueBody([
|
|
{ label: "root", packages: [] },
|
|
{
|
|
label: "clients/web",
|
|
packages: [
|
|
{ name: "zod", current: "3.0.0", wanted: "3.1.0", latest: "4.0.0" },
|
|
],
|
|
},
|
|
]);
|
|
assert.ok(body.startsWith(ISSUE_MARKER));
|
|
assert.ok(body.includes("### `clients/web`"));
|
|
assert.ok(!body.includes("### `root`"));
|
|
assert.ok(body.includes("| `zod` | 3.0.0 | 3.1.0 | 4.0.0 |"));
|
|
});
|
|
|
|
test("buildClearedBody keeps the marker so the next sweep still finds the issue", () => {
|
|
const body = buildClearedBody("2026-09-03");
|
|
assert.ok(body.startsWith(ISSUE_MARKER));
|
|
assert.ok(body.includes("2026-09-03"));
|
|
assert.ok(body.includes("Safe to close"));
|
|
});
|
|
|
|
test("main throws when npm outdated exits with an undocumented status", () => {
|
|
// The regression that motivated covering main(): exit 2 used to fall through
|
|
// to empty stdout and report a clean sweep across all five installs.
|
|
const spawn = fakeSpawn({ outdatedStatus: 2 });
|
|
assert.throws(
|
|
() => captureLog(() => main("o/r", spawn)),
|
|
/npm outdated failed in \.\s*\(exit 2\).*ENOTFOUND/s,
|
|
);
|
|
assert.equal(ghCall(spawn, "create"), undefined);
|
|
});
|
|
|
|
test("main sweeps every install and files one milestoned issue", () => {
|
|
const spawn = fakeSpawn({
|
|
outdated: {
|
|
"clients/web": {
|
|
zod: { current: "3.0.0", wanted: "3.1.0", latest: "4.0.0" },
|
|
},
|
|
},
|
|
});
|
|
const log = captureLog(() => main("o/r", spawn));
|
|
|
|
assert.deepEqual(
|
|
spawn.calls.filter((c) => c.cmd === "npm").map((c) => c.cwd),
|
|
INSTALLS.map((i) => i.dir),
|
|
);
|
|
const create = ghCall(spawn, "create");
|
|
assert.ok(create, "expected an issue to be created");
|
|
assert.deepEqual(create.args.slice(-2), ["--milestone", "v2.6.0"]);
|
|
assert.ok(create.args[create.args.indexOf("--body") + 1].includes("`zod`"));
|
|
assert.ok(log.some((l) => l.includes("filed")));
|
|
});
|
|
|
|
test("main edits the existing issue instead of filing a duplicate", () => {
|
|
const spawn = fakeSpawn({
|
|
outdated: {
|
|
".": { ajv: { current: "8.0.0", wanted: "8.1.0", latest: "8.1.0" } },
|
|
},
|
|
existing: [{ number: 77, body: `${ISSUE_MARKER}\nstale` }],
|
|
});
|
|
const log = captureLog(() => main("o/r", spawn));
|
|
|
|
assert.equal(ghCall(spawn, "create"), undefined);
|
|
const edit = ghCall(spawn, "edit");
|
|
assert.equal(edit.args[2], "77");
|
|
assert.ok(edit.args[edit.args.length - 1].includes("`ajv`"));
|
|
assert.ok(log.some((l) => l.includes("updated existing #77")));
|
|
});
|
|
|
|
test("main clears a still-open issue once everything is current again", () => {
|
|
const spawn = fakeSpawn({
|
|
existing: [{ number: 77, body: `${ISSUE_MARKER}\n| \`zod\` | 3.0.0 |` }],
|
|
});
|
|
const log = captureLog(() => main("o/r", spawn));
|
|
|
|
const edit = ghCall(spawn, "edit");
|
|
assert.ok(edit, "an open issue must be cleared, not left with a stale table");
|
|
const body = edit.args[edit.args.length - 1];
|
|
assert.ok(body.startsWith(ISSUE_MARKER));
|
|
assert.ok(!body.includes("`zod`"));
|
|
assert.ok(log.some((l) => l.includes("cleared stale list on #77")));
|
|
});
|
|
|
|
test("main is a true no-op when nothing is outdated and no issue is open", () => {
|
|
const spawn = fakeSpawn();
|
|
const log = captureLog(() => main("o/r", spawn));
|
|
|
|
assert.equal(ghCall(spawn, "edit"), undefined);
|
|
assert.equal(ghCall(spawn, "create"), undefined);
|
|
assert.ok(log.some((l) => l.includes("no-op")));
|
|
});
|
|
|
|
test("main says Incoming, not Todo, when it files without a milestone", () => {
|
|
const spawn = fakeSpawn({
|
|
outdated: {
|
|
".": { ajv: { current: "8.0.0", wanted: "8.1.0", latest: "8.1.0" } },
|
|
},
|
|
milestone: null,
|
|
});
|
|
const log = captureLog(() => main("o/r", spawn));
|
|
|
|
const create = ghCall(spawn, "create");
|
|
assert.ok(!create.args.includes("--milestone"));
|
|
// Unmilestoned is unapproved; triage parks it in Incoming.
|
|
assert.ok(log.some((l) => l.includes("Incoming")));
|
|
assert.ok(!log.some((l) => l.includes("Todo")));
|
|
});
|
|
|
|
test("main refuses to run without a repo", () => {
|
|
// `main`'s default reads process.env.GITHUB_REPOSITORY, which GitHub Actions
|
|
// sets on every run — so this has to clear the variable rather than assume
|
|
// the ambient environment lacks it. Relying on the ambient value passed
|
|
// locally and failed in CI, which is the one place the default is always
|
|
// populated.
|
|
const saved = process.env.GITHUB_REPOSITORY;
|
|
delete process.env.GITHUB_REPOSITORY;
|
|
try {
|
|
assert.throws(
|
|
() => main(undefined, fakeSpawn()),
|
|
/GITHUB_REPOSITORY unset/,
|
|
);
|
|
} finally {
|
|
if (saved !== undefined) process.env.GITHUB_REPOSITORY = saved;
|
|
}
|
|
});
|
|
|
|
test("main falls back to GITHUB_REPOSITORY when no repo is passed", () => {
|
|
// The other half of the default: with the variable set, `main(undefined, …)`
|
|
// must use it rather than throw. Together the two tests pin the default's
|
|
// behavior in both environments instead of inheriting whichever one happens
|
|
// to be running.
|
|
const saved = process.env.GITHUB_REPOSITORY;
|
|
process.env.GITHUB_REPOSITORY = "env/repo";
|
|
try {
|
|
const spawn = fakeSpawn({
|
|
outdated: {
|
|
".": { ajv: { current: "8.0.0", wanted: "8.1.0", latest: "8.1.0" } },
|
|
},
|
|
});
|
|
captureLog(() => main(undefined, spawn));
|
|
const create = ghCall(spawn, "create");
|
|
assert.equal(create.args[create.args.indexOf("--repo") + 1], "env/repo");
|
|
} finally {
|
|
if (saved === undefined) delete process.env.GITHUB_REPOSITORY;
|
|
else process.env.GITHUB_REPOSITORY = saved;
|
|
}
|
|
});
|