mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
200 lines
6.0 KiB
JavaScript
200 lines
6.0 KiB
JavaScript
#!/usr/bin/env node
|
|||
|
|
|
||
|
|
import { execFileSync, spawn } from "node:child_process";
|
||
|
|
import { createHash, randomUUID } from "node:crypto";
|
||
|
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||
|
|
import { tmpdir } from "node:os";
|
||
|
|
import { resolve } from "node:path";
|
||
|
|
import { fileURLToPath } from "node:url";
|
||
|
|
|
||
|
|
const MAX_FUZZ_RUNS = 10_000;
|
||
|
|
const MAX_FUZZ_SEED = 2_147_483_647;
|
||
|
|
|
||
|
|
export const FUZZ_USAGE = "usage: pnpm test:fuzz [--runs <1-10000>] [--seed <0-2147483647>]";
|
||
|
|
|
||
|
|
function parseInteger(name, value, { min, max }) {
|
||
|
|
if (typeof value !== "string" || !/^-?\d+$/.test(value)) {
|
||
|
|
throw new Error(`${name} must be an integer`);
|
||
|
|
}
|
||
|
|
const parsed = Number(value);
|
||
|
|
if (!Number.isSafeInteger(parsed)) throw new Error(`${name} must be an integer`);
|
||
|
|
if (parsed < min || parsed > max) {
|
||
|
|
throw new Error(`${name} must be between ${min} and ${max}`);
|
||
|
|
}
|
||
|
|
return parsed;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function parseFuzzArguments(argv) {
|
||
|
|
const result = { help: false, runs: undefined, seed: undefined };
|
||
|
|
for (let index = 0; index < argv.length; index += 1) {
|
||
|
|
const argument = argv[index];
|
||
|
|
if (argument === "--") continue;
|
||
|
|
if (argument === "--help" || argument === "-h") {
|
||
|
|
result.help = true;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const [name, inlineValue] = argument.split("=", 2);
|
||
|
|
if (name !== "--runs" && name !== "--seed") {
|
||
|
|
throw new Error(`unknown argument: ${argument}\n${FUZZ_USAGE}`);
|
||
|
|
}
|
||
|
|
const nextValue = inlineValue ?? argv[index + 1];
|
||
|
|
if (inlineValue === undefined) index += 1;
|
||
|
|
if (nextValue === undefined) throw new Error(`${name} requires a value`);
|
||
|
|
|
||
|
|
const key = name === "--runs" ? "runs" : "seed";
|
||
|
|
if (result[key] !== undefined) throw new Error(`${name} may only be provided once`);
|
||
|
|
result[key] = parseInteger(name, nextValue, {
|
||
|
|
min: key === "runs" ? 1 : 0,
|
||
|
|
max: key === "runs" ? MAX_FUZZ_RUNS : MAX_FUZZ_SEED,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function buildFuzzEnvironment(baseEnvironment, arguments_) {
|
||
|
|
const environment = {
|
||
|
|
...baseEnvironment,
|
||
|
|
FUZZ: "1",
|
||
|
|
VITEST_MAX_FORKS: "1",
|
||
|
|
};
|
||
|
|
if (arguments_.runs !== undefined) environment.FUZZ_RUNS = String(arguments_.runs);
|
||
|
|
if (arguments_.seed !== undefined) {
|
||
|
|
environment.FUZZ_SEED = String(arguments_.seed);
|
||
|
|
delete environment.FC_SEED;
|
||
|
|
}
|
||
|
|
return environment;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function fuzzLockPath(cwd) {
|
||
|
|
let repositoryIdentity = resolve(cwd);
|
||
|
|
try {
|
||
|
|
const gitCommonDirectory = execFileSync("git", ["rev-parse", "--git-common-dir"], {
|
||
|
|
cwd,
|
||
|
|
encoding: "utf8",
|
||
|
|
stdio: ["ignore", "pipe", "ignore"],
|
||
|
|
timeout: 5_000,
|
||
|
|
windowsHide: true,
|
||
|
|
}).trim();
|
||
|
|
if (gitCommonDirectory) repositoryIdentity = resolve(cwd, gitCommonDirectory);
|
||
|
|
} catch {
|
||
|
|
// Outside a Git repository, keep the resolved working directory identity.
|
||
|
|
}
|
||
|
|
const repoHash = createHash("sha256").update(repositoryIdentity).digest("hex");
|
||
|
|
return resolve(tmpdir(), `snapotter-fuzz-${repoHash}.lock`);
|
||
|
|
}
|
||
|
|
|
||
|
|
function describeLockOwner(lockPath) {
|
||
|
|
try {
|
||
|
|
const owner = JSON.parse(readFileSync(resolve(lockPath, "owner.json"), "utf8"));
|
||
|
|
return {
|
||
|
|
pid: Number.isInteger(owner.pid) ? owner.pid : "unknown",
|
||
|
|
startedAt: typeof owner.startedAt === "string" ? owner.startedAt : "unknown",
|
||
|
|
};
|
||
|
|
} catch {
|
||
|
|
return { pid: "unknown", startedAt: "unknown" };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export function acquireFuzzLock(lockPath, owner) {
|
||
|
|
try {
|
||
|
|
mkdirSync(lockPath);
|
||
|
|
} catch (error) {
|
||
|
|
if (error?.code !== "EEXIST") throw error;
|
||
|
|
const existing = describeLockOwner(lockPath);
|
||
|
|
throw new Error(
|
||
|
|
`another fuzz campaign is already running (pid=${existing.pid}, ` +
|
||
|
|
`startedAt=${existing.startedAt}, lock=${lockPath})`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
writeFileSync(resolve(lockPath, "owner.json"), `${JSON.stringify(owner, null, 2)}\n`, {
|
||
|
|
flag: "wx",
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
rmSync(lockPath, { force: true, recursive: true });
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
|
||
|
|
let released = false;
|
||
|
|
return {
|
||
|
|
release() {
|
||
|
|
if (released) return;
|
||
|
|
const currentOwner = JSON.parse(readFileSync(resolve(lockPath, "owner.json"), "utf8"));
|
||
|
|
if (currentOwner.token !== owner.token) {
|
||
|
|
throw new Error("refusing to release a fuzz lock owned by another process");
|
||
|
|
}
|
||
|
|
rmSync(lockPath, { recursive: true });
|
||
|
|
released = true;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function runVitest(cwd, environment) {
|
||
|
|
const command = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||
|
|
const args = [
|
||
|
|
"exec",
|
||
|
|
"vitest",
|
||
|
|
"run",
|
||
|
|
"tests/integration/generated/fuzz-settings.test.ts",
|
||
|
|
"--reporter=verbose",
|
||
|
|
];
|
||
|
|
|
||
|
|
return new Promise((fulfill, reject) => {
|
||
|
|
const child = spawn(command, args, { cwd, env: environment, stdio: "inherit" });
|
||
|
|
const forwardSignal = (signal) => child.kill(signal);
|
||
|
|
const interrupt = () => forwardSignal("SIGINT");
|
||
|
|
const terminate = () => forwardSignal("SIGTERM");
|
||
|
|
process.once("SIGINT", interrupt);
|
||
|
|
process.once("SIGTERM", terminate);
|
||
|
|
|
||
|
|
const cleanup = () => {
|
||
|
|
process.off("SIGINT", interrupt);
|
||
|
|
process.off("SIGTERM", terminate);
|
||
|
|
};
|
||
|
|
child.once("error", (error) => {
|
||
|
|
cleanup();
|
||
|
|
reject(error);
|
||
|
|
});
|
||
|
|
child.once("exit", (code) => {
|
||
|
|
cleanup();
|
||
|
|
fulfill(code ?? 1);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
const arguments_ = parseFuzzArguments(process.argv.slice(2));
|
||
|
|
if (arguments_.help) {
|
||
|
|
console.info(FUZZ_USAGE);
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
const cwd = process.cwd();
|
||
|
|
const lockPath = fuzzLockPath(cwd);
|
||
|
|
const lock = acquireFuzzLock(lockPath, {
|
||
|
|
pid: process.pid,
|
||
|
|
startedAt: new Date().toISOString(),
|
||
|
|
token: randomUUID(),
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
console.info(`[fuzz-runner] acquired exclusive lock ${lockPath}; VITEST_MAX_FORKS=1`);
|
||
|
|
return await runVitest(cwd, buildFuzzEnvironment(process.env, arguments_));
|
||
|
|
} finally {
|
||
|
|
lock.release();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||
|
|
main()
|
||
|
|
.then((exitCode) => {
|
||
|
|
process.exitCode = exitCode;
|
||
|
|
})
|
||
|
|
.catch((error) => {
|
||
|
|
console.error(error instanceof Error ? error.message : error);
|
||
|
|
process.exitCode = 1;
|
||
|
|
});
|
||
|
|
}
|