diff --git a/cases/software-smoke/case.json b/cases/software-smoke/case.json new file mode 100644 index 00000000..087caf01 --- /dev/null +++ b/cases/software-smoke/case.json @@ -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" + } +} diff --git a/internal/cloud/server-caserun-http.test.ts b/internal/cloud/server-caserun-http.test.ts index f294a38b..f892c2e2 100644 --- a/internal/cloud/server-caserun-http.test.ts +++ b/internal/cloud/server-caserun-http.test.ts @@ -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; diff --git a/internal/cloud/server-caserun-http.ts b/internal/cloud/server-caserun-http.ts index 91df5a50..99ad567a 100644 --- a/internal/cloud/server-caserun-http.ts +++ b/internal/cloud/server-caserun-http.ts @@ -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) => Promise) { +async function defaultCaseRunExecutor(context: any, emit: (stage: string, payload?: Record) => Promise, 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) => Promise, 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