All checks were successful
CI / verification (pull_request) Successful in 28s
test-curl.sh booted its servers on 3099 and 3098, hardcoded, and ci.test.js runs it whole on every `npm test`: two runs side by side got in each other's way, at least one of them failing every time. - TEST_CURL_PORT and TEST_CURL_NOINGEST_PORT choose the ports, 3099 and 3098 staying the defaults. Names of their own rather than PORT, which a dev shell often exports already. - BASE_URL and NOINGEST_URL are derived from the ports and no longer read from the environment: overridden alone, they sent every curl away from the servers the script had started. - A value outside 1-65535 and two identical ports are refused up front, with the variable named. - ci.test.js gives every case free ports of its own (freePorts() keeps its probes open together so they are distinct), and no case listens on 3099 or 3098 any more. New cases: two passes at once, the four refusals, source pins on the defaults and on the derived URLs, and a first-try connection check that pins the `wait` of the trap, which the fixed ports used to pin by accident. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
569 lines
25 KiB
JavaScript
569 lines
25 KiB
JavaScript
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const http = require("node:http");
|
|
const net = require("node:net");
|
|
const os = require("node:os");
|
|
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 literal 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), and a YAML anchor reused as a
|
|
// key, which only a match on the bare word catches. Unanchored and bare on
|
|
// purpose: the word 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`. An escaped key (`"continue\u002don-error": true`)
|
|
// still passes: the pin reads text, not YAML.
|
|
const CONTINUE_ON_ERROR = /continue-on-error/;
|
|
|
|
test("never lets a failing step pass", () => {
|
|
expect(ciCode).not.toMatch(CONTINUE_ON_ERROR);
|
|
});
|
|
|
|
// The detector itself, through the same comment stripping as `ciCode`.
|
|
// Anchored at the start of the line again, it would miss the flow mapping,
|
|
// the quoted key and the anchor; with a colon required right after the word,
|
|
// the quoted key and the anchor; with a closing quote allowed before that
|
|
// colon, the anchor alone.
|
|
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'],
|
|
["an anchor reused as a key", " - name: x\n env: { STEP_KEY: &coe continue-on-error }\n *coe : 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 started = Date.now();
|
|
const child = spawn(command, args, { cwd: ROOT, env: { ...process.env, ...env } });
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (d) => (stdout += d));
|
|
child.stderr.on("data", (d) => (stderr += d));
|
|
child.on("error", reject);
|
|
child.on("close", (code) => resolve({ code, stdout, stderr, ms: Date.now() - started }));
|
|
});
|
|
}
|
|
|
|
const runBootScript = (env) => run("sh", ["scripts/boot-from-copy.sh"], env);
|
|
|
|
// Ports nothing listens on, all distinct: the probes are open together, since
|
|
// a port closed first could be handed out again to the next probe. They close
|
|
// before the script starts: should something take a port in between, the
|
|
// script's own check refuses it and the test fails loudly.
|
|
async function freePorts(count) {
|
|
const probes = [];
|
|
for (let i = 0; i < count; i++) {
|
|
const probe = http.createServer();
|
|
await new Promise((resolve) => probe.listen(0, "127.0.0.1", resolve));
|
|
probes.push(probe);
|
|
}
|
|
const ports = probes.map((probe) => probe.address().port);
|
|
await Promise.all(probes.map((probe) => new Promise((resolve) => probe.close(resolve))));
|
|
return ports;
|
|
}
|
|
const freePort = async () => (await freePorts(1))[0];
|
|
|
|
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.
|
|
// no_proxy leaves the loopback out, as an inherited one may: curl reads
|
|
// it before NO_PROXY, so a script exporting NO_PROXY alone would still go
|
|
// through the proxy. NO_PROXY is dropped, so that nothing the test itself
|
|
// inherited decides the outcome.
|
|
const env = {
|
|
http_proxy: proxyUrl,
|
|
ALL_PROXY: proxyUrl,
|
|
NO_PROXY: undefined,
|
|
no_proxy: "example.invalid",
|
|
};
|
|
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 socket-level pass boots its own two servers. Every case hands it ports
|
|
// of its own through TEST_CURL_PORT and TEST_CURL_NOINGEST_PORT (#46): the
|
|
// defaults, 3099 and 3098, are never touched here, so a second `npm test` or a
|
|
// `bash test-curl.sh` running next to this one is not in the way. The defaults
|
|
// are pinned on the source below and exercised by the CI step that runs the
|
|
// script bare.
|
|
describe("test-curl.sh", () => {
|
|
const script = fs.readFileSync(path.join(ROOT, "test-curl.sh"), "utf-8");
|
|
const scriptCode = stripCommentLines(script);
|
|
const urlOf = (port) => `http://localhost:${port}`;
|
|
|
|
// One connection attempt: true if something accepts it.
|
|
function accepts(port) {
|
|
return new Promise((resolve) => {
|
|
const socket = net.connect(port, "127.0.0.1");
|
|
socket.once("connect", () => {
|
|
socket.destroy();
|
|
resolve(true);
|
|
});
|
|
socket.once("error", () => resolve(false));
|
|
});
|
|
}
|
|
|
|
// True once nothing accepts a connection on the port, false after 3 s of
|
|
// trying: a server killed by the script's trap takes a moment to let go.
|
|
async function released(port) {
|
|
for (let i = 0; i < 30; i++) {
|
|
if (!(await accepts(port))) return true;
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Throws on a missing port: the script reads an empty variable as unset, and
|
|
// the case would drift back to 3099 and 3098 with nothing to show for it.
|
|
function runSocketPass(ports, env = {}) {
|
|
const [main, noIngest] = ports;
|
|
if (!Number.isInteger(main) || !Number.isInteger(noIngest)) {
|
|
throw new Error("runSocketPass needs the two ports of the script");
|
|
}
|
|
return run("bash", ["test-curl.sh"], {
|
|
TEST_CURL_PORT: String(main),
|
|
TEST_CURL_NOINGEST_PORT: String(noIngest),
|
|
...env,
|
|
});
|
|
}
|
|
|
|
// Read on the source, not by running the script bare: that run would hold
|
|
// 3099 and 3098 during `npm test`, which is what #46 removed.
|
|
test("listens on 3099 and 3098 unless the two variables say otherwise", () => {
|
|
expect(scriptCode).toMatch(/^MAIN_PORT="\$\{TEST_CURL_PORT:-3099\}"$/m);
|
|
expect(scriptCode).toMatch(/^NOINGEST_PORT="\$\{TEST_CURL_NOINGEST_PORT:-3098\}"$/m);
|
|
// The two defaults and nothing else: no port written into a launch or a URL.
|
|
expect(scriptCode.match(/309[89]/g)).toEqual(["3099", "3098"]);
|
|
expect(scriptCode).toMatch(/^PORT="\$MAIN_PORT" \\$/m);
|
|
expect(scriptCode).toMatch(/^PORT="\$NOINGEST_PORT" \\$/m);
|
|
});
|
|
|
|
// An overridden URL with the listening port left alone sent every curl away
|
|
// from the servers the script had started.
|
|
test("derives its two URLs from the ports, never from the environment", () => {
|
|
expect(scriptCode.match(/^(BASE_URL|NOINGEST_URL)=.*$/gm)).toEqual([
|
|
'BASE_URL="http://localhost:$MAIN_PORT"',
|
|
'NOINGEST_URL="http://localhost:$NOINGEST_PORT"',
|
|
]);
|
|
});
|
|
|
|
// Same exposure as the boot check, 18 curl lines of it (#41). Before the
|
|
// export, a dead proxy made the script exit 7 with no output, and a live one
|
|
// would have answered every case in the API's place.
|
|
test("never sends a request through a proxy from the environment", async () => {
|
|
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}`;
|
|
// Same environment as the boot check's case above, for the same reasons.
|
|
// FORCE_COLOR on top: node would colour the numbers the cases compare.
|
|
// The two URLs point at a port nothing can hold: read from the
|
|
// environment, they would fail every case (vite alone already sets
|
|
// BASE_URL to "/" in the test process).
|
|
const env = {
|
|
http_proxy: proxyUrl,
|
|
ALL_PROXY: proxyUrl,
|
|
NO_PROXY: undefined,
|
|
no_proxy: "example.invalid",
|
|
FORCE_COLOR: "3",
|
|
BASE_URL: "http://127.0.0.1:1",
|
|
NOINGEST_URL: "http://127.0.0.1:1",
|
|
};
|
|
|
|
// The proxy is live: a bare curl with the same environment reaches it.
|
|
const [probed, ...ports] = await freePorts(3);
|
|
await run("curl", ["-s", "-o", "/dev/null", "--max-time", "5", `http://127.0.0.1:${probed}/`], env);
|
|
expect(proxied).toEqual([`GET http://127.0.0.1:${probed}/`]);
|
|
proxied.length = 0;
|
|
|
|
const { code, stdout, stderr } = await runSocketPass(ports, env);
|
|
// On the first try, with no grace: the trap ends on a `wait`, so both
|
|
// servers are gone by the time the script exits. On the fixed ports the
|
|
// next case found out by failing to listen; with ports of its own,
|
|
// nothing else would.
|
|
const stillOpen = [await accepts(ports[0]), await accepts(ports[1])];
|
|
|
|
expect(stillOpen).toEqual([false, false]);
|
|
expect(proxied).toEqual([]);
|
|
expect(code, stdout + stderr).toBe(0);
|
|
// Every case ran against the API: a count, so a script cut short fails.
|
|
expect(stdout).toMatch(/=== Results: 45 passed, 0 failed ===/);
|
|
} finally {
|
|
await new Promise((resolve) => proxy.close(resolve));
|
|
}
|
|
}, 30000);
|
|
|
|
// What #46 is for: two passes at the same instant, each on its own ports,
|
|
// as two `npm test` side by side. On the fixed ports at least one of the two
|
|
// failed every time (review of PR #45).
|
|
test("two passes at once both succeed, each on its own ports", async () => {
|
|
const ports = await freePorts(4);
|
|
try {
|
|
const results = await Promise.all([
|
|
runSocketPass(ports.slice(0, 2)),
|
|
runSocketPass(ports.slice(2)),
|
|
]);
|
|
|
|
for (const { code, stdout, stderr } of results) {
|
|
expect(code, stdout + stderr).toBe(0);
|
|
expect(stdout).toMatch(/=== Results: 45 passed, 0 failed ===/);
|
|
}
|
|
} finally {
|
|
for (const port of ports) expect(await released(port)).toBe(true);
|
|
}
|
|
}, 30000);
|
|
|
|
// Refused before anything starts, with the variable named. Port 0 would
|
|
// make node pick a port of its own, and two identical ports pass the "is it
|
|
// free" check: both would only end on a message about a server that died.
|
|
test.each([
|
|
["a value that is no number", () => ({ TEST_CURL_PORT: "abc" }), "TEST_CURL_PORT must be a port number (1-65535), got 'abc'"],
|
|
["port 0", () => ({ TEST_CURL_NOINGEST_PORT: "0" }), "TEST_CURL_NOINGEST_PORT must be a port number (1-65535), got '0'"],
|
|
["a port above 65535", () => ({ TEST_CURL_PORT: "65536" }), "TEST_CURL_PORT must be a port number (1-65535), got '65536'"],
|
|
[
|
|
"the same port twice",
|
|
([main]) => ({ TEST_CURL_NOINGEST_PORT: String(main) }),
|
|
"TEST_CURL_PORT and TEST_CURL_NOINGEST_PORT must differ",
|
|
],
|
|
])("refuses %s", async (_, override, message) => {
|
|
const ports = await freePorts(2);
|
|
const { code, stdout, stderr, ms } = await runSocketPass(ports, override(ports));
|
|
|
|
expect(stderr).toContain(`test-curl.sh: ${message}`);
|
|
expect(code).toBe(1);
|
|
expect(stdout).not.toMatch(/PASS|FAIL/);
|
|
expect(ms).toBeLessThan(3000);
|
|
for (const port of ports) expect(await released(port)).toBe(true);
|
|
}, 20000);
|
|
|
|
// A real neighbour on one of the two ports, as a stray server would be.
|
|
// Without the check up front the wait loop reaches the neighbour before the
|
|
// script's own server has died of EADDRINUSE, and the cases run against it
|
|
// (review of PR #45). Nothing must start, on either port.
|
|
test.each([
|
|
["main", 0],
|
|
["no-ingest", 1],
|
|
])("refuses to run when something else holds the port of the %s server", async (_, held) => {
|
|
const ports = await freePorts(2);
|
|
const asked = [];
|
|
const neighbour = http.createServer((req, res) => {
|
|
asked.push(req.url);
|
|
res.end("neighbour");
|
|
});
|
|
await new Promise((resolve, reject) => neighbour.once("error", reject).listen(ports[held], resolve));
|
|
try {
|
|
const { code, stdout, stderr } = await runSocketPass(ports);
|
|
|
|
expect(stderr).toContain(`test-curl.sh: ${urlOf(ports[held])} is already taken (curl exit 0)`);
|
|
expect(code).toBe(1);
|
|
expect(stdout).not.toMatch(/PASS|FAIL/);
|
|
// The neighbour saw the probe and nothing else: no case reached it.
|
|
expect(asked).toEqual(["/"]);
|
|
} finally {
|
|
await new Promise((resolve) => neighbour.close(resolve));
|
|
}
|
|
// The other port was free: no server of the script was left on it.
|
|
for (const port of ports) expect(await released(port)).toBe(true);
|
|
}, 20000);
|
|
|
|
// The rule is "anything but connection refused", not "an HTTP answer": an
|
|
// occupant that speaks no HTTP holds the port just as well.
|
|
test.each([
|
|
["closes at once", (socket) => socket.destroy(), /\(curl exit (52|56)\)/],
|
|
["accepts and stalls", () => {}, /\(curl exit 28\)/],
|
|
])("refuses to run when a listener that %s holds the main port", async (_, onConnection, exitCode) => {
|
|
const ports = await freePorts(2);
|
|
const sockets = new Set();
|
|
const occupant = net.createServer((socket) => {
|
|
sockets.add(socket);
|
|
socket.on("error", () => {});
|
|
onConnection(socket);
|
|
});
|
|
await new Promise((resolve, reject) => occupant.once("error", reject).listen(ports[0], resolve));
|
|
try {
|
|
const { code, stdout, stderr } = await runSocketPass(ports);
|
|
|
|
expect(stderr).toContain(`test-curl.sh: ${urlOf(ports[0])} is already taken`);
|
|
expect(stderr).toMatch(exitCode);
|
|
expect(code).toBe(1);
|
|
expect(stdout).not.toMatch(/PASS|FAIL/);
|
|
} finally {
|
|
for (const socket of sockets) socket.destroy();
|
|
await new Promise((resolve) => occupant.close(resolve));
|
|
}
|
|
expect(await released(ports[1])).toBe(true);
|
|
}, 20000);
|
|
|
|
// A server that stays alive and never listens: only the deadline can end the
|
|
// wait. The fake `node` sleeps for the port named; the script's trap kills it.
|
|
test("gives up after 5 s on a server that is alive and never answers", async () => {
|
|
const ports = await freePorts(2);
|
|
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-fake-node-"));
|
|
try {
|
|
fs.writeFileSync(
|
|
path.join(bin, "node"),
|
|
`#!/bin/sh\n[ "$PORT" = "${ports[0]}" ] && exec sleep 30\nexec "${process.execPath}" "$@"\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
const { code, stdout, stderr, ms } = await runSocketPass(ports, {
|
|
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
|
});
|
|
|
|
expect(stderr).toContain(`test-curl.sh: no server answered at ${urlOf(ports[0])} within 5 s`);
|
|
expect(code).toBe(1);
|
|
expect(stdout).not.toMatch(/PASS|FAIL/);
|
|
expect(ms).toBeGreaterThan(4000);
|
|
expect(ms).toBeLessThan(10000);
|
|
expect(await released(ports[1])).toBe(true);
|
|
} finally {
|
|
fs.rmSync(bin, { recursive: true, force: true });
|
|
}
|
|
}, 20000);
|
|
|
|
// A fake `node` first on the PATH stands in for a server that fails to start:
|
|
// it exits at once for the port named, and hands over to the real node
|
|
// otherwise. The script must name the URL instead of dying silently, for the
|
|
// main instance as for the one booted without HOSTS_INGEST_TOKEN.
|
|
test.each([
|
|
["main", 0],
|
|
["no-ingest", 1],
|
|
])("says so, and exits 1, when its %s server does not start", async (_, failing) => {
|
|
const ports = await freePorts(2);
|
|
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-fake-node-"));
|
|
try {
|
|
fs.writeFileSync(
|
|
path.join(bin, "node"),
|
|
`#!/bin/sh\n[ "$PORT" = "${ports[failing]}" ] && exit 1\nexec "${process.execPath}" "$@"\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
const { code, stdout, stderr, ms } = await runSocketPass(ports, {
|
|
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
|
});
|
|
|
|
expect(stderr).toContain(`test-curl.sh: the server for ${urlOf(ports[failing])} exited before answering`);
|
|
expect(code).toBe(1);
|
|
expect(stdout).not.toMatch(/PASS|FAIL/);
|
|
// At once, not at the end of the 5 s wait: a message that did not stop
|
|
// the run would be repeated until the deadline.
|
|
expect(ms).toBeLessThan(3000);
|
|
// The server that did start is not left behind on its port.
|
|
for (const port of ports) expect(await released(port)).toBe(true);
|
|
} finally {
|
|
fs.rmSync(bin, { recursive: true, force: true });
|
|
}
|
|
}, 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.
|
|
//
|
|
// The pin reads literal calls only, a space before the parenthesis included
|
|
// (`require (`, `import (`). Known holes, among others, left open (#35):
|
|
// - a function written whole on a column-0 line, such as
|
|
// `const lazy = () => require("x");` or a one-line
|
|
// `function lazy() { return require("x"); }`;
|
|
// - a require( wrapped onto a column-0 line inside a function, which 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;
|
|
// - any indirection, such as an alias of require.
|
|
// None of them fits the style of this repository.
|
|
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"));
|
|
|
|
// True when the line may load a module after startup: an indented require,
|
|
// or a dynamic import at any column. Comment lines never count.
|
|
function loadsLate(line) {
|
|
if (/^\s*(\/\/|\/?\*)/.test(line)) return false;
|
|
return (/\brequire\s*\(/.test(line) && /^\s/.test(line)) || /\bimport\s*\(/.test(line);
|
|
}
|
|
|
|
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 (loadsLate(line)) offenders.push(`${file}:${i + 1}: ${line.trim()}`);
|
|
});
|
|
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
|
|
// The detector itself. No runtime file breaks the pin today, so without these
|
|
// rows it could weaken, and the two holes closed in #35 reopen, with every
|
|
// test green.
|
|
test.each([
|
|
["an indented require", ' const x = require("./x");'],
|
|
["an indented require with a space before the parenthesis", ' const x = require ("./x");'],
|
|
["a dynamic import, even at column 0", 'const x = import("./x");'],
|
|
["a dynamic import with a space before the parenthesis", 'const x = import ("./x");'],
|
|
])("the load-late detector flags %s", (_, line) => {
|
|
expect(loadsLate(line)).toBe(true);
|
|
});
|
|
|
|
test.each([
|
|
["a top-level require", 'const x = require("./x");'],
|
|
["a comment line quoting require", ' // require("./x") here would load late'],
|
|
])("the load-late detector lets %s through", (_, line) => {
|
|
expect(loadsLate(line)).toBe(false);
|
|
});
|
|
});
|