diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index fbb936af..b1d81615 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -51,6 +51,12 @@ | `POST /v1/internal/device-pod/gateway-dispatch` | 仅接受 `hwlab-device-pod` 内部服务凭据,用于把 executor job dispatch 到 gateway poll/result;普通用户和 Code Agent 不可调用。 | | `POST /v1/agent/chat`、`POST /v1/agent/chat/steer`、`GET /v1/agent/chat/result/{traceId}`、`GET /v1/agent/chat/trace/{traceId}`、`POST /v1/agent/chat/cancel` | Code Agent 短连接提交、运行中 steer、轮询、trace 和取消;普通 chat 必须绑定显式 usable session。 | +### `/v1/live-builds` 语义 + +`/v1/live-builds` 返回的是运行面服务清单,不是 Cloud Web 专属 health。`latest` 字段按所有 HWLAB runtime service 的 build time 排序,可能指向 `hwlab-cloud-api`、`hwlab-cloud-web`、`hwlab-agent-skills` 或其他本轮最近滚动的服务;当 API 比 Web 更新时,`latest.serviceId=hwlab-cloud-api` 是正常状态,不能据此判断 Cloud Web 构建时间缺失或 Web rollout 失败。 + +需要验证 Cloud Web 时必须读取 `services[]` 中 `serviceId=hwlab-cloud-web` 的行,并区分 `build.createdAt`、`image.tag/digest`、`commit.id`、`revision` 和 `build.liveMetadataMatch`。`/health/live` 只能证明当前服务 health/revision;Cloud Web 顶部 build chip 和详情弹窗展示的构建时间、env image 和实际 runtime commit 以 `/v1/live-builds` 的对应 service row 为准。 + 登录鉴权 API 的最终规格见 [spec-v02-auth.md](spec-v02-auth.md);用户、权限、device-pod 管理 API 的最终规格见 [spec-user-access.md](spec-user-access.md) 和 [spec-device-pod.md](spec-device-pod.md)。 ## 测试规格 diff --git a/docs/reference/spec-v02-hwlab-cloud-web.md b/docs/reference/spec-v02-hwlab-cloud-web.md index d9d15622..3d44141d 100644 --- a/docs/reference/spec-v02-hwlab-cloud-web.md +++ b/docs/reference/spec-v02-hwlab-cloud-web.md @@ -106,6 +106,8 @@ Browser/layout/live smoke 属于显式专项诊断,不进入默认 Cloud Web c Live smoke 登录前必须等待前端 auth bootstrap 结束(`body[data-auth-state]` 不再是 `checking`,且 login submit 已可见/可用)再填表;登录后必须断言 URL query 不含 `username` 或 `password`,防止原生 form submit 泄漏凭据并伪装成 layout 超时。 +Workbench build summary 的顶部 chip 可以展示 `/v1/live-builds.latest`,但 `latest` 是跨所有 HWLAB runtime service 的最新构建,不是 Cloud Web 专属字段。Cloud Web 布局或 build-time issue 的验收必须打开详情或读取 `services[]` 中 `serviceId=hwlab-cloud-web` 的行,分别核对 build time、env image、actual commit、revision 和 source metadata;不得因为 `latest.serviceId` 指向 `hwlab-cloud-api` 就判定 Web 构建时间缺失。 + Cloud Web check 必须先对实际 bundle 输入集合运行 TypeScript 语义检查。语法检查和 Bun build 只能证明源码可解析或可打包,不能稳定发现未绑定标识符;`isRequestTraceEvent is not defined` 这类错误必须由 semantic check 在发布前拦截。实现上可以生成与 dist build 相同顺序的临时 app entry,再执行 `tsc --noEmit` 或等价 TS checker;只跑 `node --check`、`bun build` 或源码字符串断言不满足本规格。 Cloud Web 单元测试必须自动发现并执行 repo-owned `web/hwlab-cloud-web/**/*.test.ts`,不允许只维护硬编码文件清单。`app-trace` 的 trace row/render helper 必须有纯逻辑单测,直接构造 request、setup、commandExecution、assistant markdown 和 completion events,证明 trace 展示路径不会因为漏定义 helper、事件分类漂移或 markdown body 渲染变更而在浏览器运行期崩溃。 diff --git a/scripts/dev-cloud-workbench-layout-smoke.mjs b/scripts/dev-cloud-workbench-layout-smoke.mjs index 88264dbd..711100f5 100644 --- a/scripts/dev-cloud-workbench-layout-smoke.mjs +++ b/scripts/dev-cloud-workbench-layout-smoke.mjs @@ -38,7 +38,7 @@ export function parseLayoutSmokeArgs(argv) { continue; } normalized.push(arg); - if (["--url", "--report"].includes(arg)) { + if (["--url", "--report", "--viewport-timeout-ms", "--progress-interval-ms", "--cleanup-timeout-ms"].includes(arg)) { index += 1; if (!argv[index]) throw new Error(`${arg} requires a value`); normalized.push(argv[index]); @@ -110,6 +110,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { "默认和 --static 都使用 Vite local-build;源码静态服务路径已废弃。", "--build 会先运行 cd web/hwlab-cloud-web && bun run build,再检查本地 dist 构建产物。", "--live --url http://74.48.78.17:16666/ 只做 DEV live UI 布局/命中/溢出检查,不发送 Code Agent 消息,不调用 Device Pod 或硬件写操作,不等同于硬件验收。", + "长连接透传场景默认向 stderr 输出 JSON progress/heartbeat;可用 --no-progress 关闭,或用 --progress-interval-ms、--viewport-timeout-ms、--cleanup-timeout-ms 调整边界。", "结构化失败会包含 status、viewport、selector、failureType、artifact screenshot/report path。" ] } diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 892b8db8..7fdc0b1e 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -23,6 +23,9 @@ import { decideSmokeReportWrite, smokeCliExitCode } from "./dev-cloud-workbench-smoke.mjs"; +import { + parseLayoutSmokeArgs +} from "./dev-cloud-workbench-layout-smoke.mjs"; import { checkProfiles } from "./src/check-plan.mjs"; const sourceIdentity = Object.freeze({ @@ -194,6 +197,38 @@ test("smoke args include a DOM-only live read-only mode", () => { assert.equal(args.urlExplicit, true); }); +test("layout smoke CLI exposes bounded progress and timeout controls", () => { + const args = parseLayoutSmokeArgs([ + "--live", + "http://74.48.78.17:19666/", + "--viewport-timeout-ms", + "1234", + "--progress-interval-ms", + "0", + "--cleanup-timeout-ms", + "567", + "--no-progress" + ]); + assert.equal(args.mode, "layout"); + assert.equal(args.url, "http://74.48.78.17:19666/"); + assert.equal(args.urlExplicit, true); + assert.equal(args.viewportTimeoutMs, 1234); + assert.equal(args.progressIntervalMs, 0); + assert.equal(args.cleanupTimeoutMs, 567); + assert.equal(args.progressEnabled, false); +}); + +test("layout smoke CLI rejects invalid bounded timeout arguments", () => { + assert.throws( + () => parseLayoutSmokeArgs(["--viewport-timeout-ms", "0"]), + /--viewport-timeout-ms must be a positive integer/u + ); + assert.throws( + () => parseLayoutSmokeArgs(["--progress-interval-ms", "-1"]), + /--progress-interval-ms must be a non-negative integer/u + ); +}); + test("source/default smoke covers React shell-only and module boundary contracts", () => { const report = runDevCloudWorkbenchStaticSmoke(); assert.equal(report.checks.find((item) => item.id === "react-shell-only-index")?.status, "pass"); diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 1a878917..0cc0a827 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -25,6 +25,9 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../ const domOnlyReportPath = tempReportPath("dev-cloud-workbench-dom-only.json"); const liveReportPath = tempReportPath("dev-cloud-workbench-live.json"); const webRoot = path.join(repoRoot, "web/hwlab-cloud-web"); +const defaultLayoutProgressIntervalMs = 10000; +const defaultLayoutViewportTimeoutMs = 15000; +const defaultLayoutCleanupTimeoutMs = 3000; const runtime = Object.freeze({ endpoints: Object.freeze({ frontend: "http://74.48.78.17:16666", @@ -358,6 +361,17 @@ export function parseSmokeArgs(argv) { index += 1; if (!argv[index]) throw new Error("--report requires a value"); args.reportPath = argv[index]; + } else if (arg === "--viewport-timeout-ms") { + index += 1; + args.viewportTimeoutMs = parsePositiveIntegerArg("--viewport-timeout-ms", argv[index]); + } else if (arg === "--progress-interval-ms") { + index += 1; + args.progressIntervalMs = parsePositiveIntegerArg("--progress-interval-ms", argv[index], { allowZero: true }); + } else if (arg === "--cleanup-timeout-ms") { + index += 1; + args.cleanupTimeoutMs = parsePositiveIntegerArg("--cleanup-timeout-ms", argv[index]); + } else if (arg === "--no-progress") { + args.progressEnabled = false; } else if (arg === "--help") { args.help = true; } else { @@ -370,6 +384,14 @@ export function parseSmokeArgs(argv) { return args; } +function parsePositiveIntegerArg(flag, value, options = {}) { + if (!value) throw new Error(`${flag} requires a value`); + const parsed = Number(value); + const valid = Number.isInteger(parsed) && (options.allowZero ? parsed >= 0 : parsed > 0); + if (!valid) throw new Error(`${flag} must be a ${options.allowZero ? "non-negative" : "positive"} integer`); + return parsed; +} + function runStaticSmoke() { const checks = []; const blockers = []; @@ -707,6 +729,9 @@ async function runLiveDomOnlySmoke(args) { export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { const useLiveUrl = args.live === true || args.urlExplicit === true; const layoutSourceMode = useLiveUrl ? "dev-live" : "local-build"; + const progress = createLayoutSmokeProgress(args); + const viewportTimeoutMs = args.viewportTimeoutMs ?? defaultLayoutViewportTimeoutMs; + const cleanupTimeoutMs = args.cleanupTimeoutMs ?? defaultLayoutCleanupTimeoutMs; const artifactRoot = path.resolve( repoRoot, args.artifactDir ?? path.join("tmp", "dev-cloud-workbench-layout-smoke", layoutArtifactTimestamp()) @@ -714,8 +739,10 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { const layoutViewports = workbenchLayoutViewports(); let chromium; try { + progress.log("import-playwright", { sourceMode: layoutSourceMode }); ({ chromium } = await importPlaywright()); } catch (error) { + progress.stop(); const summary = `Workbench layout smoke skipped because Playwright is unavailable: ${error.message}`; return sanitizeLayoutReport({ $schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", @@ -774,30 +801,59 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { } if (!useLiveUrl && args.build === true) { - execFileSync(resolveBunCommand(), ["run", "web/hwlab-cloud-web/scripts/build.ts"], { - cwd: repoRoot, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - timeout: 30000 - }); - } - await fs.promises.mkdir(artifactRoot, { recursive: true }); - - const server = useLiveUrl - ? null - : await startStaticWebServer({ - rootDir: args.build === true ? path.join(webRoot, "dist") : webRoot, - authFixture: true, - liveBuildsFixture: true + progress.log("build-start", { command: "bun run web/hwlab-cloud-web/scripts/build.ts" }); + try { + execFileSync(resolveBunCommand(), ["run", "web/hwlab-cloud-web/scripts/build.ts"], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 30000 }); - const url = useLiveUrl ? args.url : server.url; + } catch (error) { + progress.stop(); + throw error; + } + progress.log("build-done"); + } let browser; + let server = null; + let url = useLiveUrl ? args.url : null; + let cleanupStarted = false; + const cleanupResources = async (reason) => { + if (cleanupStarted) return; + cleanupStarted = true; + progress.log("cleanup-start", { reason, browser: Boolean(browser), server: Boolean(server), timeoutMs: cleanupTimeoutMs }); + if (browser) await closeWithTimeout(() => browser.close(), cleanupTimeoutMs, "browser.close", progress); + if (server) await closeWithTimeout(() => server.close(), cleanupTimeoutMs, "static-server.close", progress); + progress.log("cleanup-done", { reason }); + }; + const signalHandlers = registerLayoutSmokeSignalCleanup(cleanupResources, progress); try { + progress.log("artifact-dir", { artifactRoot }); + await fs.promises.mkdir(artifactRoot, { recursive: true }); + progress.log(useLiveUrl ? "use-live-url" : "start-static-server", { url: useLiveUrl ? args.url : null }); + server = useLiveUrl + ? null + : await startStaticWebServer({ + rootDir: args.build === true ? path.join(webRoot, "dist") : webRoot, + authFixture: true, + liveBuildsFixture: true + }); + url = useLiveUrl ? args.url : server.url; + progress.log(useLiveUrl ? "live-url-ready" : "static-server-ready", { url }); + progress.log("launch-browser"); browser = await launchChromium(chromium); + progress.log("browser-launched", browser.hwlabLaunchPlan ?? {}); const checks = []; const screenshots = []; for (const viewport of layoutViewports) { - const check = await inspectMinimalWorkbenchLayout(browser, url, viewport, { artifactRoot }); + progress.log("viewport-start", { viewport: viewport.id, timeoutMs: viewportTimeoutMs }); + const check = await withLayoutTimeout( + inspectMinimalWorkbenchLayout(browser, url, viewport, { artifactRoot }), + viewportTimeoutMs, + `layout viewport ${viewport.id}` + ); + progress.log("viewport-done", { viewport: viewport.id, status: check.status }); checks.push(check); screenshots.push(...(check.artifacts?.screenshots ?? [])); } @@ -970,8 +1026,9 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { safety: layoutSafety() }); } finally { - if (browser) await browser.close(); - if (server) await server.close(); + removeLayoutSmokeSignalCleanup(signalHandlers); + await cleanupResources("finally"); + progress.stop(); } } @@ -983,6 +1040,83 @@ function workbenchLayoutViewports() { ]; } +function createLayoutSmokeProgress(args = {}) { + const enabled = args.progressEnabled !== false; + const intervalMs = args.progressIntervalMs ?? defaultLayoutProgressIntervalMs; + let lastStage = "start"; + let interval = null; + const write = (stage, details = {}) => { + if (!enabled) return; + const payload = { + event: "layout-smoke-progress", + stage, + generatedAt: new Date().toISOString(), + ...details + }; + process.stderr.write(`[dev-cloud-workbench-layout-smoke] ${JSON.stringify(payload)}\n`); + }; + if (enabled && intervalMs > 0) { + interval = setInterval(() => write("heartbeat", { lastStage }), intervalMs); + interval.unref?.(); + } + return { + log(stage, details = {}) { + lastStage = stage; + write(stage, details); + }, + stop() { + if (interval) clearInterval(interval); + interval = null; + } + }; +} + +function registerLayoutSmokeSignalCleanup(cleanupResources, progress) { + const handlers = []; + for (const signal of ["SIGINT", "SIGTERM"]) { + const handler = () => { + progress.log("signal", { signal }); + cleanupResources(signal) + .catch((error) => progress.log("signal-cleanup-failed", { + signal, + summary: error instanceof Error ? error.message : String(error) + })) + .finally(() => process.exit(signal === "SIGINT" ? 130 : 143)); + }; + process.once(signal, handler); + handlers.push({ signal, handler }); + } + return handlers; +} + +function removeLayoutSmokeSignalCleanup(handlers) { + for (const { signal, handler } of handlers) { + process.off(signal, handler); + } +} + +function withLayoutTimeout(promise, timeoutMs, label) { + let timeout = null; + const timer = new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); + }); + return Promise.race([promise, timer]).finally(() => { + if (timeout) clearTimeout(timeout); + }); +} + +async function closeWithTimeout(closeFn, timeoutMs, label, progress) { + try { + await withLayoutTimeout(Promise.resolve().then(closeFn), timeoutMs, label); + progress.log("cleanup-resource-done", { resource: label }); + } catch (error) { + progress.log("cleanup-resource-timeout", { + resource: label, + summary: error instanceof Error ? error.message : String(error) + }); + } +} + function layoutSafety() { return { ...staticSafety(), @@ -3441,9 +3575,11 @@ async function inspectAuthFixtureViewport(browser, url, viewport) { } async function loginWithDefaultCredentials(page) { - await page.waitForLoadState("networkidle", { timeout: 12000 }).catch(() => null); - await page.waitForFunction(() => document.body.dataset.authState !== "checking", null, { timeout: 12000 }).catch(() => null); - const loginVisible = await page.locator("#login-shell").evaluate((element) => element.hidden === false).catch(() => false); + await page.waitForFunction(() => { + const authState = document.body.dataset.authState ?? ""; + return authState !== "checking" || Boolean(document.querySelector("#login-shell, #command-input")); + }, null, { timeout: 12000 }).catch(() => null); + const loginVisible = await page.evaluate(() => document.querySelector("#login-shell")?.hidden === false).catch(() => false); if (!loginVisible) return; await page.locator("#login-submit").waitFor({ state: "visible", timeout: 12000 }); await page.waitForFunction(() => document.querySelector("#login-submit")?.disabled === false, null, { timeout: 12000 }).catch(() => null); @@ -5195,6 +5331,7 @@ async function startStaticWebServer(options = {}) { const rootDir = path.resolve(options.rootDir ?? webRoot); const authFixtureSessions = new Set(); const agentTraceStore = new Map(); + const sockets = new Set(); const server = http.createServer(async (request, response) => { const url = new URL(request.url ?? "/", "http://127.0.0.1"); if (options.authFixture && await handleAuthFixtureApi({ request, response, url, authFixtureSessions })) { @@ -5229,6 +5366,10 @@ async function startStaticWebServer(options = {}) { response.end(JSON.stringify({ ok: false, error: "not_found" })); } }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); @@ -5236,7 +5377,10 @@ async function startStaticWebServer(options = {}) { const address = server.address(); return { url: `http://127.0.0.1:${address.port}/`, - close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + close: () => new Promise((resolve, reject) => { + for (const socket of sockets) socket.destroy(); + server.close((error) => error ? reject(error) : resolve()); + }) }; }