Files
pikasTech-HWLAB/scripts/harnessrl-native-l1-smoke.mjs
2026-07-17 08:37:48 +02:00

460 lines
20 KiB
JavaScript

#!/usr/bin/env node
import { spawn } from "node:child_process";
import { createWriteStream } from "node:fs";
import { mkdir, readFile, rm } from "node:fs/promises";
import net from "node:net";
import path from "node:path";
const root = process.cwd();
const temporalAddress = requiredEnv("HARNESSRL_TEMPORAL_ADDRESS");
const temporalNamespace = process.env.HARNESSRL_TEMPORAL_NAMESPACE || "unidesk";
const startedAt = Date.now();
const tag = `${new Date().toISOString().replace(/[-:.]/gu, "").slice(0, 15)}-${process.pid}`;
const experimentRoot = path.join(process.env.TMPDIR || "/tmp", `hwlab-r45-caserun-${tag}`);
const logDir = path.join(experimentRoot, "logs");
const stateDir = path.join(experimentRoot, "state");
const databaseName = `hwlab_r45_${process.pid}_${Date.now() % 1000000}`;
const databaseUrl = `postgresql://root@/${databaseName}?host=/var/run/postgresql`;
const taskQueue = `hwlab-r45-${tag}`;
const defaultHttpTimeoutMs = 5000;
const defaultProcessTimeoutMs = 30000;
const processTerminationGraceMs = 2000;
const pollTimeoutMs = 30000;
const children = new Map();
const runIds = {};
let databaseCreated = false;
let result;
let failure;
await mkdir(logDir, { recursive: true });
await mkdir(stateDir, { recursive: true });
try {
await run("postgres-probe", "psql", ["-d", "postgres", "-Atqc", "select current_user"]);
await run("database-create", "createdb", [databaseName]);
databaseCreated = true;
const apiPort = await availablePort();
const workerPort = await availablePort();
const nativeFixturePort = await availablePort();
const vitePort = await availablePort();
const reloadPort = await availablePort();
const baseUrl = `http://127.0.0.1:${apiPort}`;
const nativeFixtureUrl = `http://127.0.0.1:${nativeFixturePort}`;
const viteUrl = `http://127.0.0.1:${vitePort}`;
const runtimeEnv = {
...process.env,
HARNESSRL_DATABASE_URL: databaseUrl,
HARNESSRL_TEMPORAL_ADDRESS: temporalAddress,
HARNESSRL_TEMPORAL_NAMESPACE: temporalNamespace,
HARNESSRL_TEMPORAL_TASK_QUEUE: taskQueue,
HARNESSRL_STATE_DIR: stateDir,
HWLAB_CASERUN_REPO_ROOT: root,
HWLAB_CASERUN_CASE_REPO: root,
HARNESSRL_API_HOST: "127.0.0.1",
HARNESSRL_API_PORT: String(apiPort),
HARNESSRL_WORKER_HEALTH_HOST: "127.0.0.1",
HARNESSRL_WORKER_HEALTH_PORT: String(workerPort)
};
stage("infra-ready", { databaseName, temporalAddress, temporalNamespace, taskQueue });
await startApi(runtimeEnv, baseUrl);
runIds.apiRestart = `r45-api-restart-${tag}`;
await submitHttp(baseUrl, runIds.apiRestart);
await stopChild("api");
await startApi(runtimeEnv, baseUrl);
await startWorker(runtimeEnv, workerPort);
const apiRestart = await pollHttp(baseUrl, runIds.apiRestart, "completed");
stage("api-restart-recovery", identity(apiRestart));
await stopChild("worker");
runIds.workerRestart = `r45-worker-restart-${tag}`;
await submitHttp(baseUrl, runIds.workerRestart);
runIds.cancel = `r45-cancel-${tag}`;
await submitHttp(baseUrl, runIds.cancel);
await requestJson("cancel-run", `${baseUrl}/v1/caserun/runs/${encodeURIComponent(runIds.cancel)}/cancel`, { method: "POST" }, 202);
await startWorker(runtimeEnv, workerPort);
const workerRestart = await pollHttp(baseUrl, runIds.workerRestart, "completed");
stage("worker-restart-recovery", identity(workerRestart));
const canceled = await pollHttp(baseUrl, runIds.cancel, "canceled");
stage("cancel", identity(canceled));
runIds.local = `r45-local-${tag}`;
runIds.overApi = `r45-over-api-${tag}`;
await cli(runtimeEnv, ["case", "run", "start", "software-smoke", "--case-repo", root, "--run-id", runIds.local]);
await cli(runtimeEnv, ["case", "run", "start", "software-smoke", "--over-api", "--base-url", baseUrl, "--run-id", runIds.overApi]);
const localFinal = await pollCli(runtimeEnv, "local", runIds.local, "completed", baseUrl);
const apiFinal = await pollCli(runtimeEnv, "api", runIds.overApi, "completed", baseUrl);
const localEvents = await cli(runtimeEnv, ["case", "run", "events", runIds.local]);
const apiEvents = await cli(runtimeEnv, ["case", "run", "events", runIds.overApi, "--over-api", "--base-url", baseUrl]);
const localAggregate = await cli(runtimeEnv, ["case", "run", "aggregate", runIds.local]);
const apiAggregate = await cli(runtimeEnv, ["case", "run", "aggregate", runIds.overApi, "--over-api", "--base-url", baseUrl]);
requireCondition(runIds.local !== runIds.overApi, "local and over-api runId must be independent");
requireCondition(localFinal.workflowRunId !== apiFinal.workflowRunId, "local and over-api workflow execution must be independent");
requireCondition(localFinal.status === apiFinal.status, "local and over-api status differ");
requireCondition(localEvents.events.length === apiEvents.events.length, "local and over-api event counts differ");
const localArtifact = softwareSmokeArtifact(localAggregate);
const apiArtifact = softwareSmokeArtifact(apiAggregate);
requireCondition(validSha(localArtifact.sha256) && validSha(apiArtifact.sha256), "software-smoke artifact SHA is missing");
stage("transport-equivalence", {
localRunId: runIds.local,
overApiRunId: runIds.overApi,
status: localFinal.status,
eventCount: localEvents.events.length,
artifactCount: 1,
artifactShaPresent: true
});
const missingRunId = `r45-missing-${tag}`;
const localError = await cli(runtimeEnv, ["case", "run", "status", missingRunId], { allowFailure: true });
const apiError = await cli(runtimeEnv, ["case", "run", "status", missingRunId, "--over-api", "--base-url", baseUrl], { allowFailure: true });
requireCondition(localError.exitCode !== 0 && apiError.exitCode !== 0, "typed not-found must fail for both transports");
requireCondition(localError.error?.code === "caserun_run_not_found", `unexpected local error ${localError.error?.code}`);
requireCondition(apiError.error?.code === localError.error.code, `typed errors differ ${localError.error?.code}/${apiError.error?.code}`);
stage("typed-error-equivalence", { code: localError.error.code });
const webEnv = {
...process.env,
HWLAB_CASERUN_NATIVE_TEST: "1",
HWLAB_CASERUN_NATIVE_PORT: String(nativeFixturePort),
HWLAB_CASERUN_NATIVE_URL: nativeFixtureUrl,
HWLAB_CASERUN_RELOAD_SMOKE_PORT: String(reloadPort)
};
await startChild("native-fixture", ["bun", "scripts/caserun-native-supervisor.ts"], {
cwd: path.join(root, "web/hwlab-cloud-web"), env: webEnv
});
await waitForHttp("wait-native-fixture", `${nativeFixtureUrl}/health`);
await run("native-fixture-smoke", "bun", ["scripts/caserun-native-smoke.ts"], {
cwd: path.join(root, "web/hwlab-cloud-web"), env: webEnv
});
await startChild("vite", ["bunx", "vite", "--host", "127.0.0.1", "--port", String(vitePort), "--strictPort"], {
cwd: path.join(root, "web/hwlab-cloud-web"), env: webEnv
});
const viteHtml = await waitForHttp("wait-vite-workbench", `${viteUrl}/workbench`, { returnText: true });
requireCondition(viteHtml.includes("/@vite/client"), "Vite HMR client was not injected");
const proxiedCases = await requestJson("vite-caserun-cases", `${viteUrl}/v1/caserun/cases`);
requireCondition(proxiedCases.mode === "native-test" && Array.isArray(proxiedCases.cases), "Vite native CaseRun proxy failed");
await run("native-reload-smoke", "bun", ["scripts/caserun-native-reload-smoke.ts"], {
cwd: path.join(root, "web/hwlab-cloud-web"), env: webEnv
});
stage("web-native-http-hmr", { nativeFixtureUrl, viteUrl, caseCount: proxiedCases.cases.length, hmrClient: true });
const databaseCounts = await queryDatabase(databaseUrl, "select (select count(*) from harnessrl_runs)::int as runs,(select count(*) from harnessrl_run_events)::int as events");
result = {
ok: true,
contractVersion: "hwlab-caserun-r45-native-smoke-v1",
temporal: { address: temporalAddress, namespace: temporalNamespace, taskQueue },
runs: runIds,
registry: databaseCounts,
web: { nativeFixtureUrl, viteUrl, hmrClient: true },
nativeElapsedSeconds: Math.ceil((Date.now() - startedAt) / 1000)
};
} catch (error) {
const logs = await diagnosticLogs();
failure = {
ok: false,
stage: "harnessrl-native-l1-smoke",
error: error instanceof Error ? { name: error.name, message: error.message } : { message: String(error) },
logs
};
process.exitCode = 1;
} finally {
const cleanup = await cleanupResources();
if (result) console.log(JSON.stringify({ ...result, cleanup }));
else if (failure) console.error(JSON.stringify({ ...failure, cleanup }));
}
function requiredEnv(name) {
const value = String(process.env[name] || "").trim();
if (!value) throw new Error(`${name} is required; resolve the current Temporal frontend Service address before running the smoke`);
return value;
}
function requireCondition(condition, message) {
if (!condition) throw new Error(message);
}
function validSha(value) {
return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
}
function identity(record) {
return { runId: record.runId, workflowId: record.workflowId, workflowRunId: record.workflowRunId, status: record.status };
}
function stage(name, details = {}) {
console.error(JSON.stringify({ event: "r45-stage", name, at: new Date().toISOString(), ...details }));
}
async function availablePort() {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
server.close((error) => error ? reject(error) : resolve(address.port));
});
});
}
async function startApi(env, baseUrl) {
await startChild("api", ["bun", "cmd/hwlab-harnessrl-api/main.ts"], { cwd: root, env });
await waitForHttp("wait-api-ready", `${baseUrl}/readyz`);
stage("api-started", { pid: children.get("api").pid });
}
async function startWorker(env, port) {
await startChild("worker", ["bun", "cmd/hwlab-harnessrl-worker/main.ts"], { cwd: root, env });
await waitForHttp("wait-worker-ready", `http://127.0.0.1:${port}/readyz`, { timeoutMs: 30000 });
stage("worker-started", { pid: children.get("worker").pid });
}
async function startChild(name, argv, options) {
requireCondition(!children.has(name), `${name} is already running`);
const logPath = path.join(logDir, `${name}.log`);
const log = createWriteStream(logPath, { flags: "a" });
const child = spawn(argv[0], argv.slice(1), { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"] });
child.stdout.pipe(log);
child.stderr.pipe(log);
child.once("exit", () => log.end());
children.set(name, child);
}
async function stopChild(name) {
const child = children.get(name);
if (!child) return;
children.delete(name);
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGTERM");
const exit = await Promise.race([
new Promise((resolve) => child.once("exit", (code, signal) => resolve({ code, signal }))),
sleep(8000).then(() => null)
]);
if (!exit && child.exitCode === null) {
child.kill("SIGKILL");
await new Promise((resolve) => child.once("exit", resolve));
}
}
stage(`${name}-stopped`, { pid: child.pid, exitCode: child.exitCode, signal: child.signalCode });
}
async function run(label, command, args, options = {}) {
const timeoutMs = positiveTimeout(options.timeoutMs, defaultProcessTimeoutMs, "process timeout");
const completed = await new Promise((resolve, reject) => {
const child = spawn(command, args, { cwd: options.cwd || root, env: options.env || process.env, stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
let timedOut = false;
let terminationTimer;
const timeoutTimer = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
terminationTimer = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}, processTerminationGraceMs);
}, timeoutMs);
child.stdout.on("data", (chunk) => { stdout += chunk; });
child.stderr.on("data", (chunk) => { stderr += chunk; });
child.once("error", (error) => {
clearTimeout(timeoutTimer);
if (terminationTimer) clearTimeout(terminationTimer);
reject(error);
});
child.once("exit", (exitCode, signal) => {
clearTimeout(timeoutTimer);
if (terminationTimer) clearTimeout(terminationTimer);
resolve({ exitCode: exitCode ?? 1, signal, stdout, stderr, timedOut, timeoutMs });
});
});
if (completed.timedOut) throw new Error(`process operation timed out operation=${clip(label, 160)} timeoutMs=${completed.timeoutMs}`);
if (completed.exitCode !== 0 && !options.allowFailure) {
throw new Error(`${label} failed exitCode=${completed.exitCode}: ${clip(completed.stderr || completed.stdout)}`);
}
return completed;
}
async function cli(env, args, options = {}) {
const completed = await run(`hwlab-cli ${args.join(" ")}`, "bun", ["tools/hwlab-cli/bin/hwlab-cli.ts", ...args], {
env, allowFailure: options.allowFailure, timeoutMs: options.timeoutMs
});
const payload = parseJson(completed.stdout, "hwlab-cli output");
return { ...payload, exitCode: completed.exitCode };
}
async function submitHttp(baseUrl, runId) {
const response = await requestJson("submit-run", `${baseUrl}/v1/caserun/runs`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ caseId: "software-smoke", runId })
}, 202);
requireCondition(response.runId === runId, `submitted runId differs for ${runId}`);
return response;
}
async function pollHttp(baseUrl, runId, expected) {
const deadline = Date.now() + pollTimeoutMs;
let previousStatus;
for (let attempt = 1; Date.now() < deadline; attempt += 1) {
const record = await requestJson("poll-run", `${baseUrl}/v1/caserun/runs/${encodeURIComponent(runId)}`, undefined, 200, {
timeoutMs: Math.min(defaultHttpTimeoutMs, Math.max(1, deadline - Date.now()))
});
if (record.status !== previousStatus || record.status === expected) stage("poll", { transport: "api", runId, attempt, status: record.status });
previousStatus = record.status;
if (record.status === expected) return record;
if (record.status === "failed") throw new Error(`run ${runId} failed: ${JSON.stringify(record.error)}`);
await sleep(250);
}
throw new Error(`timeout waiting for ${runId}=${expected}`);
}
async function pollCli(env, mode, runId, expected, baseUrl) {
const overApi = mode === "api" ? ["--over-api", "--base-url", baseUrl] : [];
const deadline = Date.now() + pollTimeoutMs;
let previousStatus;
for (let attempt = 1; Date.now() < deadline; attempt += 1) {
const record = await cli(env, ["case", "run", "status", runId, ...overApi], {
timeoutMs: Math.min(defaultProcessTimeoutMs, Math.max(1, deadline - Date.now()))
});
if (record.status !== previousStatus || record.status === expected) stage("poll", { transport: mode, runId, attempt, status: record.status });
previousStatus = record.status;
if (record.status === expected) return record;
if (record.status === "failed") throw new Error(`run ${runId} failed: ${JSON.stringify(record.error)}`);
await sleep(250);
}
throw new Error(`timeout waiting for ${mode}/${runId}=${expected}`);
}
async function waitForHttp(operation, url, options = {}) {
const timeoutMs = positiveTimeout(options.timeoutMs, 10000, "HTTP wait timeout");
const deadline = Date.now() + timeoutMs;
let lastError;
while (Date.now() < deadline) {
try {
const requestTimeoutMs = Math.min(defaultHttpTimeoutMs, Math.max(1, deadline - Date.now()));
const { response, text } = await requestText(operation, url, undefined, { timeoutMs: requestTimeoutMs });
if (response.ok) return options.returnText ? text : response;
lastError = new Error(`HTTP status operation=${boundedOperation(operation)} url=${boundedUrl(url)} status=${response.status}`);
} catch (error) {
lastError = error;
}
await sleep(100);
}
throw new Error(`HTTP wait timed out operation=${boundedOperation(operation)} url=${boundedUrl(url)} timeoutMs=${timeoutMs} lastError=${clip(lastError instanceof Error ? lastError.message : String(lastError), 400)}`);
}
async function requestJson(operation, url, init, expectedStatus = 200, options = {}) {
const { response, text, timeoutMs } = await requestText(operation, url, init, options);
let payload;
try {
payload = JSON.parse(text);
} catch {
throw new Error(`HTTP invalid JSON operation=${boundedOperation(operation)} url=${boundedUrl(url)} timeoutMs=${timeoutMs} body=${clip(text)}`);
}
if (response.status !== expectedStatus) {
throw new Error(`HTTP status operation=${boundedOperation(operation)} url=${boundedUrl(url)} timeoutMs=${timeoutMs} status=${response.status} body=${clip(text)}`);
}
return payload;
}
async function requestText(operation, url, init, options = {}) {
const timeoutMs = positiveTimeout(options.timeoutMs, defaultHttpTimeoutMs, "HTTP request timeout");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { ...init, signal: controller.signal });
const text = await response.text();
return { response, text, timeoutMs };
} catch (error) {
if (controller.signal.aborted) {
throw new Error(`HTTP request timed out operation=${boundedOperation(operation)} url=${boundedUrl(url)} timeoutMs=${timeoutMs}`);
}
throw new Error(`HTTP request failed operation=${boundedOperation(operation)} url=${boundedUrl(url)} timeoutMs=${timeoutMs} error=${clip(error instanceof Error ? error.message : String(error), 300)}`);
} finally {
clearTimeout(timer);
}
}
async function queryDatabase(url, sql) {
const completed = await run("database-query", "psql", [url, "-At", "-F", "|", "-c", sql]);
const [runs, events] = completed.stdout.trim().split("|").map(Number);
return { runs, events };
}
async function diagnosticLogs() {
const logs = {};
for (const name of ["api", "worker", "native-fixture", "vite"]) {
const logPath = path.join(logDir, `${name}.log`);
const text = await readFile(logPath, "utf8").catch(() => "");
if (text) logs[name] = clip(text, 3000);
}
return logs;
}
async function cleanupResources() {
const childNames = [...children.keys()];
for (const name of childNames.reverse()) await stopChild(name);
if (databaseCreated) {
await run("database-drop", "dropdb", ["--if-exists", databaseName], { allowFailure: true });
databaseCreated = false;
}
const databaseCheck = await run("database-cleanup-check", "psql", ["-d", "postgres", "-Atqc", `select count(*) from pg_database where datname='${databaseName}'`], { allowFailure: true });
const remainingProcesses = [...children.values()].filter((child) => child.exitCode === null).length;
await rm(experimentRoot, { recursive: true, force: true });
const cleanup = {
processesStopped: remainingProcesses === 0,
databaseDropped: databaseCheck.stdout.trim() === "0",
experimentDirectoryRemoved: true
};
if (!Object.values(cleanup).every(Boolean)) process.exitCode = 1;
return cleanup;
}
function parseJson(text, label) {
try {
return JSON.parse(text);
} catch {
throw new Error(`invalid JSON from ${label}: ${clip(text)}`);
}
}
function softwareSmokeArtifact(aggregate) {
const artifacts = aggregate?.result?.summary?.artifacts;
requireCondition(aggregate?.result?.summary?.artifactCount === 1, "software-smoke aggregate artifactCount must be 1");
requireCondition(Array.isArray(artifacts) && artifacts.length === 1, "software-smoke aggregate artifact is missing");
return artifacts[0];
}
function clip(value, limit = 1200) {
const text = String(value || "").trim();
return text.length <= limit ? text : `${text.slice(0, limit)}…`;
}
function positiveTimeout(value, fallback, label) {
const timeoutMs = value === undefined ? fallback : Number(value);
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new Error(`${label} must be a positive integer`);
return timeoutMs;
}
function boundedOperation(value) {
return clip(String(value || "unknown-operation").replace(/[\r\n\t]/gu, " "), 120);
}
function boundedUrl(value) {
try {
const url = new URL(String(value));
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
return clip(url.toString(), 300);
} catch {
return clip(String(value || "invalid-url").split(/[?#]/u, 1)[0], 300);
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}