feat: 新增纯软件最小 CaseRun
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user