Move e2e harness into mjs
This commit is contained in:
parent
935387c769
commit
2e4e3535e4
1 changed files with 438 additions and 162 deletions
266
scripts/e2e-runner.mjs
Normal file
266
scripts/e2e-runner.mjs
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
createWriteStream,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export function parseOptions(env = process.env) {
|
||||
const workers = positive(env.E2E_WORKERS ?? "1", "E2E_WORKERS");
|
||||
|
||||
const seed = Number(env.E2E_SEED ?? "1");
|
||||
|
||||
if (
|
||||
!/^\d+$/.test(env.E2E_SEED ?? "1") ||
|
||||
!Number.isInteger(seed) ||
|
||||
seed < 0 ||
|
||||
seed + workers - 1 > 0xffffffff
|
||||
) {
|
||||
throw new Error("E2E_SEED range must fit uint32");
|
||||
}
|
||||
|
||||
return {
|
||||
workers,
|
||||
seed,
|
||||
iterations: positive(env.E2E_ITERATIONS ?? "25", "E2E_ITERATIONS"),
|
||||
timeout:
|
||||
positive(env.E2E_TIMEOUT_SECONDS ?? "900", "E2E_TIMEOUT_SECONDS") *
|
||||
1000,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCommand(
|
||||
command,
|
||||
args,
|
||||
{ cwd, logPath, timeout, active = new Set(), env = process.env },
|
||||
) {
|
||||
const log = createWriteStream(logPath, { flags: "w" });
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
|
||||
let logError;
|
||||
log.on("error", (error) => {
|
||||
logError = error;
|
||||
killOwned(child, "SIGTERM");
|
||||
});
|
||||
|
||||
active.add(child);
|
||||
child.stdout.pipe(log, { end: false });
|
||||
child.stderr.pipe(log, { end: false });
|
||||
let timedOut = false;
|
||||
let hardKill;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
killOwned(child, "SIGTERM");
|
||||
hardKill = setTimeout(() => killOwned(child, "SIGKILL"), 5_000);
|
||||
}, timeout);
|
||||
|
||||
let spawnError;
|
||||
child.once("error", (error) => {
|
||||
spawnError = error;
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await new Promise((resolveResult) =>
|
||||
child.once("close", (code, signal) =>
|
||||
resolveResult({ code, signal }),
|
||||
),
|
||||
);
|
||||
|
||||
if (!logError) {
|
||||
await new Promise((resolveLog) => {
|
||||
log.once("error", resolveLog);
|
||||
log.end(resolveLog);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
command,
|
||||
args,
|
||||
...result,
|
||||
timedOut,
|
||||
error: spawnError?.message ?? logError?.message,
|
||||
success: result.code === 0 && !timedOut && !spawnError && !logError,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
clearTimeout(hardKill);
|
||||
active.delete(child);
|
||||
}
|
||||
}
|
||||
|
||||
function killOwned(child, signal) {
|
||||
if (!child.pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
child.kill(signal);
|
||||
} else {
|
||||
process.kill(-child.pid, signal); // the group created by this spawn
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== "ESRCH") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function positive(value, label) {
|
||||
const result = Number(value);
|
||||
if (
|
||||
!/^\d+$/.test(String(value)) ||
|
||||
!Number.isSafeInteger(result) ||
|
||||
result < 1
|
||||
)
|
||||
throw new Error(`${label} must be a positive integer`);
|
||||
return result;
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const options = parseOptions();
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const artifacts = process.env.E2E_ARTIFACTS
|
||||
? resolve(process.env.E2E_ARTIFACTS)
|
||||
: mkdtempSync(join(tmpdir(), "vault-link-e2e-"));
|
||||
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
console.log(`Artifacts: ${artifacts}`);
|
||||
console.log(
|
||||
`Seeds: ${options.seed}..${options.seed + options.workers - 1}; iterations: ${options.iterations}`,
|
||||
);
|
||||
|
||||
const active = new Set();
|
||||
let interrupted = false;
|
||||
const stop = () => {
|
||||
interrupted = true;
|
||||
for (const child of active) killOwned(child, "SIGTERM");
|
||||
};
|
||||
process.on("SIGINT", stop);
|
||||
process.on("SIGTERM", stop);
|
||||
process.on("exit", () => {
|
||||
for (const child of active) killOwned(child, "SIGKILL");
|
||||
});
|
||||
|
||||
const results = [];
|
||||
const run = async (name, command, args, cwd = root) => {
|
||||
if (interrupted) {
|
||||
throw new Error("Interrupted");
|
||||
}
|
||||
|
||||
const logPath = join(artifacts, `${name}.log`);
|
||||
const result = await runCommand(command, args, {
|
||||
cwd,
|
||||
logPath,
|
||||
timeout: options.timeout,
|
||||
active,
|
||||
env: { ...process.env, E2E_ARTIFACTS: artifacts },
|
||||
});
|
||||
results.push({ name, ...result });
|
||||
writeFileSync(
|
||||
join(artifacts, "results.json"),
|
||||
JSON.stringify({ options, results }, null, 2),
|
||||
);
|
||||
console.log(`${result.success ? "PASS" : "FAIL"} ${name}: ${logPath}`);
|
||||
return result.success;
|
||||
};
|
||||
const frontend = join(root, "frontend");
|
||||
|
||||
// Build only packages in scope and always rebuild the protocol peers.
|
||||
if (
|
||||
!(await run(
|
||||
"build-server",
|
||||
"cargo",
|
||||
["build", "--release"],
|
||||
join(root, "sync-server"),
|
||||
))
|
||||
) {
|
||||
throw new Error("Server build failed");
|
||||
}
|
||||
|
||||
if (
|
||||
!(await run(
|
||||
"build-clients",
|
||||
"npm",
|
||||
[
|
||||
"run",
|
||||
"build",
|
||||
"--workspace",
|
||||
"sync-client",
|
||||
"--workspace",
|
||||
"deterministic-tests",
|
||||
"--workspace",
|
||||
"test-client",
|
||||
],
|
||||
frontend,
|
||||
))
|
||||
) {
|
||||
throw new Error(
|
||||
"Client build failed (run npm ci in frontend if dependencies are missing)",
|
||||
);
|
||||
}
|
||||
|
||||
await run(
|
||||
"server-unit-tests",
|
||||
"cargo",
|
||||
["test"],
|
||||
join(root, "sync-server"),
|
||||
);
|
||||
await run(
|
||||
"client-unit-tests",
|
||||
"npm",
|
||||
["run", "test", "--workspace", "sync-client"],
|
||||
frontend,
|
||||
);
|
||||
await run("harness-self-tests", "npm", ["run", "test:harness"], frontend);
|
||||
await run("crash-and-protocol", "npm", ["run", "test:protocol"], frontend);
|
||||
|
||||
await run("deterministic", process.execPath, [
|
||||
join(frontend, "deterministic-tests/dist/cli.js"),
|
||||
"--concurrency",
|
||||
String(options.workers),
|
||||
]);
|
||||
|
||||
// Execute all suites even if one reports a regression; failure is never
|
||||
// inferred from log text and successful workers cannot mask failed ones.
|
||||
await Promise.all(
|
||||
Array.from({ length: options.workers }, (_, i) =>
|
||||
run(`fuzz-${options.seed + i}`, process.execPath, [
|
||||
join(frontend, "test-client/dist/cli.js"),
|
||||
"--seed",
|
||||
String(options.seed + i),
|
||||
"--iterations",
|
||||
String(options.iterations),
|
||||
"--artifacts",
|
||||
artifacts,
|
||||
]),
|
||||
),
|
||||
);
|
||||
if (interrupted || results.some((result) => !result.success)) {
|
||||
throw new Error(
|
||||
`Tests failed; inspect ${join(artifacts, "results.json")}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("All harness suites passed");
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue