inspector/scripts/dependency-refresh.test.mjs
cliffhall 6af74424d8
chore(deps): remove dependabot.yml, fold action bumps into the sweep
Closes #2235.

Switches the old dependency flow off now that #2232 landed the
replacement. Removes the five npm ecosystem entries (root plus each
client under clients/*) and, resolving the question #2229 left open, the
github-actions entry as well — so .github/dependabot.yml goes away
outright rather than being emptied, which its schema does not allow.

The github-actions entry had exactly the property #2229 exists to
remove: it opened a grouped monthly PR carrying no `Closes #N` and no
board card, the one standing exception to "every PR references an
issue". Deleting it unreplaced would have left 9 actions unwatched, and
`npm outdated` says nothing about actions, so the monthly sweep now also
checks every `uses:` ref under .github/workflows and renders the stale
ones as one more section of the same tracking issue.

Ranking comes from the release LIST, not `releases/latest`. That
endpoint returns the release GitHub designates most recent, not the
greatest version, so an action publishing a maintenance release for an
older major (a v6.9.1 cut after v8.0.0) would make a workflow pinned to
v7 compare against v6 and read as current — silently missing a whole
major upgrade, the one thing this check exists to catch.

Staleness is compared only to the precision the ref specifies. `v7` is a
moving major tag that GitHub repoints at every v7.x release, so `v7`
against a highest of `v7.0.1` is current and only `v8` makes it stale;
an exactly-pinned `v7.0.0` is behind `v7.0.1`; a SHA pin is deliberately
immovable and is never reported.

A release lookup suppresses only a 404 — the legitimate "this action has
never cut a release" answer — and throws on anything else. Treating a
rate limit or an expired token as "no release" is indistinguishable from
"not stale", and since every action here already sits on its latest
major, the resulting empty section is byte-identical to a healthy run.

`buildClearedBody` now speaks for both halves: once actions are in scope,
its npm-only wording would assert a clean bill of health the sweep never
checked.

Dependabot security updates are unaffected — they are configured in repo
settings, not in this file, and kept working while it was missing
entirely (#1833, #1840). That note moves into the workflow header rather
than dying with the file; #2233 is where they are turned off
deliberately. The script header, the workflow header and the generated
issue body all say version-update PRs rather than claiming Dependabot is
replaced wholesale.

Also renames the workflow's npm-outdated job to dependency-sweep now
that the sweep is no longer npm-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGtzPg3UxMLszysQXXfqax
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-04 09:06:31 -04:00

465 lines
17 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,
highestVersionTag,
isActionStale,
isMissingRelease,
main,
parseActionRefs,
parseOutdated,
parseVersionRef,
staleActions,
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
* @param {string[]} [opts.releaseTags] tags every action's release list returns
* @param {number} [opts.releasesStatus] exit status for every release lookup
* @param {string} [opts.releasesStderr] stderr for a failing release lookup
*/
function fakeSpawn({
outdated = {},
outdatedStatus,
existing = [],
milestone = "v2.6.0",
releaseTags = [],
releasesStatus,
releasesStderr = "",
} = {}) {
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: "" };
// MUST be tested before the milestone branch below: both are `gh api`,
// so matching on args[0] alone hands the release lookup the milestone
// string and the assertion silently checks nothing.
if (args[0] === "api" && args[1].includes("/releases"))
return {
status: releasesStatus ?? 0,
stdout: releasesStatus ? "" : releaseTags.join("\n"),
stderr: releasesStderr,
};
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("parseActionRefs pulls owner/repo@ref out of a workflow, in file order", () => {
const yaml = [
"jobs:",
" build:",
" steps:",
" - uses: actions/checkout@v7",
" - name: Setup",
" uses: actions/setup-node@v7",
' - uses: "docker/login-action@v4"',
" - uses: docker/build-push-action@v7 # trailing comment",
].join("\n");
assert.deepEqual(parseActionRefs(yaml), [
{ action: "actions/checkout", ref: "v7" },
{ action: "actions/setup-node", ref: "v7" },
{ action: "docker/login-action", ref: "v4" },
{ action: "docker/build-push-action", ref: "v7" },
]);
});
test("parseActionRefs skips local, container and unpinned steps", () => {
const yaml = [
" - uses: ./.github/actions/local",
" - uses: docker://alpine:3.20",
" - uses: actions/checkout",
" - uses: github/codeql-action/init@v3",
].join("\n");
assert.deepEqual(parseActionRefs(yaml), [
{ action: "github/codeql-action/init", ref: "v3" },
]);
});
test("parseVersionRef reads a numeric ref and rejects anything else", () => {
assert.deepEqual(parseVersionRef("v7"), [7]);
assert.deepEqual(parseVersionRef("7.0.1"), [7, 0, 1]);
assert.equal(parseVersionRef("main"), null);
assert.equal(
parseVersionRef("8f4b7f84864484a7bf31766abe9204da3cbe65b3"),
null,
);
});
test("isActionStale compares only to the precision the ref specifies", () => {
// `v7` is a moving major tag, so a v7.x release does not make it stale.
assert.equal(isActionStale("v7", "v7.0.1"), false);
assert.equal(isActionStale("v7", "v8.0.0"), true);
// An exactly-pinned ref is behind its own patch release.
assert.equal(isActionStale("v7.0.0", "v7.0.1"), true);
assert.equal(isActionStale("v7.1", "v7.0.9"), false);
});
test("isActionStale reports nothing for a ref it cannot rank", () => {
// A SHA pin is deliberately immovable; a tag comparison says nothing about it.
assert.equal(
isActionStale("8f4b7f84864484a7bf31766abe9204da3cbe65b3", "v5"),
false,
);
assert.equal(isActionStale("main", "v5"), false);
assert.equal(isActionStale("v5", "not-a-tag"), false);
});
test("highestVersionTag picks the greatest version, not the newest entry", () => {
// GitHub's `releases/latest` is the DESIGNATED latest, not the greatest
// version: a maintenance release cut for an older major after a newer one
// would make a workflow on v7 read as current (Copilot).
assert.equal(highestVersionTag(["v6.9.1", "v8.0.0", "v7.2.0"]), "v8.0.0");
assert.equal(highestVersionTag(["v7", "v7.0.1"]), "v7.0.1");
assert.equal(highestVersionTag(["v10.0.0", "v9.9.9"]), "v10.0.0");
});
test("highestVersionTag ignores tags it cannot parse", () => {
assert.equal(highestVersionTag(["nightly", "v2.0.0", "latest"]), "v2.0.0");
assert.equal(highestVersionTag(["nightly", "latest"]), null);
assert.equal(highestVersionTag([]), null);
});
test("staleActions dedupes, drops actions with no known release and sorts", () => {
const refs = [
{ action: "actions/checkout", ref: "v7" },
{ action: "actions/cache", ref: "v6" },
{ action: "actions/cache", ref: "v6" },
{ action: "some/unreleased", ref: "v1" },
];
assert.deepEqual(
staleActions(refs, {
"actions/cache": "v7.0.0",
"actions/checkout": "v7.0.1",
"some/unreleased": null,
}),
[{ action: "actions/cache", current: "v6", latest: "v7.0.0" }],
);
});
test("isMissingRelease suppresses only a 404, never a real API failure", () => {
// A 404 is the legitimate "this action cuts no GitHub releases" answer.
assert.equal(isMissingRelease("gh: Not Found (HTTP 404)"), true);
// Everything else must fail the sweep rather than read as "not stale" — an
// empty actions section is indistinguishable from a healthy run (Copilot).
assert.equal(
isMissingRelease("gh: API rate limit exceeded (HTTP 403)"),
false,
);
assert.equal(isMissingRelease("gh: Bad credentials (HTTP 401)"), false);
assert.equal(isMissingRelease("gh: Server Error (HTTP 502)"), false);
assert.equal(isMissingRelease(""), false);
});
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;
}
});
test("buildIssueBody renders a GitHub Actions section after the npm ones", () => {
const body = buildIssueBody(
[{ label: "root", packages: [] }],
[{ action: "actions/cache", current: "v6", latest: "v7.0.0" }],
);
assert.ok(body.startsWith(ISSUE_MARKER));
assert.ok(body.includes("### GitHub Actions"));
assert.ok(body.includes("| `actions/cache` | v6 | v7.0.0 |"));
assert.ok(!body.includes("### `root`"));
});
test("buildIssueBody returns null only when npm and actions are both clean", () => {
assert.equal(buildIssueBody([{ label: "root", packages: [] }], []), null);
assert.notEqual(
buildIssueBody(
[],
[{ action: "actions/cache", current: "v6", latest: "v7.0.0" }],
),
null,
);
});
test("buildClearedBody speaks for both halves of the sweep", () => {
const body = buildClearedBody("2026-09-04");
assert.ok(body.startsWith(ISSUE_MARKER));
assert.ok(body.includes("2026-09-04"));
// npm-only wording here would assert an all-clear the sweep never checked.
assert.match(body, /npm package/);
assert.match(body, /uses:/);
});
test("main fails the sweep when a release lookup errors, rather than reporting no stale actions", () => {
// The silent-success shape one level down: a 403 read as "no release" is
// indistinguishable from "not stale", and every action here is already on
// its latest major, so the empty section would look like a healthy run.
const spawn = fakeSpawn({
releasesStatus: 1,
releasesStderr: "gh: API rate limit exceeded (HTTP 403)",
});
assert.throws(
() => captureLog(() => main("o/r", spawn)),
/release lookup for .* failed/,
);
});
test("main treats a 404 release lookup as 'this action cuts no releases'", () => {
const spawn = fakeSpawn({
releasesStatus: 1,
releasesStderr: "gh: Not Found (HTTP 404)",
});
const log = captureLog(() => main("o/r", spawn));
assert.match(log.join("\n"), /no-op/);
});
test("main asks for release lists, not the designated latest release", () => {
const spawn = fakeSpawn();
captureLog(() => main("o/r", spawn));
const lookups = spawn.calls.filter(
(c) =>
c.cmd === "gh" && c.args[0] === "api" && c.args[1].includes("/releases"),
);
assert.ok(lookups.length > 0, "expected at least one release lookup");
for (const call of lookups) {
// `releases/latest` is GitHub's designated latest, not the greatest
// version — ranking must come from the list.
assert.doesNotMatch(call.args[1], /releases\/latest/);
assert.match(call.args[1], /\/releases\?/);
}
});