diff --git a/tools/hwlab-cli/bin/hwlab-cli.ts b/tools/hwlab-cli/bin/hwlab-cli.ts index c6ce93ba..0b2ce917 100644 --- a/tools/hwlab-cli/bin/hwlab-cli.ts +++ b/tools/hwlab-cli/bin/hwlab-cli.ts @@ -12,6 +12,18 @@ if (argv[0] === "tasktree") { console.log(JSON.stringify({ ok: false, operation: "tasktree", error: { code: (error as any)?.code ?? "tasktree_cli_error", message: (error as any)?.message ?? String(error) } }, null, 2)); process.exitCode = 1; } +} else if (argv[0] === "caserun") { + const { caserunNativeServiceCommand } = await import("../../src/caserun-native-service.ts"); + try { + const service = argv[1] === "service" ? argv[2] : ""; + const action = argv[1] === "service" ? argv[3] ?? "status" : "status"; + const result = await caserunNativeServiceCommand({ service, action, cwd: process.cwd(), env: process.env }); + console.log(JSON.stringify(result, null, 2)); + process.exitCode = result?.ok === false ? 1 : 0; + } catch (error: any) { + console.log(JSON.stringify({ ok: false, operation: "caserun", error: { code: error?.code ?? "caserun_cli_error", message: error?.message ?? String(error) } }, null, 2)); + process.exitCode = 1; + } } else if (argv[0] === "workbench") { const { runWorkbenchCli } = await import("../../src/workbench-cli.ts"); try { diff --git a/tools/src/caserun-native-service.ts b/tools/src/caserun-native-service.ts new file mode 100644 index 00000000..e2402265 --- /dev/null +++ b/tools/src/caserun-native-service.ts @@ -0,0 +1,94 @@ +import { spawn } from "node:child_process"; +import { closeSync, openSync } from "node:fs"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +type ServiceName = "api" | "web"; + +export async function caserunNativeServiceCommand(input: { service: string; action: string; cwd: string; env: Record }) { + const service = validService(input.service); + const stateDir = path.resolve(input.cwd, input.env.CASERUN_NATIVE_SERVICE_STATE_DIR ?? ".state/caserun-native/services"); + const stateFile = path.join(stateDir, `${service}.json`); + const logFile = path.join(stateDir, `${service}.log`); + if (input.action === "status") return status(service, stateFile, logFile); + if (input.action === "logs") return logs(service, stateFile, logFile); + if (input.action === "stop") return stop(service, stateFile, logFile); + if (input.action === "restart") { + await stop(service, stateFile, logFile); + return start(service, stateDir, stateFile, logFile, input.cwd, input.env); + } + if (input.action === "start") { + const current = await readState(stateFile); + if (current?.pid && alive(current.pid)) await stop(service, stateFile, logFile); + return start(service, stateDir, stateFile, logFile, input.cwd, input.env); + } + throw codedError("unsupported_service_action", `unsupported CaseRun service action: ${input.action}`); +} + +async function start(service: ServiceName, stateDir: string, stateFile: string, logFile: string, cwd: string, env: Record) { + await mkdir(stateDir, { recursive: true }); + const endpoints = serviceEndpoints(service, env); + const command = serviceCommand(service); + const logFd = openSync(logFile, "w"); + const serviceCwd = path.join(cwd, "web/hwlab-cloud-web"); + const child = spawn(command[0], command.slice(1), { cwd: serviceCwd, env: serviceEnv(service, env), detached: true, stdio: ["ignore", logFd, logFd] }); + closeSync(logFd); + child.unref(); + const state = { service, pid: child.pid, status: "starting", command, cwd, logFile, endpoints, startedAt: new Date().toISOString() }; + await writeFile(stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + return { ok: true, operation: `caserun.service.${service}.start`, ...state, nextCommand: `hwlab-cli caserun service ${service} status` }; +} + +async function stop(service: ServiceName, stateFile: string, logFile: string) { + const state = await readState(stateFile); + if (!state?.pid || !alive(state.pid)) { await rm(stateFile, { force: true }); return { ok: true, operation: `caserun.service.${service}.stop`, service, status: "stopped", alreadyStopped: true, logFile }; } + try { process.kill(-state.pid, "SIGTERM"); } catch (error: any) { if (error?.code !== "ESRCH") throw error; } + if (!await waitForExit(state.pid, 3_000)) { + try { process.kill(-state.pid, "SIGKILL"); } catch (error: any) { if (error?.code !== "ESRCH") throw error; } + await waitForExit(state.pid, 1_000); + } + await rm(stateFile, { force: true }); + return { ok: true, operation: `caserun.service.${service}.stop`, service, status: "stopped", pid: state.pid, logFile }; +} + +async function status(service: ServiceName, stateFile: string, logFile: string) { + const state = await readState(stateFile); + const running = Boolean(state?.pid && alive(state.pid)); + return { ok: running, operation: `caserun.service.${service}.status`, service, status: running ? "running" : "stopped", pid: running ? state.pid : null, endpoints: state?.endpoints ?? null, stateFile, logFile, nextCommand: `hwlab-cli caserun service ${service} logs` }; +} + +async function logs(service: ServiceName, stateFile: string, logFile: string) { + const state = await status(service, stateFile, logFile); + const lines = (await readFile(logFile, "utf8").catch(() => "")).split("\n").filter(Boolean).slice(-80); + return { ...state, operation: `caserun.service.${service}.logs`, lines, truncated: lines.length === 80 }; +} + +function serviceCommand(service: ServiceName) { + return service === "api" + ? ["bun", "scripts/caserun-native-supervisor.ts"] + : ["bun", "run", "dev:native-caserun"]; +} +function serviceEnv(service: ServiceName, env: Record) { + return { ...process.env, ...env, HWLAB_CASERUN_NATIVE_TEST: "1", ...(service === "web" + ? { + WORKBENCH_WEB_HOST: requiredEnv(env, "CASERUN_WEB_BIND_HOST"), + WORKBENCH_WEB_PORT: requiredEnv(env, "CASERUN_WEB_PORT"), + HWLAB_CASERUN_NATIVE_URL: `http://${requiredEnv(env, "CASERUN_PROBE_HOST")}:${requiredEnv(env, "CASERUN_API_PORT")}` + } + : { HWLAB_CASERUN_NATIVE_HOST: requiredEnv(env, "CASERUN_API_BIND_HOST"), HWLAB_CASERUN_NATIVE_PORT: requiredEnv(env, "CASERUN_API_PORT") }) }; +} +function serviceEndpoints(service: ServiceName, env: Record) { + const port = service === "api" ? requiredEnv(env, "CASERUN_API_PORT") : requiredEnv(env, "CASERUN_WEB_PORT"); + const host = requiredEnv(env, "CASERUN_PUBLIC_HOST"); + return { host, port: Number(port), url: `http://${host}:${Number(port)}${service === "api" ? "/health" : "/caserun"}` }; +} +function validService(value: string): ServiceName { if (value === "api" || value === "web") return value; throw codedError("invalid_service", "CaseRun service must be api or web"); } +async function readState(file: string) { return JSON.parse(await readFile(file, "utf8").catch(() => "null")); } +function alive(pid: number) { try { process.kill(pid, 0); return true; } catch { return false; } } +async function waitForExit(pid: number, timeoutMs: number) { + const deadline = Date.now() + timeoutMs; + while (alive(pid) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 50)); + return !alive(pid); +} +function requiredEnv(env: Record, key: string) { const value = String(env[key] ?? "").trim(); if (!value) throw codedError("native_exposure_config_required", `${key} is required from YAML-first native CaseRun config`); return value; } +function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); } diff --git a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts index 817c3eb7..79ef62a1 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts +++ b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts @@ -21,12 +21,12 @@ const CONTRACT_VERSION = "caserun.v1"; const BASE_TIME = Date.parse("2026-07-17T00:00:00.000Z"); const scenarios: Record = { - "native-queued": { title: "Native queued", steps: [{ status: "queued", stage: "queued", terminal: false }] }, - "native-running": { title: "Native running", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }] }, - "native-completed": { title: "Native completed", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }, { status: "completed", stage: "completed", terminal: true }] }, - "native-failed": { title: "Native failed", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "building", terminal: false }, { status: "failed", stage: "failed", terminal: true, blocker: { code: "native-build-failed", summary: "Deterministic native build failure", layer: "build" } }] }, - "native-blocked": { title: "Native blocked", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "blocked", stage: "preparing", terminal: true, blocker: { code: "native-capability-blocked", summary: "Deterministic native capability blocker", layer: "hwpod" } }] }, - "native-canceled": { title: "Native canceled", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }, { status: "canceled", stage: "canceled", terminal: true }] } + "native-queued": { title: "原生排队中", steps: [{ status: "queued", stage: "queued", terminal: false }] }, + "native-running": { title: "原生运行中", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }] }, + "native-completed": { title: "原生已完成", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }, { status: "completed", stage: "completed", terminal: true }] }, + "native-failed": { title: "原生失败", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "building", terminal: false }, { status: "failed", stage: "failed", terminal: true, blocker: { code: "native-build-failed", summary: "原生构建按确定性场景失败", layer: "build" } }] }, + "native-blocked": { title: "原生受阻", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "blocked", stage: "preparing", terminal: true, blocker: { code: "native-capability-blocked", summary: "原生能力按确定性场景受阻", layer: "hwpod" } }] }, + "native-canceled": { title: "原生已取消", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }, { status: "canceled", stage: "canceled", terminal: true }] } }; let runSequence = 0; diff --git a/web/hwlab-cloud-web/scripts/caserun-native-reload-smoke.ts b/web/hwlab-cloud-web/scripts/caserun-native-reload-smoke.ts index 1cb2fe85..bb728a1d 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-reload-smoke.ts +++ b/web/hwlab-cloud-web/scripts/caserun-native-reload-smoke.ts @@ -75,7 +75,7 @@ function childPids(supervisorPid: number): number[] { rmSync(displacedPath, { force: true }); const supervisor = Bun.spawn([process.execPath, "scripts/caserun-native-supervisor.ts"], { cwd: root, - env: { ...process.env, HWLAB_CASERUN_NATIVE_PORT: String(port) }, + env: { ...process.env, HWLAB_CASERUN_NATIVE_HOST: "127.0.0.1", HWLAB_CASERUN_NATIVE_PORT: String(port) }, stdin: "ignore", stdout: "pipe", stderr: "pipe" diff --git a/web/hwlab-cloud-web/scripts/caserun-native-server.ts b/web/hwlab-cloud-web/scripts/caserun-native-server.ts index 5ee57959..da6afc83 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-server.ts +++ b/web/hwlab-cloud-web/scripts/caserun-native-server.ts @@ -1,7 +1,7 @@ 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"; +const port = Number(requiredEnv("HWLAB_CASERUN_NATIVE_PORT")); +const hostname = requiredEnv("HWLAB_CASERUN_NATIVE_HOST"); function json(data: unknown, status = 200): Response { return Response.json(data, { status, headers: { "cache-control": "no-store" } }); @@ -65,3 +65,9 @@ const server = Bun.serve({ }); console.log(JSON.stringify({ event: "caserun-native-listening", mode: "native-test", hostname: server.hostname, port: server.port })); + +function requiredEnv(name: string): string { + const value = String(process.env[name] ?? "").trim(); + if (!value) throw new Error(`${name} is required from the CLI-managed YAML profile`); + return value; +} diff --git a/web/hwlab-cloud-web/scripts/caserun-native-supervisor.ts b/web/hwlab-cloud-web/scripts/caserun-native-supervisor.ts index 7c5603fc..7c3a91f5 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-supervisor.ts +++ b/web/hwlab-cloud-web/scripts/caserun-native-supervisor.ts @@ -7,7 +7,7 @@ const watchedFiles = [ resolve(root, "scripts/caserun-native-fixtures.ts") ]; -let child: ReturnType | null = null; +let child: ReturnType | null = null; let signature = sourceSignature(); let stopping = false; let restarting = false; @@ -25,7 +25,7 @@ function sourceSignature(): string | null { } function startChild(reason: string): void { - const spawned = Bun.spawn([process.execPath, "scripts/caserun-native-server.ts"], { cwd: root, stdin: "ignore", stdout: "inherit", stderr: "inherit" }); + const spawned = spawnNativeServer(); child = spawned; console.log(JSON.stringify({ event: "caserun-native-reload", mode: "native-test", reason, pid: spawned.pid })); void spawned.exited.then((exitCode) => { @@ -33,6 +33,10 @@ function startChild(reason: string): void { }); } +function spawnNativeServer() { + return Bun.spawn([process.execPath, "scripts/caserun-native-server.ts"], { cwd: root, stdin: "ignore", stdout: "inherit", stderr: "inherit" }); +} + async function stopChild(): Promise { const runningChild = child; if (!runningChild) return; 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 5e23c912..4c7444d5 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-web-probe.mjs +++ b/web/hwlab-cloud-web/scripts/caserun-native-web-probe.mjs @@ -1,22 +1,26 @@ export default async function caserunNativeSmoke({ page, goto, wait, screenshot, recordStep }) { - await goto("/workbench"); + await goto("/caserun"); const panel = page.locator("#caserun-panel"); await panel.waitFor({ state: "visible", timeout: 20000 }); - await page.locator("#caserun-case-select").selectOption("native-completed"); + const nativeCase = page.locator(".caserun-case-row").filter({ hasText: "native-completed" }).first(); + await nativeCase.waitFor({ state: "visible", timeout: 20000 }); + await nativeCase.click(); await page.locator("#caserun-start").click(); await page.locator("#caserun-refresh").click(); await wait(300); const state = await page.locator("#caserun-status").evaluate((element) => element.textContent || ""); + const mode = (await page.locator("#caserun-mode").textContent())?.trim() || ""; + const terminal = (await page.locator("#caserun-terminal").textContent())?.trim() || ""; const runId = (await page.locator("#caserun-run-id").textContent())?.trim() || ""; const events = await page.locator("#caserun-event-text").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"), - aggregateStatus: aggregateStatus === "completed", + mode: mode.includes("native-test"), + terminal: terminal === "是", + aggregateStatus: aggregateStatus === "已完成", aggregateRunId: aggregateRunId === runId && runId.startsWith("native-completed-"), aggregateSha: /^[a-f0-9]{64}$/.test(aggregateSha), queuedEvent: events?.includes("queued") === true, diff --git a/web/hwlab-cloud-web/src/components/layout/AppNavigation.vue b/web/hwlab-cloud-web/src/components/layout/AppNavigation.vue index e4f10ede..f4f9398a 100644 --- a/web/hwlab-cloud-web/src/components/layout/AppNavigation.vue +++ b/web/hwlab-cloud-web/src/components/layout/AppNavigation.vue @@ -82,6 +82,13 @@ const navSections = [ icon: Bot, navId: "workbench.code", }, + { + name: "CaseRun", + label: "CaseRun", + path: "/caserun", + icon: CircuitBoard, + navId: "workbench.code", + }, { name: "WorkbenchDebug", label: "调试", diff --git a/web/hwlab-cloud-web/src/components/layout/AppShell.vue b/web/hwlab-cloud-web/src/components/layout/AppShell.vue index 9e1b0096..49f26590 100644 --- a/web/hwlab-cloud-web/src/components/layout/AppShell.vue +++ b/web/hwlab-cloud-web/src/components/layout/AppShell.vue @@ -64,7 +64,7 @@ watch(showWorkbenchDiagnostics, (visible) => { -
+
diff --git a/web/hwlab-cloud-web/src/router/index.ts b/web/hwlab-cloud-web/src/router/index.ts index e648264c..92dea099 100644 --- a/web/hwlab-cloud-web/src/router/index.ts +++ b/web/hwlab-cloud-web/src/router/index.ts @@ -12,6 +12,7 @@ const ProjectsView = () => import("@/views/projects/ProjectsView.vue"); const ProjectMdtodoView = () => import("@/views/projects/MdtodoView.vue"); const TaskTreeView = () => import("@/views/projects/TaskTreeView.vue"); const AgentRunsView = () => import("@/views/agents/AgentRunsView.vue"); +const CaseRunView = () => import("@/views/caserun/CaseRunView.vue"); const routes: RouteRecordRaw[] = [ { path: "/", redirect: "/workbench" }, @@ -21,6 +22,8 @@ const routes: RouteRecordRaw[] = [ { path: "/workbench/debug", name: "WorkbenchDebug", component: WorkbenchDebugView, meta: { requiresAuth: true, navId: "workbench.debug", title: "Workbench 调试", section: "workbench-debug" } }, { path: "/workbench/sessions/:sessionId", alias: ["/workspace/sessions/:sessionId"], name: "CodeWorkbenchSession", component: CodeWorkbenchView, meta: { requiresAuth: true, navId: "workbench.code", title: "Code 工作台", section: "workbench" } }, { path: "/workbench", alias: ["/workspace"], name: "CodeWorkbench", component: CodeWorkbenchView, meta: { requiresAuth: true, navId: "workbench.code", title: "Code 工作台", section: "workbench" } }, + { path: "/caserun", name: "CaseRun", component: CaseRunView, meta: { requiresAuth: true, navId: "workbench.code", title: "CaseRun 执行台", section: "caserun", boundedLayout: true } }, + { path: "/caserun/runs/:runId", name: "CaseRunDetail", component: CaseRunView, meta: { requiresAuth: true, navId: "workbench.code", navParent: "CaseRun", title: "CaseRun 详情", section: "caserun", boundedLayout: true } }, { path: "/agents/runs", name: "AgentRuns", component: AgentRunsView, meta: { requiresAuth: true, navId: "workbench.code", title: "AgentRun 运行观察", section: "agents" } }, { path: "/agents/runs/:runId", name: "AgentRunDetail", component: AgentRunsView, meta: { requiresAuth: true, navId: "workbench.code", navParent: "AgentRuns", title: "AgentRun 详情", section: "agents" } }, { path: "/opencode", name: "OpenCode", component: OpenCodeFrameView, meta: { requiresAuth: true, navId: "opencode.root", title: "OpenCode", section: "opencode" } }, diff --git a/web/hwlab-cloud-web/src/router/meta.d.ts b/web/hwlab-cloud-web/src/router/meta.d.ts index cc3b485c..d67a8278 100644 --- a/web/hwlab-cloud-web/src/router/meta.d.ts +++ b/web/hwlab-cloud-web/src/router/meta.d.ts @@ -12,6 +12,7 @@ declare module "vue-router" { navId?: string; navParent?: string; title?: string; - section?: "workbench" | "workbench-debug" | "opencode" | "project" | "agents" | "admin" | "user" | "system"; + boundedLayout?: boolean; + section?: "workbench" | "workbench-debug" | "caserun" | "opencode" | "project" | "agents" | "admin" | "user" | "system"; } } diff --git a/web/hwlab-cloud-web/src/styles/console-shell.css b/web/hwlab-cloud-web/src/styles/console-shell.css index 413fad7d..3c2c1957 100644 --- a/web/hwlab-cloud-web/src/styles/console-shell.css +++ b/web/hwlab-cloud-web/src/styles/console-shell.css @@ -67,6 +67,15 @@ padding: 12px; } +.platform-content.is-bounded-layout { + overflow: hidden; +} + +.platform-content.is-bounded-layout > * { + height: 100%; + overflow: hidden; +} + .platform-content > * { min-height: 0; } diff --git a/web/hwlab-cloud-web/src/views/caserun/CaseRunView.vue b/web/hwlab-cloud-web/src/views/caserun/CaseRunView.vue new file mode 100644 index 00000000..74d25d52 --- /dev/null +++ b/web/hwlab-cloud-web/src/views/caserun/CaseRunView.vue @@ -0,0 +1,317 @@ + + + + + + diff --git a/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue b/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue index 094b59cd..b890ea3b 100644 --- a/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue +++ b/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue @@ -8,7 +8,6 @@ import CommandComposer from "@/components/workbench/CommandComposer.vue"; import ConversationPanel from "@/components/workbench/ConversationPanel.vue"; import SessionRail from "@/components/workbench/SessionRail.vue"; import WorkbenchKafkaDebugPanel from "@/components/workbench/WorkbenchKafkaDebugPanel.vue"; -import CaseRunPanel from "@/components/caserun/CaseRunPanel.vue"; import HwpodNodeOpsPanel from "@/components/hwpod/HwpodNodeOpsPanel.vue"; import { useAutoRefresh } from "@/composables/useAutoRefresh"; import { shouldReflectWorkbenchSessionUrl } from "@/router/workbench-navigation"; @@ -152,7 +151,6 @@ async function reflectActiveSessionInUrl(value: string | null): Promise {
-
diff --git a/web/hwlab-cloud-web/vite.config.ts b/web/hwlab-cloud-web/vite.config.ts index b266b208..f7d261ce 100644 --- a/web/hwlab-cloud-web/vite.config.ts +++ b/web/hwlab-cloud-web/vite.config.ts @@ -2,23 +2,40 @@ import { Agent } from "node:http"; import { fileURLToPath, URL } from "node:url"; import vue from "@vitejs/plugin-vue"; -import { defineConfig, loadEnv, type Plugin } from "vite"; +import { defineConfig, loadEnv, type Plugin, type ProxyOptions } from "vite"; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ""); const nativeApiUrl = String(process.env.WORKBENCH_NATIVE_API_URL ?? env.WORKBENCH_NATIVE_API_URL ?? "").trim(); const nativeRuntimeConfig = String(process.env.WORKBENCH_WEB_RUNTIME_CONFIG ?? env.WORKBENCH_WEB_RUNTIME_CONFIG ?? "").trim(); const nativeCaseRunEnabled = (process.env.HWLAB_CASERUN_NATIVE_TEST ?? env.HWLAB_CASERUN_NATIVE_TEST) === "1"; - const nativeCaseRunTarget = process.env.HWLAB_CASERUN_NATIVE_URL ?? env.HWLAB_CASERUN_NATIVE_URL ?? "http://127.0.0.1:4316"; + const nativeCaseRunTarget = String(process.env.HWLAB_CASERUN_NATIVE_URL ?? env.HWLAB_CASERUN_NATIVE_URL ?? "").trim(); const nativeCaseRunAgent = new Agent({ keepAlive: true }); if (nativeApiUrl && nativeCaseRunEnabled) { throw new Error("Workbench and CaseRun native modes cannot share one Vite server"); } + if (nativeCaseRunEnabled && !nativeCaseRunTarget) throw new Error("HWLAB_CASERUN_NATIVE_URL is required from the CLI-managed YAML profile in CaseRun native mode"); if (nativeApiUrl && !nativeRuntimeConfig) throw new Error("WORKBENCH_WEB_RUNTIME_CONFIG is required in Workbench native mode"); + const proxy: Record | undefined = nativeCaseRunEnabled + ? { + "/auth": { target: nativeCaseRunTarget, changeOrigin: false, agent: nativeCaseRunAgent }, + "/v1/caserun": { target: nativeCaseRunTarget, changeOrigin: false, agent: nativeCaseRunAgent } + } + : nativeApiUrl + ? { + "/v1": { target: nativeApiUrl, changeOrigin: false }, + "/auth": { target: nativeApiUrl, changeOrigin: false }, + "/health": { target: nativeApiUrl, changeOrigin: false } + } + : undefined; return { - plugins: [...(nativeApiUrl ? [nativeRuntimeConfigPlugin(nativeRuntimeConfig)] : []), vue()], + plugins: [ + ...(nativeApiUrl ? [nativeRuntimeConfigPlugin(nativeRuntimeConfig)] : []), + ...(nativeCaseRunEnabled ? [nativeCaseRunRuntimeConfigPlugin()] : []), + vue() + ], resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) @@ -31,26 +48,7 @@ export default defineConfig(({ mode }) => { watch: nativeCaseRunEnabled || env.WORKBENCH_VITE_USE_POLLING === "1" ? { usePolling: true, interval: nativeCaseRunEnabled ? 300 : 500 } : undefined, - proxy: nativeCaseRunEnabled - ? { - "/auth": { - target: nativeCaseRunTarget, - changeOrigin: false, - agent: nativeCaseRunAgent - }, - "/v1/caserun": { - target: nativeCaseRunTarget, - changeOrigin: false, - agent: nativeCaseRunAgent - } - } - : nativeApiUrl - ? { - "/v1": { target: nativeApiUrl, changeOrigin: false }, - "/auth": { target: nativeApiUrl, changeOrigin: false }, - "/health": { target: nativeApiUrl, changeOrigin: false } - } - : undefined + proxy }, build: { outDir: "dist", @@ -78,6 +76,34 @@ function nativeRuntimeConfigPlugin(serialized: string): Plugin { }; } +function nativeCaseRunRuntimeConfigPlugin(): Plugin { + return { + name: "hwlab-caserun-native-runtime-config", + configureServer(server) { + server.middlewares.use((request, response, next) => { + if (request.url !== "/v1/web-performance" || request.method !== "POST") return next(); + response.writeHead(204, { "cache-control": "no-store" }); + response.end(); + }); + }, + transformIndexHtml() { + return [{ + tag: "script", + injectTo: "head-prepend", + children: `window.HWLAB_CLOUD_WEB_CONFIG = { + displayTime: { timeZone: "Asia/Shanghai", locale: "zh-CN", label: "北京时间" }, + workbench: { + realtimeFeatures: { liveKafkaSse: false, kafkaRefreshReplay: false, projectionRealtime: true }, + debugCapabilities: { isolatedKafka: false, rawHwlabEventWindow: { enabled: false, maxEntries: 1, maxRetainedBytes: 1 } }, + traceTimeline: { autoExpandRunning: false, autoCollapseTerminal: false }, + runtimePolicy: {} + } + };` + }]; + } + }; +} + export function parseWorkbenchNativeRuntimeConfig(serialized: string): Record { let value: unknown; try { value = JSON.parse(serialized); }