153 lines
4.9 KiB
JavaScript
153 lines
4.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// Safe runner for the k6 load-test suite.
|
|
//
|
|
// SKIPs cleanly (exit 0) when the k6 binary is missing or the target URL is
|
|
// unreachable, so this script is safe to wire into CI lanes that may run on
|
|
// machines without k6 installed. Only fails (non-zero exit) when k6 itself
|
|
// reports a threshold or runtime failure.
|
|
|
|
import path from "node:path";
|
|
import url from "node:url";
|
|
import { spawnSync } from "node:child_process";
|
|
|
|
const DEFAULT_TARGET_URL = "http://localhost:3000";
|
|
const DEFAULT_SCENARIO_PATH = "tests/load/scenarios.js";
|
|
const REACHABILITY_TIMEOUT_SECONDS = 3;
|
|
|
|
/**
|
|
* Test whether the k6 binary is available on PATH.
|
|
* Returns the trimmed version string on success, null on failure.
|
|
*/
|
|
export function detectK6Binary({ spawn = spawnSync } = {}) {
|
|
const result = spawn("k6", ["version"], { encoding: "utf8" });
|
|
if (result.error || result.status !== 0) {
|
|
return null;
|
|
}
|
|
return (result.stdout || "").trim();
|
|
}
|
|
|
|
/**
|
|
* Test whether the configured target URL responds to an HTTP request.
|
|
* Uses curl with a short timeout so the runner does not hang when the
|
|
* target environment is offline. Returns true on reachable, false otherwise.
|
|
*/
|
|
export function isTargetReachable(targetUrl, { spawn = spawnSync, timeoutSeconds = REACHABILITY_TIMEOUT_SECONDS } = {}) {
|
|
const result = spawn(
|
|
"curl",
|
|
[
|
|
"-fsS",
|
|
"--max-time",
|
|
String(timeoutSeconds),
|
|
"-o",
|
|
"/dev/null",
|
|
"-w",
|
|
"%{http_code}",
|
|
targetUrl,
|
|
],
|
|
{ encoding: "utf8" },
|
|
);
|
|
if (result.error) {
|
|
return false;
|
|
}
|
|
// curl -fsS exits non-zero on HTTP >= 400, but for reachability we accept
|
|
// any successful TCP+HTTP roundtrip. Re-check by looking at the captured
|
|
// status code in stdout when curl exited non-zero.
|
|
if (result.status === 0) {
|
|
return true;
|
|
}
|
|
const code = (result.stdout || "").trim();
|
|
return /^[1-5]\d\d$/.test(code);
|
|
}
|
|
|
|
/**
|
|
* Parse argv into a configuration object.
|
|
* --target <url> override K6_TARGET_URL
|
|
* --scenario <path> override the default composite scenario script
|
|
* --skip-reachability skip the curl probe (useful when the target is
|
|
* only reachable from within a Docker network)
|
|
*/
|
|
export function parseArgs(argv) {
|
|
const config = {
|
|
targetUrl: null,
|
|
scenarioPath: null,
|
|
skipReachability: false,
|
|
};
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--target" || arg === "--target-url") {
|
|
config.targetUrl = argv[index + 1] || null;
|
|
index += 1;
|
|
} else if (arg === "--scenario") {
|
|
config.scenarioPath = argv[index + 1] || null;
|
|
index += 1;
|
|
} else if (arg === "--skip-reachability") {
|
|
config.skipReachability = true;
|
|
}
|
|
}
|
|
return config;
|
|
}
|
|
|
|
/**
|
|
* Resolve the final runtime configuration from argv + env, applying defaults.
|
|
*/
|
|
export function resolveConfig({ argv = [], env = process.env } = {}) {
|
|
const parsed = parseArgs(argv);
|
|
const targetUrl = parsed.targetUrl || env.K6_TARGET_URL || DEFAULT_TARGET_URL;
|
|
const scenarioPath = parsed.scenarioPath || DEFAULT_SCENARIO_PATH;
|
|
return {
|
|
targetUrl,
|
|
scenarioPath,
|
|
skipReachability: parsed.skipReachability,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Run the load-test suite. Returns an object describing the outcome so the
|
|
* CLI wrapper can map it to an exit code.
|
|
*/
|
|
export function runLoadTests({
|
|
argv = [],
|
|
env = process.env,
|
|
spawn = spawnSync,
|
|
stdout = (line) => process.stdout.write(`${line}\n`),
|
|
stderr = (line) => process.stderr.write(`${line}\n`),
|
|
} = {}) {
|
|
const config = resolveConfig({ argv, env });
|
|
|
|
const version = detectK6Binary({ spawn });
|
|
if (!version) {
|
|
stdout(
|
|
"SKIP k6 binary not installed; download from https://k6.io/docs/getting-started/installation/",
|
|
);
|
|
return { status: "skip", reason: "binary-missing", exitCode: 0 };
|
|
}
|
|
|
|
if (!config.skipReachability && !isTargetReachable(config.targetUrl, { spawn })) {
|
|
stdout(`SKIP target ${config.targetUrl} not reachable`);
|
|
return { status: "skip", reason: "target-unreachable", exitCode: 0 };
|
|
}
|
|
|
|
stdout(`PASS k6 detected (${version}); running ${config.scenarioPath} against ${config.targetUrl}`);
|
|
const result = spawn("k6", ["run", config.scenarioPath], {
|
|
stdio: "inherit",
|
|
env: { ...env, K6_TARGET_URL: config.targetUrl },
|
|
});
|
|
|
|
if (result.error) {
|
|
stderr(`FAIL k6 run failed to start: ${result.error.message}`);
|
|
return { status: "fail", reason: "spawn-error", exitCode: 1 };
|
|
}
|
|
if (typeof result.status === "number" && result.status !== 0) {
|
|
stderr(`FAIL k6 run exited with status ${result.status}`);
|
|
return { status: "fail", reason: "non-zero-exit", exitCode: result.status };
|
|
}
|
|
return { status: "pass", reason: "ok", exitCode: 0 };
|
|
}
|
|
|
|
const isCli = process.argv[1] && url.fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
|
|
if (isCli) {
|
|
const outcome = runLoadTests({ argv: process.argv.slice(2) });
|
|
process.exit(outcome.exitCode);
|
|
}
|