diff --git a/web/hwlab-cloud-web/package.json b/web/hwlab-cloud-web/package.json index 75fded24..0ca7915e 100644 --- a/web/hwlab-cloud-web/package.json +++ b/web/hwlab-cloud-web/package.json @@ -12,6 +12,7 @@ "caserun:native:stop": "bun run scripts/caserun-native-service.ts stop", "caserun:native:status": "bun run scripts/caserun-native-service.ts status", "caserun:native:logs": "bun run scripts/caserun-native-service.ts logs", + "caserun:native:smoke": "bun run scripts/caserun-native-smoke.ts", "check": "bun run deps --quiet && bun run scripts/tsc-check.ts && bun test scripts", "check:tsc": "bun run deps --quiet && bun run scripts/tsc-check.ts", "check:tsc-strict": "bun run deps --quiet && bun run scripts/tsc-check.ts --strict", diff --git a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts index 0872c743..62d6d4bb 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts +++ b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { listNativeCases, readNativeEvents, readNativeRun, resetNativeFixtures, startNativeRun } from "./caserun-native-fixtures"; +import { listNativeCases, readNativeAggregate, readNativeEvents, readNativeRun, resetNativeFixtures, startNativeRun } from "./caserun-native-fixtures"; test("native CaseRun fixtures expose six deterministic API-owned states", () => { resetNativeFixtures(); @@ -20,6 +20,12 @@ test("native CaseRun fixtures expose six deterministic API-owned states", () => assert.equal(completed?.terminal, true); assert.match(completed?.references.aggregate.sha256 ?? "", /^[a-f0-9]{64}$/); + const aggregate = readNativeAggregate(started!.runId); + assert.equal(aggregate?.mode, "native-test"); + assert.equal(aggregate?.runId, started!.runId); + assert.equal(aggregate?.status, "completed"); + assert.equal(aggregate?.sha256, completed?.references.aggregate.sha256); + const events = readNativeEvents(started!.runId); assert.deepEqual(events?.events.map((event) => event.status), ["queued", "running", "completed"]); assert.equal(events?.mode, "native-test"); diff --git a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts index 617708e6..817c3eb7 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts +++ b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts @@ -36,8 +36,42 @@ function timestamp(index: number): string { return new Date(BASE_TIME + index * 1000).toISOString(); } -function aggregateSha(runId: string, status: NativeStatus): string { - return createHash("sha256").update(`${MODE}:${runId}:${status}`).digest("hex"); +function aggregateResponse(run: NativeRunState) { + const step = run.steps[run.cursor] ?? run.steps.at(-1)!; + const summary = { + status: step.status, + jobId: `job-${run.runId}`, + artifactCount: step.status === "completed" ? 2 : 0 + }; + const evidence = { + status: step.status, + blocker: step.blocker ?? null, + artifacts: step.status === "completed" ? [{ name: "firmware.bin" }, { name: "caserun.log" }] : [] + }; + const archivedRun = { + runId: run.runId, + caseId: run.caseId, + status: step.status, + stage: step.stage, + terminal: step.terminal, + mode: MODE + }; + const source = { runId: run.runId, caseId: run.caseId, status: step.status, summary, evidence, run: archivedRun }; + return { + ok: true, + mode: MODE, + contractVersion: "caserun.aggregate.v1", + runId: run.runId, + caseId: run.caseId, + status: step.status, + stage: step.stage, + terminal: step.terminal, + sourceAuthority: { mode: MODE, sequence: "request-driven" }, + sha256: createHash("sha256").update(JSON.stringify(source)).digest("hex"), + summary, + evidence, + run: archivedRun + }; } function runResponse(run: NativeRunState) { @@ -62,7 +96,7 @@ function runResponse(run: NativeRunState) { references: { status: { href: `/v1/caserun/runs/${run.runId}` }, events: { href: `/v1/caserun/runs/${run.runId}/events`, count: run.cursor + 1 }, - aggregate: { href: `/v1/caserun/runs/${run.runId}/aggregate`, sha256: aggregateSha(run.runId, step.status) }, + aggregate: { href: `/v1/caserun/runs/${run.runId}/aggregate`, sha256: aggregateResponse(run).sha256 }, manifest: step.status === "completed" ? { href: `/v1/caserun/runs/${run.runId}/manifest`, count: 2 } : null, replay: null, trace: { traceId: `trace-${run.runId}` }, @@ -116,6 +150,11 @@ export function readNativeEvents(runId: string) { return { ok: true, mode: MODE, contractVersion: CONTRACT_VERSION, runId, caseId: run.caseId, status: current.status, stage: current.stage, terminal: current.terminal, sourceAuthority: current.sourceAuthority, events }; } +export function readNativeAggregate(runId: string) { + const run = runs.get(runId); + return run ? aggregateResponse(run) : null; +} + export function resetNativeFixtures(): void { runSequence = 0; runs.clear(); diff --git a/web/hwlab-cloud-web/scripts/caserun-native-server.ts b/web/hwlab-cloud-web/scripts/caserun-native-server.ts index 1f5b5e65..5ee57959 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-server.ts +++ b/web/hwlab-cloud-web/scripts/caserun-native-server.ts @@ -1,4 +1,4 @@ -import { listNativeCases, readNativeEvents, readNativeRun, startNativeRun } from "./caserun-native-fixtures"; +import { listNativeCases, readNativeAggregate, readNativeEvents, readNativeRun, startNativeRun } from "./caserun-native-fixtures"; const port = Number(process.env.HWLAB_CASERUN_NATIVE_PORT || 4316); const hostname = process.env.HWLAB_CASERUN_NATIVE_HOST || "127.0.0.1"; @@ -44,13 +44,19 @@ const server = Bun.serve({ } const eventsMatch = url.pathname.match(/^\/v1\/caserun\/runs\/([^/]+)\/events$/); if (request.method === "GET" && eventsMatch) { - const runId = decodeURIComponent(eventsMatch[1]); + const runId = decodeURIComponent(eventsMatch[1]!); const events = readNativeEvents(runId); return events ? json(events) : notFound("run", runId); } + const aggregateMatch = url.pathname.match(/^\/v1\/caserun\/runs\/([^/]+)\/aggregate$/); + if (request.method === "GET" && aggregateMatch) { + const runId = decodeURIComponent(aggregateMatch[1]!); + const aggregate = readNativeAggregate(runId); + return aggregate ? json(aggregate) : notFound("run", runId); + } const runMatch = url.pathname.match(/^\/v1\/caserun\/runs\/([^/]+)$/); if (request.method === "GET" && runMatch) { - const runId = decodeURIComponent(runMatch[1]); + const runId = decodeURIComponent(runMatch[1]!); const run = readNativeRun(runId); return run ? json(run) : notFound("run", runId); } diff --git a/web/hwlab-cloud-web/scripts/caserun-native-smoke.ts b/web/hwlab-cloud-web/scripts/caserun-native-smoke.ts new file mode 100644 index 00000000..922436df --- /dev/null +++ b/web/hwlab-cloud-web/scripts/caserun-native-smoke.ts @@ -0,0 +1,47 @@ +const baseUrl = process.env.HWLAB_CASERUN_NATIVE_URL || "http://127.0.0.1:4316"; + +export {}; + +function requireCondition(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +async function readJson(path: string, init?: RequestInit): Promise<{ response: Response; body: Record }> { + const response = await fetch(`${baseUrl}${path}`, init); + const body = await response.json(); + requireCondition(body && typeof body === "object" && !Array.isArray(body), `invalid JSON object from ${path}`); + return { response, body }; +} + +const started = await readJson("/v1/caserun/runs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ caseId: "native-completed" }) +}); +requireCondition(started.response.status === 202, `start status=${started.response.status}`); +requireCondition(typeof started.body.runId === "string", "start runId missing"); + +await readJson(`/v1/caserun/runs/${encodeURIComponent(started.body.runId)}`); +const completed = await readJson(`/v1/caserun/runs/${encodeURIComponent(started.body.runId)}`); +requireCondition(completed.body.status === "completed", `run status=${completed.body.status}`); + +const aggregate = await readJson(`/v1/caserun/runs/${encodeURIComponent(started.body.runId)}/aggregate`); +requireCondition(aggregate.response.status === 200, `aggregate httpStatus=${aggregate.response.status}`); +requireCondition(aggregate.body.mode === "native-test", `aggregate mode=${aggregate.body.mode}`); +requireCondition(aggregate.body.status === "completed", `aggregate status=${aggregate.body.status}`); +requireCondition(aggregate.body.runId === started.body.runId, `aggregate runId=${aggregate.body.runId}`); +requireCondition(typeof aggregate.body.sha256 === "string" && /^[a-f0-9]{64}$/.test(aggregate.body.sha256), `aggregate sha256=${aggregate.body.sha256}`); +const references = completed.body.references; +requireCondition(references && typeof references === "object" && !Array.isArray(references), "run aggregate reference missing"); +const aggregateReference = (references as Record).aggregate; +requireCondition(aggregateReference && typeof aggregateReference === "object" && !Array.isArray(aggregateReference), "run aggregate reference invalid"); +requireCondition(aggregate.body.sha256 === (aggregateReference as Record).sha256, "aggregate SHA does not match run reference"); + +console.log(JSON.stringify({ + ok: true, + mode: aggregate.body.mode, + path: `/v1/caserun/runs/${started.body.runId}/aggregate`, + status: aggregate.body.status, + runId: aggregate.body.runId, + sha256: aggregate.body.sha256 +})); diff --git a/web/hwlab-cloud-web/scripts/caserun-native-web-probe.mjs b/web/hwlab-cloud-web/scripts/caserun-native-web-probe.mjs index b799d977..5e23c912 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-web-probe.mjs +++ b/web/hwlab-cloud-web/scripts/caserun-native-web-probe.mjs @@ -8,16 +8,20 @@ export default async function caserunNativeSmoke({ page, goto, wait, screenshot, await wait(300); const state = await page.locator("#caserun-status").evaluate((element) => element.textContent || ""); + const runId = (await page.locator("#caserun-run-id").textContent())?.trim() || ""; const events = await page.locator("#caserun-event-text").textContent(); - const aggregate = await page.locator("#caserun-aggregate").textContent(); + const aggregateStatus = (await page.locator("#caserun-aggregate-status").textContent())?.trim() || ""; + const aggregateRunId = (await page.locator("#caserun-aggregate-run-id").textContent())?.trim() || ""; + const aggregateSha = (await page.locator("#caserun-aggregate-sha").textContent())?.trim() || ""; const conditions = { mode: state.includes("native-test"), terminal: state.includes("true"), - completed: aggregate?.includes("completed") === true, + aggregateStatus: aggregateStatus === "completed", + aggregateRunId: aggregateRunId === runId && runId.startsWith("native-completed-"), + aggregateSha: /^[a-f0-9]{64}$/.test(aggregateSha), queuedEvent: events?.includes("queued") === true, runningEvent: events?.includes("running") === true, - completedEvent: events?.includes("completed") === true, - aggregate: aggregate?.includes("aggregate pending") === false + completedEvent: events?.includes("completed") === true }; const image = await screenshot("caserun-native-completed.png"); recordStep("caserun-native-completed", { ok: Object.values(conditions).every(Boolean), conditions }); diff --git a/web/hwlab-cloud-web/src/api/caserun.ts b/web/hwlab-cloud-web/src/api/caserun.ts index a4028888..7fd589d6 100644 --- a/web/hwlab-cloud-web/src/api/caserun.ts +++ b/web/hwlab-cloud-web/src/api/caserun.ts @@ -1,5 +1,5 @@ import { fetchJson } from "./client"; -import type { ApiResult, CaseRunCasesResponse, CaseRunEventsResponse, CaseRunRunResponse } from "@/types"; +import type { ApiResult, CaseRunAggregateResponse, CaseRunCasesResponse, CaseRunEventsResponse, CaseRunRunResponse } from "@/types"; export const caserunAPI = { cases: (): Promise> => fetchJson("/v1/caserun/cases", { timeoutMs: 12000, timeoutName: "CaseRun cases" }), @@ -10,5 +10,6 @@ export const caserunAPI = { timeoutName: "CaseRun start" }), run: (runId: string): Promise> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}`, { timeoutMs: 12000, timeoutName: "CaseRun run" }), - events: (runId: string): Promise> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}/events`, { timeoutMs: 12000, timeoutName: "CaseRun events" }) + events: (runId: string): Promise> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}/events`, { timeoutMs: 12000, timeoutName: "CaseRun events" }), + aggregate: (runId: string): Promise> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}/aggregate`, { timeoutMs: 12000, timeoutName: "CaseRun aggregate" }) }; diff --git a/web/hwlab-cloud-web/src/components/caserun/CaseRunPanel.vue b/web/hwlab-cloud-web/src/components/caserun/CaseRunPanel.vue index 85c2b87f..7516c17e 100644 --- a/web/hwlab-cloud-web/src/components/caserun/CaseRunPanel.vue +++ b/web/hwlab-cloud-web/src/components/caserun/CaseRunPanel.vue @@ -18,9 +18,9 @@ const statusTone = computed(() => { const eventLines = computed(() => caserun.events.map((event) => `[${formatTimestamp(event.at)}] ${event.stage} ${event.status}${event.payload ? ` ${JSON.stringify(event.payload)}` : ""}`)); const artifactCount = computed(() => Number(caserun.currentRun?.facts?.artifactCount ?? 0)); const jobId = computed(() => String(caserun.currentRun?.facts?.jobId ?? "")); -const aggregateHash = computed(() => caserun.currentRun?.references?.aggregate?.sha256 ?? ""); +const aggregateHash = computed(() => caserun.aggregate?.sha256 ?? ""); const blockerText = computed(() => caserun.currentRun?.blocker ? `${caserun.currentRun.blocker.code}: ${caserun.currentRun.blocker.summary}` : ""); -const fixtureMode = computed(() => String(caserun.currentRun?.mode ?? caserun.cases[0]?.mode ?? "live")); +const fixtureMode = computed(() => String(caserun.aggregate?.mode ?? caserun.currentRun?.mode ?? caserun.cases[0]?.mode ?? "live")); onMounted(async () => { await caserun.refreshCases(); @@ -63,7 +63,7 @@ async function startRun(): Promise {
-
run
{{ caserun.currentRun?.runId || '-' }}
+
run
{{ caserun.currentRun?.runId || '-' }}
stage
{{ caserun.currentRun?.stage || '-' }}
mode
{{ fixtureMode }}
terminal
{{ caserun.currentRun?.terminal ?? '-' }}
@@ -78,8 +78,9 @@ async function startRun(): Promise {
{{ eventLines.join('\n') || blockerText || caserun.error || '等待 CaseRun。' }}
- {{ aggregateHash ? aggregateHash.slice(0, 16) : 'aggregate pending' }} - {{ caserun.currentRun?.status || 'idle' }} + {{ aggregateHash || 'aggregate pending' }} + {{ caserun.aggregate?.status || 'pending' }} + {{ caserun.aggregate?.runId || '-' }}
diff --git a/web/hwlab-cloud-web/src/stores/caserun.test.ts b/web/hwlab-cloud-web/src/stores/caserun.test.ts index 4d8f2675..6dd6d3cb 100644 --- a/web/hwlab-cloud-web/src/stores/caserun.test.ts +++ b/web/hwlab-cloud-web/src/stores/caserun.test.ts @@ -1,15 +1,16 @@ import { createPinia, setActivePinia } from "pinia"; import { beforeEach, expect, test, vi } from "vitest"; -const { casesRequest, startRequest, runRequest, eventsRequest } = vi.hoisted(() => ({ +const { casesRequest, startRequest, runRequest, eventsRequest, aggregateRequest } = vi.hoisted(() => ({ casesRequest: vi.fn(), startRequest: vi.fn(), runRequest: vi.fn(), - eventsRequest: vi.fn() + eventsRequest: vi.fn(), + aggregateRequest: vi.fn() })); vi.mock("@/api", () => ({ - caserunAPI: { cases: casesRequest, startRun: startRequest, run: runRequest, events: eventsRequest } + caserunAPI: { cases: casesRequest, startRun: startRequest, run: runRequest, events: eventsRequest, aggregate: aggregateRequest } })); import { useCaseRunStore } from "./caserun"; @@ -20,11 +21,13 @@ beforeEach(() => { startRequest.mockReset(); runRequest.mockReset(); eventsRequest.mockReset(); + aggregateRequest.mockReset(); }); test("polling follows API terminal instead of status names", async () => { runRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", caseId: "native-completed", status: "custom-progress", stage: "agent-running", terminal: false } }); eventsRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", events: [] } }); + aggregateRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", status: "custom-progress", sha256: "progress-sha" } }); const store = useCaseRunStore(); await store.refreshRun("run-1"); @@ -35,15 +38,18 @@ test("polling follows API terminal instead of status names", async () => { expect(store.shouldPoll).toBe(false); }); -test("start keeps blocker and aggregate from API read model", async () => { +test("start keeps blocker and independent aggregate from API read models", async () => { startRequest.mockResolvedValue({ ok: true, data: { runId: "run-2", caseId: "native-blocked", status: "queued", stage: "queued", terminal: false } }); - runRequest.mockResolvedValue({ ok: true, data: { runId: "run-2", caseId: "native-blocked", status: "blocked", stage: "preparing", terminal: true, blocker: { code: "native-capability-blocked", summary: "blocked" }, references: { aggregate: { sha256: "abc123" } } } }); + runRequest.mockResolvedValue({ ok: true, data: { runId: "run-2", caseId: "native-blocked", status: "blocked", stage: "preparing", terminal: true, blocker: { code: "native-capability-blocked", summary: "blocked" }, references: { aggregate: { sha256: "embedded-sha" } } } }); eventsRequest.mockResolvedValue({ ok: true, data: { runId: "run-2", events: [{ status: "blocked", stage: "preparing" }] } }); + aggregateRequest.mockResolvedValue({ ok: true, data: { mode: "native-test", runId: "run-2", status: "blocked", sha256: "api-aggregate-sha" } }); const store = useCaseRunStore(); await store.startRun("native-blocked"); expect(store.currentRun?.blocker?.code).toBe("native-capability-blocked"); - expect(store.currentRun?.references?.aggregate?.sha256).toBe("abc123"); + expect(store.aggregate?.runId).toBe("run-2"); + expect(store.aggregate?.status).toBe("blocked"); + expect(store.aggregate?.sha256).toBe("api-aggregate-sha"); expect(store.shouldPoll).toBe(false); }); diff --git a/web/hwlab-cloud-web/src/stores/caserun.ts b/web/hwlab-cloud-web/src/stores/caserun.ts index 3943925b..ab609a98 100644 --- a/web/hwlab-cloud-web/src/stores/caserun.ts +++ b/web/hwlab-cloud-web/src/stores/caserun.ts @@ -1,12 +1,13 @@ import { computed, ref } from "vue"; import { defineStore } from "pinia"; import { caserunAPI } from "@/api"; -import type { CaseRunCaseSummary, CaseRunEvent, CaseRunRunResponse } from "@/types"; +import type { CaseRunAggregateResponse, CaseRunCaseSummary, CaseRunEvent, CaseRunRunResponse } from "@/types"; export const useCaseRunStore = defineStore("caserun", () => { const cases = ref([]); const currentRun = ref(null); const events = ref([]); + const aggregate = ref(null); const loading = ref(false); const starting = ref(false); const error = ref(null); @@ -26,6 +27,7 @@ export const useCaseRunStore = defineStore("caserun", () => { starting.value = true; error.value = null; events.value = []; + aggregate.value = null; const result = await caserunAPI.startRun(caseId); starting.value = false; if (!result.ok || !result.data) { @@ -45,7 +47,7 @@ export const useCaseRunStore = defineStore("caserun", () => { return; } currentRun.value = result.data; - await refreshEvents(runId); + await Promise.all([refreshEvents(runId), refreshAggregate(runId)]); } async function refreshEvents(runId = currentRun.value?.runId ?? ""): Promise { @@ -54,5 +56,11 @@ export const useCaseRunStore = defineStore("caserun", () => { if (result.ok && result.data?.runId === runId) events.value = result.data.events ?? []; } - return { cases, currentRun, events, loading, starting, error, selectedCaseId, shouldPoll, refreshCases, startRun, refreshRun, refreshEvents }; + async function refreshAggregate(runId = currentRun.value?.runId ?? ""): Promise { + if (!runId) return; + const result = await caserunAPI.aggregate(runId); + if (result.ok && result.data?.runId === runId) aggregate.value = result.data; + } + + return { cases, currentRun, events, aggregate, loading, starting, error, selectedCaseId, shouldPoll, refreshCases, startRun, refreshRun, refreshEvents, refreshAggregate }; }); diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 38eae6a5..a7ddd0ab 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -522,6 +522,7 @@ export interface CaseRunBlocker { code: string; summary: string; layer?: string export interface CaseRunReference { href?: string; ref?: string | null; sha256?: string | null; count?: number; relationship?: Record | null; [key: string]: unknown } export interface CaseRunRunResponse { ok?: boolean; mode?: string; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; blocker?: CaseRunBlocker | null; createdAt?: string; updatedAt?: string; facts?: { jobId?: string | null; artifactCount?: number; evidenceStatus?: string | null }; references?: { status?: CaseRunReference; events?: CaseRunReference; aggregate?: CaseRunReference; manifest?: CaseRunReference | null; replay?: CaseRunReference | null; trace?: Record | null; hwpod?: Record | null }; sourceAuthority?: Record; error?: { code?: string; message?: string } | null; statusUrl?: string; [key: string]: unknown } export interface CaseRunEventsResponse { ok?: boolean; mode?: string; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; sourceAuthority?: Record; events?: CaseRunEvent[]; [key: string]: unknown } +export interface CaseRunAggregateResponse { ok?: boolean; mode?: string; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; sourceAuthority?: Record; sha256?: string; summary?: Record | null; evidence?: Record | null; run?: Record | null; [key: string]: unknown } export interface ProviderProfilesResponse { ok?: boolean; status?: string; profiles?: Array>; [key: string]: unknown } export interface ProviderProfileCatalogItem { profile: string;