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. const ciCode = ci .split("\n") .filter((line) => !/^\s*#/.test(line)) .join("\n"); // 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:/); } }); test("never lets a failing step pass", () => { // `continue-on-error: true` turns a red step green without a trace in the // commit status. Anchored on the key position, so only a real key counts. expect(ciCode).not.toMatch(/^\s*(- )?continue-on-error\s*:/m); }); }); 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. 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([]); }); });