All checks were successful
CI / verification (pull_request) Successful in 21s
The PR #34 review listed two forms that pass the "require at column 0" heuristic: a function written whole on a column-0 line, and a require( wrapped onto a column-0 line inside a function. Both stay open: neither fits the style of the repository, and the second has the very shape of the legitimate `} = require("node:fs");` that closes index.js's multi-line import, so only a parser could tell them apart. They are now written next to the pin and in CLAUDE.md, rather than only in a closed PR and its follow-up issue. CLAUDE.md also records the proxy bypass of boot-from-copy.sh, the wider continue-on-error pin and its self-check, and the new counts (ci.test.js 12 -> 18 cases, vitest 340 -> 346). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
238 lines
10 KiB
JavaScript
238 lines
10 KiB
JavaScript
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const http = require("node:http");
|
|
const { spawn } = require("node:child_process");
|
|
|
|
// Pins on the CI workflow and on what the boot check assumes of the Dockerfile.
|
|
// The runtime stays 0-dependency and vitest ships no YAML parser, so these read
|
|
// the files with narrow regexes: each one looks at a single key, never at the
|
|
// YAML structure as a whole.
|
|
|
|
const ROOT = path.join(__dirname, "..");
|
|
const CI_PATH = path.join(ROOT, ".forgejo", "workflows", "ci.yml");
|
|
const ci = fs.readFileSync(CI_PATH, "utf-8");
|
|
const dockerfile = fs.readFileSync(path.join(ROOT, "Dockerfile"), "utf-8");
|
|
|
|
// Comment lines stripped, so an explanation that quotes a key cannot satisfy
|
|
// (or break) a pin.
|
|
function stripCommentLines(text) {
|
|
return text
|
|
.split("\n")
|
|
.filter((line) => !/^\s*#/.test(line))
|
|
.join("\n");
|
|
}
|
|
const ciCode = stripCommentLines(ci);
|
|
|
|
// Lines of a top-level-indented block (` pull_request:`), up to the next key at
|
|
// the same or a lower indentation.
|
|
function block(text, key) {
|
|
const lines = text.split("\n");
|
|
const start = lines.findIndex((l) => new RegExp(`^(\\s*)${key}:`).test(l));
|
|
if (start === -1) return null;
|
|
const indent = lines[start].match(/^(\s*)/)[1].length;
|
|
const out = [lines[start]];
|
|
for (const line of lines.slice(start + 1)) {
|
|
if (line.trim() === "") continue;
|
|
if (line.match(/^(\s*)/)[1].length <= indent) break;
|
|
out.push(line);
|
|
}
|
|
return out.join("\n");
|
|
}
|
|
|
|
describe("CI workflow", () => {
|
|
test("runs in the Dockerfile's own base image", () => {
|
|
const from = dockerfile.match(/^\s*FROM\s+(\S+)/m);
|
|
const container = ciCode.match(/^\s*container:\s*["']?([^"'\s]+)["']?\s*$/m);
|
|
|
|
expect(from).not.toBeNull();
|
|
expect(container).not.toBeNull();
|
|
expect(container[1]).toBe(from[1]);
|
|
});
|
|
|
|
test("runs the unit tests, the socket pass and the boot check", () => {
|
|
expect(ciCode).toMatch(/^\s*run:\s*npm ci\s*$/m);
|
|
expect(ciCode).toMatch(/^\s*run:\s*npm test\s*$/m);
|
|
expect(ciCode).toMatch(/^\s*run:\s*bash test-curl\.sh\s*$/m);
|
|
expect(ciCode).toMatch(/^\s*run:\s*sh scripts\/boot-from-copy\.sh\s*$/m);
|
|
});
|
|
|
|
test("builds every pull request, whatever branch it targets", () => {
|
|
// A `branches: [main]` here would leave the intermediate links of a stack
|
|
// without any status.
|
|
const pr = block(ciCode, "pull_request");
|
|
|
|
expect(pr).not.toBeNull();
|
|
expect(pr).not.toMatch(/branches(-ignore)?:/);
|
|
});
|
|
|
|
test("builds pushes to main", () => {
|
|
const push = block(ciCode, "push");
|
|
|
|
expect(push).not.toBeNull();
|
|
expect(push).toMatch(/branches:\s*\[\s*main\s*\]/);
|
|
});
|
|
|
|
test("avoids the two forms the runner rejects before the first command", () => {
|
|
// `defaults:` anywhere, and `working-directory` on a `uses:` step.
|
|
expect(ciCode).not.toMatch(/^\s*defaults:/m);
|
|
const steps = ciCode.split(/^\s*- (?=name:|uses:|run:)/m);
|
|
for (const step of steps) {
|
|
if (/^\s*uses:|\n\s*uses:/.test(step)) expect(step).not.toMatch(/working-directory:/);
|
|
}
|
|
});
|
|
|
|
// `continue-on-error: true` turns a red step green without a trace in the
|
|
// commit status. Any spelling of the key counts: a block key or a list item,
|
|
// but also a flow mapping and a quoted key, which a pin anchored at the start
|
|
// of the line let through (#35). Unanchored on purpose: the key quoted in a
|
|
// `run:` script, a step name or a trailing comment fails the pin too, loudly.
|
|
// Only full comment lines are exempt, being stripped from `ciCode`.
|
|
const CONTINUE_ON_ERROR = /continue-on-error["']?\s*:/;
|
|
|
|
test("never lets a failing step pass", () => {
|
|
expect(ciCode).not.toMatch(CONTINUE_ON_ERROR);
|
|
});
|
|
|
|
// The detector itself, through the same comment stripping as `ciCode`. Anchor
|
|
// it at the start of the line again and the last two spellings turn red.
|
|
test.each([
|
|
["a block key", " - name: x\n run: npm test\n continue-on-error: true"],
|
|
["a list item", " - continue-on-error: true\n run: npm test"],
|
|
["a flow mapping", " - { name: x, run: false, continue-on-error: true }"],
|
|
["a quoted key", ' - name: x\n "continue-on-error": true'],
|
|
])("the continue-on-error pin catches %s", (_, step) => {
|
|
expect(stripCommentLines(step)).toMatch(CONTINUE_ON_ERROR);
|
|
});
|
|
|
|
test("the continue-on-error pin ignores a full comment line quoting the key", () => {
|
|
expect(stripCommentLines(" # never add continue-on-error: true here")).not.toMatch(CONTINUE_ON_ERROR);
|
|
});
|
|
});
|
|
|
|
describe("what the boot check assumes of the Dockerfile", () => {
|
|
test("the image still drops to the node user, as the script does", () => {
|
|
expect(dockerfile).toMatch(/^\s*USER\s+node\s*$/m);
|
|
});
|
|
|
|
test("CMD is in exec form, the only one the script parses", () => {
|
|
const cmd = dockerfile.match(/^\s*CMD\s+(.+)$/m);
|
|
|
|
expect(cmd).not.toBeNull();
|
|
const argv = JSON.parse(cmd[1]);
|
|
expect(Array.isArray(argv)).toBe(true);
|
|
expect(argv.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// Spawns a command from the repository root with `env` over the test's own
|
|
// environment; a key set to undefined is dropped (spawn ignores undefined
|
|
// values). Async, so a listener in this process keeps answering meanwhile.
|
|
function run(command, args, env) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, { cwd: ROOT, env: { ...process.env, ...env } });
|
|
let stderr = "";
|
|
child.stderr.on("data", (d) => (stderr += d));
|
|
child.on("error", reject);
|
|
child.on("close", (code) => resolve({ code, stderr }));
|
|
});
|
|
}
|
|
|
|
const runBootScript = (env) => run("sh", ["scripts/boot-from-copy.sh"], env);
|
|
|
|
// A port nothing listens on. The probe closes before the script starts: should
|
|
// something take the port in between, the script's own check refuses it and
|
|
// the test fails loudly.
|
|
async function freePort() {
|
|
const probe = http.createServer();
|
|
await new Promise((resolve) => probe.listen(0, "127.0.0.1", resolve));
|
|
const { port } = probe.address();
|
|
await new Promise((resolve) => probe.close(resolve));
|
|
return port;
|
|
}
|
|
|
|
describe("boot-from-copy.sh", () => {
|
|
// A listener already on the port: without the pre-check, the wait loop could
|
|
// reach this neighbour and assert on a server it never started.
|
|
test("refuses a port something else already holds", async () => {
|
|
const neighbour = http.createServer((req, res) => res.end("not the API"));
|
|
await new Promise((resolve) => neighbour.listen(0, "127.0.0.1", resolve));
|
|
try {
|
|
const { port } = neighbour.address();
|
|
const { code, stderr } = await runBootScript({ BOOT_PORT: String(port) });
|
|
|
|
expect(code).toBe(1);
|
|
expect(stderr).toMatch(new RegExp(`port ${port} is already taken`));
|
|
} finally {
|
|
await new Promise((resolve) => neighbour.close(resolve));
|
|
}
|
|
}, 15000);
|
|
|
|
// Every curl of the script targets the loopback, and a proxy from the
|
|
// environment would answer in the server's place. Before #35 only the port
|
|
// check had --noproxy: the wait loop and the assertions reached the proxy.
|
|
test("never sends a request through a proxy from the environment", async () => {
|
|
// Answers everything, as a real proxy would, and records what reaches it.
|
|
const proxied = [];
|
|
const proxy = http.createServer((req, res) => {
|
|
proxied.push(`${req.method} ${req.url}`);
|
|
res.end("answered by the proxy");
|
|
});
|
|
await new Promise((resolve) => proxy.listen(0, "127.0.0.1", resolve));
|
|
try {
|
|
const proxyUrl = `http://127.0.0.1:${proxy.address().port}`;
|
|
// ALL_PROXY as well, so that unsetting http_proxy alone would not pass.
|
|
// An inherited NO_PROXY would hide the defect, hence dropped.
|
|
const env = { http_proxy: proxyUrl, ALL_PROXY: proxyUrl, NO_PROXY: undefined, no_proxy: undefined };
|
|
const port = await freePort();
|
|
|
|
// The proxy is live: a bare curl with the same environment reaches it.
|
|
// Without this, "nothing reached the proxy" could pass for nothing.
|
|
await run("curl", ["-s", "-o", "/dev/null", "--max-time", "5", `http://127.0.0.1:${port}/`], env);
|
|
expect(proxied).toEqual([`GET http://127.0.0.1:${port}/`]);
|
|
proxied.length = 0;
|
|
|
|
const { code, stderr } = await runBootScript({ ...env, BOOT_PORT: String(port) });
|
|
|
|
expect(proxied).toEqual([]);
|
|
expect(code, stderr).toBe(0);
|
|
} finally {
|
|
await new Promise((resolve) => proxy.close(resolve));
|
|
}
|
|
}, 20000);
|
|
});
|
|
|
|
// The boot check only calls /health, so it proves the existence of the modules
|
|
// loaded at startup, nothing more. A require inside a route handler would load
|
|
// only when that route runs and escape it. Heuristic stand-in for "loaded at
|
|
// startup": every require starts at column 0 (a top-level statement), and there
|
|
// is no dynamic import. Comment lines are skipped, so an explanation quoting
|
|
// require( cannot break the pin.
|
|
//
|
|
// Known holes, left open (#35). A function written whole on a column-0 line
|
|
// passes: `const lazy = () => require("x");`, or the same as a one-line
|
|
// `function lazy() { return require("x"); }`. So does a require( wrapped onto a
|
|
// column-0 line inside a function. Neither fits the style of this repository,
|
|
// and the second has the very shape of the top-level `} = require("node:fs");`
|
|
// that closes index.js's multi-line import: telling them apart takes a parser,
|
|
// not a line regex.
|
|
describe("runtime modules are all loaded at startup", () => {
|
|
const copyLine = dockerfile.match(/^\s*COPY\s+(.+)$/m);
|
|
const sources = copyLine ? copyLine[1].trim().split(/\s+/).slice(0, -1) : [];
|
|
const runtimeJs = sources.filter((f) => f.endsWith(".js"));
|
|
|
|
test("the COPY line lists runtime modules", () => {
|
|
expect(runtimeJs.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test.each(runtimeJs)("%s requires only at module level", (file) => {
|
|
const lines = fs.readFileSync(path.join(ROOT, file), "utf-8").split("\n");
|
|
const offenders = [];
|
|
lines.forEach((line, i) => {
|
|
if (/^\s*(\/\/|\/?\*)/.test(line)) return;
|
|
if (/\brequire\(/.test(line) && /^\s/.test(line)) offenders.push(`${file}:${i + 1}: ${line.trim()}`);
|
|
if (/\bimport\(/.test(line)) offenders.push(`${file}:${i + 1}: ${line.trim()}`);
|
|
});
|
|
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
});
|