MiniSearch / scripts /changelog-guard.test.js
system's picture
system HF Staff
Sync from felladrin/MiniSearch@4f21bd1
7aa615f verified
Raw History Blame Contribute Delete
8.36 kB
import { execFileSync } from "node:child_process";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SCRIPT_PATH = path.resolve(__dirname, "changelog-guard.cjs");
const REPO_ROOT = path.resolve(__dirname, "..");
const { evaluate, MAX_LISTED_FILES } = createRequire(import.meta.url)(
SCRIPT_PATH,
);
function runGuard(args = [], { stdin, env } = {}) {
try {
const output = execFileSync(process.execPath, [SCRIPT_PATH, ...args], {
cwd: REPO_ROOT,
encoding: "utf8",
stdio: "pipe",
input: stdin ?? "",
env: { ...process.env, ...env },
});
return { exitCode: 0, stdout: output, stderr: "" };
} catch (error) {
if (error.signal) throw new Error(`Killed by ${error.signal}`);
return {
exitCode: error.status ?? 1,
stdout: error.stdout ?? "",
stderr: error.stderr ?? "",
};
}
}
// A shell pipeline, so the writer owns its end of the pipe. execFileSync's
// `input` hands the script a file descriptor instead, which never reproduces
// a writer that is slow or that marks the pipe non-blocking.
function runPipeline(writerCommand) {
try {
const output = execFileSync(
"bash",
["-c", `${writerCommand} | "$0" "$1"`, process.execPath, SCRIPT_PATH],
{ cwd: REPO_ROOT, encoding: "utf8", stdio: "pipe" },
);
return { exitCode: 0, stdout: output };
} catch (error) {
if (error.signal) throw new Error(`Killed by ${error.signal}`);
return { exitCode: error.status ?? 1, stdout: error.stdout ?? "" };
}
}
describe("changelog-guard decision", () => {
it("fails a user-facing change that carries no changelog entry", () => {
const { ok, reason } = evaluate({
changedFiles: ["client/modules/search.ts"],
});
expect(ok).toBe(false);
expect(reason).toContain("client/modules/search.ts");
expect(reason).toContain("changelog.md");
});
it("passes the same change once changelog.md is part of it", () => {
const { ok } = evaluate({
changedFiles: ["client/modules/search.ts", "changelog.md"],
});
expect(ok).toBe(true);
});
it("passes a docs-only change", () => {
const { ok } = evaluate({
changedFiles: ["docs/pull-requests.md", "README.md", "agents.md"],
});
expect(ok).toBe(true);
});
it("passes a lockfile-only change", () => {
const { ok } = evaluate({
changedFiles: ["package-lock.json", "package.json"],
});
expect(ok).toBe(true);
});
it("passes a user-facing change carrying the skip-changelog label", () => {
const { ok, reason } = evaluate({
changedFiles: ["server/webSearchService.ts"],
hasSkipLabel: true,
});
expect(ok).toBe(true);
expect(reason).toContain("skip-changelog");
});
// "renovate[bot]" is the REST shape, which the workflow reads.
// "app/renovate" is the GraphQL shape, which `gh pr view --json author`
// returns. Both pass, so changing where the author comes from cannot
// silently break the hatch.
it.each([
"renovate[bot]",
"dependabot[bot]",
"app/renovate",
"app/dependabot",
])("passes a %s pull request that only bumps the Dockerfile", (login) => {
const { ok, reason } = evaluate({
changedFiles: ["Dockerfile"],
authorLogin: login,
});
expect(ok).toBe(true);
expect(reason).toContain(login);
});
it.each(["renovate", "dependabot", "renovate-bot", "app/renovate-fork"])(
"does not treat %s as a bot login",
(login) => {
expect(
evaluate({ changedFiles: ["Dockerfile"], authorLogin: login }).ok,
).toBe(false);
},
);
it("fails a human pull request that changes the Dockerfile with no entry", () => {
const { ok, reason } = evaluate({
changedFiles: ["Dockerfile"],
authorLogin: "felladrin",
});
expect(ok).toBe(false);
expect(reason).toContain("Dockerfile");
});
it.each([
"Dockerfile",
"docker-compose.yml",
"docker-compose.production.yml",
"searxng-settings.yml",
"client/index.tsx",
"server/searchToken.ts",
"shared/types.ts",
])("treats %s as user-facing", (filePath) => {
expect(evaluate({ changedFiles: [filePath] }).ok).toBe(false);
});
it.each([
"docs/api.md",
"changelog.md",
"e2e/smoke.spec.ts",
"scripts/changelog-guard.cjs",
".github/workflows/ci.yml",
"clientele/notes.txt",
])("treats %s as not user-facing", (filePath) => {
expect(evaluate({ changedFiles: [filePath] }).ok).toBe(true);
});
it("names today's UTC date in the heading it asks for", () => {
const { reason } = evaluate({
changedFiles: ["client/index.tsx"],
// 00:30 UTC on the 24th; a local-time formatter would print the 23rd.
now: new Date("2026-09-24T00:30:00Z"),
});
expect(reason).toContain("## 2026-09-24");
});
it("caps the listed files and counts the rest", () => {
const changedFiles = Array.from(
{ length: MAX_LISTED_FILES + 3 },
(_, index) => `client/modules/file${index}.ts`,
);
const { ok, reason } = evaluate({ changedFiles });
expect(ok).toBe(false);
expect(reason).toContain("and 3 more");
expect(reason).not.toContain(`file${MAX_LISTED_FILES}.ts,`);
});
it("ignores blank lines from an empty git diff", () => {
expect(evaluate({ changedFiles: ["", " ", "\n"] }).ok).toBe(true);
});
it("requires an entry even when a non-trigger file is also changed", () => {
const { ok } = evaluate({
changedFiles: ["docs/api.md", "server/rerankerService.ts"],
});
expect(ok).toBe(false);
});
});
describe("changelog-guard command line", () => {
it("exits 1 and prints the remedy for a failing change", () => {
const { exitCode, stdout } = runGuard(["client/modules/search.ts"]);
expect(exitCode).toBe(1);
expect(stdout).toContain("client/modules/search.ts");
expect(stdout).toContain("skip-changelog");
});
it("exits 0 for a change that includes the changelog", () => {
const { exitCode } = runGuard(["client/modules/search.ts", "changelog.md"]);
expect(exitCode).toBe(0);
});
it("reads the changed files from stdin, one per line", () => {
const { exitCode, stdout } = runGuard([], {
stdin: "server/searchToken.ts\ndocs/api.md\n",
});
expect(exitCode).toBe(1);
expect(stdout).toContain("server/searchToken.ts");
});
it("honours --skip-label", () => {
expect(runGuard(["Dockerfile", "--skip-label"]).exitCode).toBe(0);
});
it("honours --author for a bot", () => {
expect(runGuard(["Dockerfile", "--author", "renovate[bot]"]).exitCode).toBe(
0,
);
expect(runGuard(["Dockerfile", "--author", "app/renovate"]).exitCode).toBe(
0,
);
});
it("reads the same facts from the environment", () => {
expect(
runGuard(["Dockerfile"], {
env: { CHANGELOG_GUARD_SKIP_LABEL: "true" },
}).exitCode,
).toBe(0);
expect(
runGuard(["Dockerfile"], {
env: { CHANGELOG_GUARD_AUTHOR: "renovate[bot]" },
}).exitCode,
).toBe(0);
expect(
runGuard(["Dockerfile"], {
env: { CHANGELOG_GUARD_SKIP_LABEL: "false" },
}).exitCode,
).toBe(1);
});
it("exits 1 on an unknown option", () => {
const { exitCode, stderr } = runGuard(["--nope"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("Unknown option");
});
it("exits 0 when nothing changed", () => {
expect(runGuard([], { stdin: "" }).exitCode).toBe(0);
});
it("waits for a writer that has not produced anything yet", () => {
const { exitCode, stdout } = runPipeline(
`sleep 0.3; printf 'server/searchToken.ts\\n'`,
);
expect(exitCode).toBe(1);
expect(stdout).toContain("server/searchToken.ts");
});
it("reads a list larger than one read buffer", () => {
const paths = Array.from(
{ length: 4000 },
(_, index) => `docs/file${index}.md`,
);
paths.push("client/index.tsx");
const input = `${paths.join("\n")}\n`;
expect(input.length).toBeGreaterThan(64 * 1024);
const { exitCode, stdout } = runGuard([], { stdin: input });
expect(exitCode).toBe(1);
expect(stdout).toContain("client/index.tsx");
});
});