feat: 新增纯软件最小 CaseRun

This commit is contained in:
root
2026-07-16 08:14:15 +02:00
parent d568f9d126
commit cb2fce6cef
3 changed files with 108 additions and 7 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"contractVersion": "hwlab-caserun-software-smoke-v1",
"caseId": "software-smoke",
"title": "HWLAB v0.3 纯软件最小 CaseRun",
"mode": "software-smoke",
"expected": {
"status": "completed",
"hardwareRequired": false,
"artifact": "software-smoke.json"
}
}
+57 -1
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
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";
@@ -84,6 +84,62 @@ test("cloud-api exposes web CaseRun cases, run status, events and aggregate", as
}
});
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.summary.mode, "software-smoke");
assert.equal(completed.summary.hardwareRequired, false);
assert.equal(completed.summary.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 });
}
});
async function waitForRun(server: any, runId: string, status: string) {
const deadline = Date.now() + 3000;
let last: any = null;
+40 -6
View File
@@ -8,6 +8,8 @@ import { buildCaseRun, collectCaseRun, prepareCaseRun } from "../../tools/src/hw
const CONTRACT_VERSION = "hwlab-caserun-web-v1";
const CASES_CONTRACT_VERSION = "hwlab-caserun-cases-v1";
const AGGREGATE_CONTRACT_VERSION = "hwlab-caserun-aggregate-v1";
const SOFTWARE_SMOKE_CONTRACT_VERSION = "hwlab-caserun-software-smoke-v1";
const SOFTWARE_SMOKE_MODE = "software-smoke";
const DEFAULT_BODY_LIMIT_BYTES = 64 * 1024;
const RUN_ID_PATTERN = /^[A-Za-z0-9_.:-]{3,180}$/u;
@@ -129,7 +131,8 @@ async function runBackgroundCaseRun(record: any, input: any) {
await saveRecord(input.stateRoot, record);
}
async function defaultCaseRunExecutor(context: any, emit: (stage: string, payload?: Record<string, unknown>) => Promise<void>) {
async function defaultCaseRunExecutor(context: any, emit: (stage: string, payload?: Record<string, unknown>) => Promise<void>, input: any = {}) {
if (input.caseDefinition?.mode === SOFTWARE_SMOKE_MODE) return runSoftwareSmokeCase(context, emit, input.caseDefinition);
const prepared = await prepareCaseRun(context, "web");
await emit("prepared", { runDir: prepared.runDir, specPath: prepared.specPath });
const run = prepared.run;
@@ -140,6 +143,34 @@ async function defaultCaseRunExecutor(context: any, emit: (stage: string, payloa
return { prepared, build, collected, evidence: build.evidence, summary: build.summary, run: collected.run ?? build.run ?? run };
}
async function runSoftwareSmokeCase(context: any, emit: (stage: string, payload?: Record<string, unknown>) => Promise<void>, caseDefinition: any) {
const runDir = path.resolve(context.parsed.runDir);
await mkdir(runDir, { recursive: true });
await emit("software-smoke-running", { mode: SOFTWARE_SMOKE_MODE, hardwareRequired: false });
const artifact = {
contractVersion: SOFTWARE_SMOKE_CONTRACT_VERSION,
caseId: context.parsed.caseId,
runId: context.parsed.runId,
mode: SOFTWARE_SMOKE_MODE,
status: "completed",
hardwareRequired: false,
checks: {
caseDefinitionLoaded: caseDefinition.available === true,
runDirectoryWritable: true,
backgroundWorkerRunning: true
}
};
const artifactPath = path.join(runDir, "software-smoke.json");
const artifactText = `${JSON.stringify(artifact, null, 2)}\n`;
await writeFile(artifactPath, artifactText, "utf8");
const artifacts = [{ path: "software-smoke.json", bytes: Buffer.byteLength(artifactText), sha256: createHash("sha256").update(artifactText).digest("hex") }];
const evidence = { status: "recorded", mode: SOFTWARE_SMOKE_MODE, hardwareRequired: false, checks: artifact.checks, artifacts };
const summary = { status: "completed", mode: SOFTWARE_SMOKE_MODE, hardwareRequired: false, artifactCount: artifacts.length, artifacts };
const run = { runId: context.parsed.runId, caseId: context.parsed.caseId, mode: SOFTWARE_SMOKE_MODE, hardwareRequired: false };
await emit("software-smoke-completed", { artifactCount: artifacts.length, artifactSha256: artifacts[0].sha256 });
return { summary, evidence, run };
}
function caseContextForRecord(record: any, input: any) {
const internalApiUrl = internalRuntimeApiUrlForCaseRun(record, input.env ?? {});
const parsed = {
@@ -212,15 +243,18 @@ async function loadCase(caseRepo: string, caseId: string) {
const caseDir = path.join(caseRepo, "cases", caseId);
const caseFile = path.join(caseDir, "case.json");
const definition = JSON.parse(await readFile(caseFile, "utf8"));
const hwpodSpec = String(definition.hwpodSpec ?? definition.specPath ?? "hwpod-spec.yaml").trim() || "hwpod-spec.yaml";
const specPath = path.resolve(caseDir, hwpodSpec);
const specInfo = await stat(specPath).catch(() => null);
const mode = String(definition.mode ?? "compile-only").trim() || "compile-only";
const hardwareRequired = mode !== SOFTWARE_SMOKE_MODE;
const hwpodSpec = hardwareRequired ? String(definition.hwpodSpec ?? definition.specPath ?? "hwpod-spec.yaml").trim() || "hwpod-spec.yaml" : null;
const specPath = hwpodSpec ? path.resolve(caseDir, hwpodSpec) : null;
const specInfo = specPath ? await stat(specPath).catch(() => null) : null;
return {
caseId: String(definition.caseId ?? caseId).trim() || caseId,
title: String(definition.title ?? caseId).trim() || caseId,
mode: String(definition.mode ?? "compile-only").trim() || "compile-only",
mode,
hwpodSpec,
available: Boolean(specInfo?.isFile()),
hardwareRequired,
available: hardwareRequired ? Boolean(specInfo?.isFile()) : true,
subject: definition.subject ?? null,
expected: definition.expected ?? null,
runtime: definition.runtime ?? null