337 lines
16 KiB
TypeScript
337 lines
16 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { test } from "bun:test";
|
|
|
|
import { createCloudApiServer } from "./server.ts";
|
|
import { caseRunInternalFetch } from "./server-caserun-http.ts";
|
|
import { caseValidationPlanFromDefinition, hwpodCommandForValidationStep } from "../../tools/src/hwlab-caserun-validation.ts";
|
|
|
|
test("CaseRun internal requests reuse the existing bootstrap admin API key only for the internal origin", async () => {
|
|
const calls: Array<{ url: string; authorization: string | null }> = [];
|
|
const fetchImpl = async (input: URL | RequestInfo, init: RequestInit = {}) => {
|
|
const headers = new Headers(init.headers);
|
|
calls.push({ url: input instanceof Request ? input.url : String(input), authorization: headers.get("authorization") });
|
|
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
|
|
};
|
|
const internalFetch = caseRunInternalFetch(fetchImpl as typeof fetch, "http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667", { HWLAB_BOOTSTRAP_ADMIN_API_KEY: "hwl_live_test" });
|
|
|
|
await internalFetch("http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667/v1/hwpod-node-ops", { method: "POST" });
|
|
await internalFetch("https://lab-dev.hwpod.com/v1/hwpod-node-ops", { method: "POST" });
|
|
await internalFetch(new Request("http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667/v1/hwpod-node-ops", { headers: { authorization: "Bearer caller-token" } }), { method: "POST" });
|
|
|
|
assert.deepEqual(calls, [
|
|
{ url: "http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667/v1/hwpod-node-ops", authorization: "Bearer hwl_live_test" },
|
|
{ url: "https://lab-dev.hwpod.com/v1/hwpod-node-ops", authorization: null },
|
|
{ url: "http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667/v1/hwpod-node-ops", authorization: "Bearer caller-token" }
|
|
]);
|
|
});
|
|
|
|
test("cloud-api exposes web CaseRun cases, run status, events and aggregate", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-caserun-api-"));
|
|
const stateRoot = path.join(root, "state");
|
|
const caseDir = path.join(root, "cases", "d601-f103-v2-compile");
|
|
await mkdir(caseDir, { recursive: true });
|
|
await writeFile(path.join(caseDir, "case.json"), JSON.stringify({
|
|
contractVersion: "hwpod-case-v1",
|
|
caseId: "d601-f103-v2-compile",
|
|
title: "D601 compile",
|
|
mode: "compile-only",
|
|
hwpodSpec: "hwpod-spec.yaml",
|
|
subject: { repoLocalPath: "F:\\Work\\HWLAB-CASE-F103", commitId: "abc", subdir: "projects/01_baseline" }
|
|
}), "utf8");
|
|
await writeFile(path.join(caseDir, "hwpod-spec.yaml"), sampleSpecYaml(), "utf8");
|
|
const seen: any[] = [];
|
|
const accessController = {
|
|
async ensureBootstrap() {},
|
|
async requireNavAccess() { return true; }
|
|
};
|
|
const server = createCloudApiServer({
|
|
accessController,
|
|
env: { PATH: process.env.PATH, HWLAB_CASERUN_CASE_REPO: root, HWLAB_CASERUN_STATE_DIR: stateRoot, HWLAB_ENVIRONMENT: "v03", HWLAB_CLOUD_API_PORT: "6667" },
|
|
caseRunExecutor: async (context: any, emit: any) => {
|
|
seen.push(context.parsed);
|
|
await emit("prepared", { runDir: context.parsed.runDir });
|
|
await emit("build-completed", { jobId: "job-test" });
|
|
return {
|
|
summary: { status: "recorded", jobId: "job-test", artifactCount: 2, artifacts: [{ path: "atk_f103.hex" }, { path: "atk_f103.axf" }] },
|
|
evidence: { status: "recorded", keilJob: { jobId: "job-test", status: "completed" }, artifacts: [{ path: "atk_f103.hex" }, { path: "atk_f103.axf" }] },
|
|
run: { runId: context.parsed.runId, caseId: context.parsed.caseId }
|
|
};
|
|
}
|
|
});
|
|
await listen(server);
|
|
try {
|
|
const indexResponse = await fetch(`${serverUrl(server)}/v1`);
|
|
const indexPayload = await indexResponse.json();
|
|
assert.equal(indexPayload.caserun.route, "/v1/caserun");
|
|
|
|
const casesResponse = await fetch(`${serverUrl(server)}/v1/caserun/cases`);
|
|
const casesPayload = await casesResponse.json();
|
|
assert.equal(casesResponse.status, 200);
|
|
assert.equal(casesPayload.count, 1);
|
|
assert.equal(casesPayload.cases[0].caseId, "d601-f103-v2-compile");
|
|
|
|
const startResponse = await fetch(`${serverUrl(server)}/v1/caserun/runs`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ caseId: "d601-f103-v2-compile" })
|
|
});
|
|
const startPayload = await startResponse.json();
|
|
assert.equal(startResponse.status, 202);
|
|
assert.ok(["queued", "running"].includes(startPayload.status));
|
|
assert.match(startPayload.runId, /^web-d601-f103-v2-compile-/u);
|
|
assert.equal(startPayload.statusUrl, `/v1/caserun/runs/${startPayload.runId}`);
|
|
|
|
const runPayload = await waitForRun(server, startPayload.runId, "completed");
|
|
assert.equal(runPayload.terminal, true);
|
|
assert.equal(runPayload.facts.jobId, "job-test");
|
|
assert.equal(runPayload.facts.artifactCount, 2);
|
|
assert.equal(runPayload.references.events.count >= 3, true);
|
|
assert.match(runPayload.references.aggregate.sha256, /^[a-f0-9]{64}$/u);
|
|
assert.equal(runPayload.sourceAuthority.authority, "caserun-archived-facts");
|
|
assert.equal(seen[0].noCaseRepoRecord, true);
|
|
assert.equal(seen[0].caseRepo, root);
|
|
assert.equal(seen[0].apiUrl, "http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667");
|
|
|
|
const eventsResponse = await fetch(`${serverUrl(server)}/v1/caserun/runs/${startPayload.runId}/events`);
|
|
const eventsPayload = await eventsResponse.json();
|
|
assert.equal(eventsResponse.status, 200);
|
|
assert.equal(eventsPayload.runId, runPayload.runId);
|
|
assert.equal(eventsPayload.caseId, runPayload.caseId);
|
|
assert.equal(eventsPayload.sourceAuthority.authority, runPayload.sourceAuthority.authority);
|
|
assert.ok(eventsPayload.events.some((event: any) => event.stage === "build-completed"));
|
|
|
|
const aggregateResponse = await fetch(`${serverUrl(server)}/v1/caserun/runs/${startPayload.runId}/aggregate`);
|
|
const aggregatePayload = await aggregateResponse.json();
|
|
assert.equal(aggregateResponse.status, 200);
|
|
assert.equal(aggregatePayload.summary.jobId, "job-test");
|
|
assert.match(aggregatePayload.sha256, /^[a-f0-9]{64}$/u);
|
|
assert.equal(aggregatePayload.sourceAuthority.authority, runPayload.sourceAuthority.authority);
|
|
} finally {
|
|
await close(server);
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("builtin CaseRun exposes and preflights the D601 download and UART case", async () => {
|
|
const stateRoot = await mkdtemp(path.join(os.tmpdir(), "hwlab-caserun-download-uart-"));
|
|
const caseId = "d601-f103-v2-main-marker-download-uart";
|
|
const caseFile = path.join(process.cwd(), "cases", caseId, "case.json");
|
|
const definition = JSON.parse(await readFile(caseFile, "utf8"));
|
|
const validationPlan = caseValidationPlanFromDefinition(definition);
|
|
assert.equal(validationPlan.mode, "download-uart");
|
|
assert.equal(validationPlan.compileOnly, false);
|
|
assert.deepEqual(validationPlan.steps.map((step) => step.kind), ["build", "download", "uart"]);
|
|
assert.deepEqual(validationPlan.steps.map((step) => step.expectedOp), ["debug.build", "debug.download", "io.uart.read"]);
|
|
assert.deepEqual(validationPlan.steps.map((step) => hwpodCommandForValidationStep(step, {
|
|
executable: "bun",
|
|
cliPath: "tools/hwpod-cli.ts",
|
|
specPath: ".hwlab/hwpod-spec.yaml",
|
|
reason: "focused preflight"
|
|
}).slice(2, 4)), [["build", "--spec"], ["download", "--spec"], ["uart", "read"]]);
|
|
|
|
const accessController = {
|
|
async ensureBootstrap() {},
|
|
async requireNavAccess() { return true; }
|
|
};
|
|
const server = createCloudApiServer({
|
|
accessController,
|
|
env: { PATH: process.env.PATH, HWLAB_CASERUN_REPO_ROOT: process.cwd(), HWLAB_CASERUN_STATE_DIR: stateRoot, HWLAB_ENVIRONMENT: "v03", HWLAB_CLOUD_API_PORT: "6667" },
|
|
caseRunExecutor: async (context: any, emit: any) => {
|
|
await emit("prepared", { runDir: context.parsed.runDir });
|
|
throw Object.assign(new Error("No HWPOD node is available for focused preflight."), {
|
|
code: "hwpod_node_unavailable",
|
|
details: { blocker: { code: "hwpod_node_unavailable", summary: "No HWPOD node is available.", retryable: true } }
|
|
});
|
|
}
|
|
});
|
|
await listen(server);
|
|
try {
|
|
const casesResponse = await fetch(`${serverUrl(server)}/v1/caserun/cases`);
|
|
const casesPayload = await casesResponse.json();
|
|
const matches = casesPayload.cases.filter((item: any) => item.caseId === caseId);
|
|
assert.equal(matches.length, 1);
|
|
assert.deepEqual(matches[0], {
|
|
caseId,
|
|
title: "D601-F103-V2 main marker download and UART CaseRun",
|
|
mode: "download-uart",
|
|
hwpodSpec: "hwpod-spec.yaml",
|
|
hardwareRequired: true,
|
|
available: true,
|
|
subject: definition.subject,
|
|
expected: definition.expected,
|
|
runtime: definition.runtime
|
|
});
|
|
|
|
const startResponse = await fetch(`${serverUrl(server)}/v1/caserun/runs`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ caseId })
|
|
});
|
|
const started = await startResponse.json();
|
|
assert.equal(startResponse.status, 202);
|
|
assert.match(started.runId, /^web-d601-f103-v2-main-marker-download-uart-/u);
|
|
const failed = await waitForRun(server, started.runId, "failed");
|
|
assert.equal(failed.caseId, caseId);
|
|
assert.equal(failed.terminal, true);
|
|
assert.equal(failed.blocker.code, "hwpod_node_unavailable");
|
|
} finally {
|
|
await close(server);
|
|
await rm(stateRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("cloud-api completes a hardware-free software smoke CaseRun", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-caserun-software-smoke-"));
|
|
const stateRoot = path.join(root, "state");
|
|
const caseDir = path.join(root, "cases", "software-smoke");
|
|
await mkdir(caseDir, { recursive: true });
|
|
await writeFile(path.join(caseDir, "case.json"), JSON.stringify({
|
|
contractVersion: "hwlab-caserun-software-smoke-v1",
|
|
caseId: "software-smoke",
|
|
title: "software smoke",
|
|
mode: "software-smoke"
|
|
}), "utf8");
|
|
const accessController = {
|
|
async ensureBootstrap() {},
|
|
async requireNavAccess() { return true; }
|
|
};
|
|
const server = createCloudApiServer({
|
|
accessController,
|
|
env: { PATH: process.env.PATH, HWLAB_CASERUN_CASE_REPO: root, HWLAB_CASERUN_STATE_DIR: stateRoot, HWLAB_ENVIRONMENT: "v03", HWLAB_CLOUD_API_PORT: "6667" }
|
|
});
|
|
await listen(server);
|
|
try {
|
|
const casesResponse = await fetch(`${serverUrl(server)}/v1/caserun/cases`);
|
|
const casesPayload = await casesResponse.json();
|
|
assert.equal(casesPayload.cases[0].caseId, "software-smoke");
|
|
assert.equal(casesPayload.cases[0].hardwareRequired, false);
|
|
assert.equal(casesPayload.cases[0].available, true);
|
|
|
|
const startResponse = await fetch(`${serverUrl(server)}/v1/caserun/runs`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ caseId: "software-smoke" })
|
|
});
|
|
const started = await startResponse.json();
|
|
assert.equal(startResponse.status, 202);
|
|
const completed = await waitForRun(server, started.runId, "completed");
|
|
assert.equal(completed.terminal, true);
|
|
assert.equal(completed.facts.artifactCount, 1);
|
|
|
|
const eventsResponse = await fetch(`${serverUrl(server)}/v1/caserun/runs/${started.runId}/events`);
|
|
const eventsPayload = await eventsResponse.json();
|
|
assert.ok(eventsPayload.events.some((event: any) => event.stage === "software-smoke-completed"));
|
|
|
|
const aggregateResponse = await fetch(`${serverUrl(server)}/v1/caserun/runs/${started.runId}/aggregate`);
|
|
const aggregate = await aggregateResponse.json();
|
|
assert.equal(aggregate.status, "completed");
|
|
assert.equal(aggregate.evidence.hardwareRequired, false);
|
|
assert.match(aggregate.sha256, /^[a-f0-9]{64}$/u);
|
|
const artifact = JSON.parse(await readFile(path.join(stateRoot, "runs", started.runId, "software-smoke.json"), "utf8"));
|
|
assert.deepEqual(artifact.checks, { caseDefinitionLoaded: true, runDirectoryWritable: true, backgroundWorkerRunning: true });
|
|
} finally {
|
|
await close(server);
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("cloud-api projects queued, running, completed and failed records with one read-model shape", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-caserun-read-model-"));
|
|
const stateRoot = path.join(root, "state");
|
|
const accessController = {
|
|
async ensureBootstrap() {},
|
|
async requireNavAccess() { return true; }
|
|
};
|
|
const server = createCloudApiServer({
|
|
accessController,
|
|
env: { PATH: process.env.PATH, HWLAB_CASERUN_CASE_REPO: root, HWLAB_CASERUN_STATE_DIR: stateRoot }
|
|
});
|
|
await mkdir(path.join(stateRoot, "records"), { recursive: true });
|
|
const statuses = ["queued", "running", "completed", "failed"];
|
|
for (const status of statuses) {
|
|
const runId = `read-model-${status}`;
|
|
const record = {
|
|
runId,
|
|
caseId: "read-model-case",
|
|
status,
|
|
stage: status === "running" ? "build" : status,
|
|
runDir: path.join(stateRoot, "runs", runId),
|
|
createdAt: "2026-07-16T00:00:00.000Z",
|
|
updatedAt: "2026-07-16T00:00:01.000Z",
|
|
caseAuthority: { kind: "case-repo" },
|
|
events: [],
|
|
result: status === "completed" ? {
|
|
summary: { jobId: "job-read-model", artifactCount: 1, artifactManifestPath: "runs/read-model/artifact-manifest.json", artifactManifestSha256: "a".repeat(64) },
|
|
evidence: { status: "recorded", replay: { contractVersion: "hwpod-case-validation-replay-v1" }, validation: { archivePath: "validation-observations.json" }, hwpod: { operation: { rawArtifactRef: { path: "raw.json" } } } },
|
|
run: { runId, caseId: "read-model-case" }
|
|
} : null,
|
|
blocker: status === "failed" ? { code: "hwpod_build_failed", summary: "HWPOD build failed", layer: "hwpod" } : null,
|
|
error: status === "failed" ? { code: "caserun_failed", message: "CaseRun failed." } : null
|
|
};
|
|
await mkdir(record.runDir, { recursive: true });
|
|
await writeFile(path.join(stateRoot, "records", `${runId}.json`), `${JSON.stringify(record)}\n`, "utf8");
|
|
}
|
|
await listen(server);
|
|
try {
|
|
for (const status of statuses) {
|
|
const response = await fetch(`${serverUrl(server)}/v1/caserun/runs/read-model-${status}`);
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.deepEqual(Object.keys(payload).sort(), ["blocker", "caseId", "contractVersion", "createdAt", "error", "facts", "ok", "references", "runId", "sourceAuthority", "stage", "status", "terminal", "updatedAt"].sort());
|
|
assert.equal(payload.status, status);
|
|
assert.equal(payload.terminal, ["completed", "failed"].includes(status));
|
|
assert.equal(payload.references.status.href, `/v1/caserun/runs/read-model-${status}`);
|
|
}
|
|
const completed = await (await fetch(`${serverUrl(server)}/v1/caserun/runs/read-model-completed`)).json();
|
|
assert.equal(completed.references.manifest.sha256, "a".repeat(64));
|
|
assert.equal(completed.references.replay.ref, "validation-observations.json");
|
|
assert.equal(completed.references.hwpod.path, "raw.json");
|
|
const failed = await (await fetch(`${serverUrl(server)}/v1/caserun/runs/read-model-failed`)).json();
|
|
assert.equal(failed.blocker.code, "hwpod_build_failed");
|
|
} finally {
|
|
await close(server);
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
async function waitForRun(server: any, runId: string, status: string) {
|
|
const deadline = Date.now() + 3000;
|
|
let last: any = null;
|
|
while (Date.now() < deadline) {
|
|
const response = await fetch(`${serverUrl(server)}/v1/caserun/runs/${runId}`);
|
|
last = await response.json();
|
|
if (last.status === status) return last;
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
assert.fail(`run did not reach ${status}: ${JSON.stringify(last)}`);
|
|
}
|
|
|
|
async function listen(server: any) {
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
}
|
|
|
|
async function close(server: any) {
|
|
await new Promise((resolve, reject) => server.close((error: Error | undefined) => (error ? reject(error) : resolve(undefined))));
|
|
}
|
|
|
|
function serverUrl(server: any) {
|
|
return `http://127.0.0.1:${server.address().port}`;
|
|
}
|
|
|
|
function sampleSpecYaml() {
|
|
return `apiVersion: hwlab.dev/v0alpha1
|
|
kind: Hwpod
|
|
metadata:
|
|
uid: D601-F103-V2
|
|
name: d601-f103-v2
|
|
spec:
|
|
workspace:
|
|
path: "F:\\\\Work\\\\HWLAB-CASE-F103"
|
|
nodeBinding:
|
|
nodeId: node-d601-f103-v2
|
|
`;
|
|
}
|