From ee9d60f00b1bc47c1d99b7edb8746623333fe544 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Fri, 22 May 2026 17:53:05 +0000 Subject: [PATCH] test: capture deployed workbench user journey evidence --- .../dev-gate/dev-cloud-workbench-live.json | 225 +++++++++++++ scripts/dev-cloud-workbench-smoke.mjs | 8 + scripts/src/dev-cloud-workbench-smoke-lib.mjs | 305 +++++++++++++++++- scripts/validate-dev-gate-report.mjs | 183 ++++++++++- 4 files changed, 707 insertions(+), 14 deletions(-) create mode 100644 reports/dev-gate/dev-cloud-workbench-live.json diff --git a/reports/dev-gate/dev-cloud-workbench-live.json b/reports/dev-gate/dev-cloud-workbench-live.json new file mode 100644 index 00000000..cc15e2e1 --- /dev/null +++ b/reports/dev-gate/dev-cloud-workbench-live.json @@ -0,0 +1,225 @@ +{ + "$schema": "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", + "$id": "https://hwlab.pikastech.local/reports/dev-gate/dev-cloud-workbench-live.json", + "reportVersion": "v1", + "status": "pass", + "issue": "pikasTech/HWLAB#7", + "taskId": "dev-cloud-workbench-live", + "commitId": "ab252f5", + "acceptanceLevel": "dev_cloud_workbench_live", + "devOnly": true, + "prodDisabled": true, + "reportLifecycle": { + "version": "v1", + "state": "active", + "activeEndpoint": "http://74.48.78.17:16667", + "activeBrowserEndpoint": "http://74.48.78.17:16666", + "deprecatedEndpoint": null, + "summary": "Current deployed browser user journey report for the 16666 Cloud Workbench; it does not substitute for M3/M4/M5 hardware acceptance." + }, + "task": "DC-DCSN-P0-2026-003", + "refs": [ + "pikasTech/HWLAB#7", + "pikasTech/HWLAB#108", + "pikasTech/HWLAB#99", + "pikasTech/HWLAB#78", + "pikasTech/HWLAB#117", + "pikasTech/HWLAB#118", + "pikasTech/HWLAB#119", + "pikasTech/HWLAB#120", + "pikasTech/HWLAB#131", + "pikasTech/HWLAB#164" + ], + "mode": "live", + "url": "http://74.48.78.17:16666/", + "generatedAt": "2026-05-22T17:52:04.341Z", + "evidenceLevel": "DEV-LIVE-BROWSER", + "devLive": true, + "sourceContract": { + "status": "pass", + "documents": [ + "docs/cloud-web-workbench.md", + "docs/reference/cloud-workbench.md", + "docs/reference/code-agent-chat-readiness.md" + ], + "summary": "The deployed browser journey is anchored to the Cloud Workbench and Code Agent readiness contracts." + }, + "validationCommands": [ + "node --check scripts/dev-cloud-workbench-smoke.mjs", + "node --check scripts/src/dev-cloud-workbench-smoke-lib.mjs", + "node scripts/dev-cloud-workbench-smoke.mjs --static", + "node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json", + "node scripts/validate-dev-gate-report.mjs reports/dev-gate/dev-cloud-workbench-live.json", + "node scripts/validate-dev-gate-report.mjs" + ], + "localSmoke": { + "status": "not_run", + "commands": [ + "node scripts/dev-cloud-workbench-smoke.mjs --static" + ], + "evidence": [ + "Static mode verifies the source contract and same-origin Code Agent wiring only." + ], + "summary": "Static workbench smoke is SOURCE-level evidence and does not prove the deployed browser journey." + }, + "dryRun": { + "status": "not_applicable", + "commands": [ + "node scripts/dev-cloud-workbench-smoke.mjs --static" + ], + "evidence": [ + "No dry-run substitute is accepted for the deployed browser journey." + ], + "summary": "The journey evidence is collected from the real DEV browser endpoint, not a dry-run fixture." + }, + "devPreconditions": { + "status": "pass", + "requirements": [ + "GET http://74.48.78.17:16666/ serves the Cloud Workbench HTML.", + "Browser DOM exposes the default workbench and core controls.", + "A controlled same-origin POST /v1/agent/chat returns completed UI-visible evidence or reports a blocker.", + "No hardware write API, service restart, PROD access, or secret read is performed." + ], + "commands": [ + "node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json" + ], + "summary": "Deployed 16666 browser journey and same-origin Code Agent chat completed." + }, + "endpoints": { + "frontend": "http://74.48.78.17:16666", + "api": "http://74.48.78.17:16667", + "edge": "http://74.48.78.17:16667" + }, + "checks": [ + { + "id": "live-http-html", + "status": "pass", + "summary": "Live URL serves the Cloud Workbench HTML.", + "evidence": [ + "http://74.48.78.17:16666/", + "HTTP 200", + "text/html; charset=utf-8" + ] + }, + { + "id": "live-browser-dom", + "status": "pass", + "summary": "Browser DOM confirms the default workbench, core controls, and outer scroll lock.", + "evidence": [ + "title=HWLAB 云工作台", + "bodyOverflow=hidden", + "htmlOverflow=hidden" + ], + "observations": { + "title": "HWLAB 云工作台", + "bodyOverflow": "hidden", + "htmlOverflow": "hidden", + "workspaceHidden": false, + "gateHidden": true, + "helpHidden": true, + "outerScrollLocked": true, + "labelsPresent": true, + "coreControlsVisible": { + "commandInput": true, + "commandSend": true, + "agentChatStatus": true, + "conversationList": true, + "hardwareList": true, + "wiringTab": true, + "recordsTab": true + } + } + }, + { + "id": "live-code-agent-browser-journey", + "status": "pass", + "summary": "Deployed browser journey opened the workbench, sent a Code Agent message, and observed a completed UI response.", + "evidence": [ + "POST /v1/agent/chat HTTP 200", + "status=completed", + "provider=openai-responses", + "model=gpt-5.5", + "traceId=trc_cloud-web-workbench-1779472320347-11", + "uiStatus=已回复" + ], + "observations": { + "controls": { + "appShell": true, + "workspace": true, + "commandInput": true, + "commandSend": true, + "agentChatStatus": true, + "conversationList": true, + "hardwareList": true, + "wiringTab": true, + "recordsTab": true + }, + "request": { + "method": "POST", + "status": 200, + "urlPath": "/v1/agent/chat" + }, + "response": { + "status": "completed", + "provider": "openai-responses", + "model": "gpt-5.5", + "backend": "hwlab-cloud-api/openai-responses", + "traceId": "trc_cloud-web-workbench-1779472320347-11", + "hasReply": true, + "error": null + }, + "ui": { + "title": "HWLAB 云工作台", + "workspaceHidden": false, + "gateHidden": true, + "helpHidden": true, + "agentChatStatus": "已回复", + "inputCleared": true, + "completedMessageVisible": true, + "failedMessageVisible": false, + "messageCount": 4, + "completedMessageHasNonSensitiveMeta": true + }, + "networkEvents": [ + { + "method": "POST", + "status": 200, + "urlPath": "/v1/agent/chat", + "body": { + "status": "completed", + "provider": "openai-responses", + "model": "gpt-5.5", + "backend": "hwlab-cloud-api/openai-responses", + "traceId": "trc_cloud-web-workbench-1779472320347-11", + "hasReply": true, + "error": null + } + } + ] + } + } + ], + "blockers": [], + "safety": { + "prodTouched": false, + "servicesRestarted": false, + "heavyE2E": false, + "hardwareWriteApis": false, + "sourceIsDevLive": false, + "liveMode": "browser-user-journey-with-code-agent-post", + "retainedApiFields": [ + "status", + "provider", + "model", + "backend", + "traceId", + "hasReply", + "error.code", + "error.missingEnv" + ], + "statement": "Live smoke opens the deployed workbench and sends one controlled Code Agent message; it does not call hardware write APIs and must not be used to infer M3 DEV-LIVE hardware-loop acceptance." + }, + "artifacts": { + "reportPath": "/workspace/hwlab/.worktrees/deployed-workbench-journey-evidence/reports/dev-gate/dev-cloud-workbench-live.json" + } +} diff --git a/scripts/dev-cloud-workbench-smoke.mjs b/scripts/dev-cloud-workbench-smoke.mjs index 4ca31f9b..3f9e5fdd 100644 --- a/scripts/dev-cloud-workbench-smoke.mjs +++ b/scripts/dev-cloud-workbench-smoke.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -16,6 +18,12 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { : args.mobile ? await runDevCloudWorkbenchMobileSmoke() : await runDevCloudWorkbenchSmoke(process.argv.slice(2)); + if (args.reportPath && report.status !== "usage") { + const reportPath = path.resolve(process.cwd(), args.reportPath); + await mkdir(path.dirname(reportPath), { recursive: true }); + report.artifacts = { ...(report.artifacts ?? {}), reportPath }; + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + } process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); process.exitCode = report.status === "pass" || report.status === "usage" || report.status === "skip" ? 0 : 2; } catch (error) { diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 0ed04fb7..8ac13aff 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -164,6 +164,10 @@ export function parseSmokeArgs(argv) { index += 1; if (!argv[index]) throw new Error("--url requires a value"); args.url = argv[index]; + } else if (arg === "--report") { + index += 1; + if (!argv[index]) throw new Error("--report requires a value"); + args.reportPath = argv[index]; } else if (arg === "--help") { args.help = true; } else { @@ -346,6 +350,22 @@ async function runLiveSmoke(args) { }); } + const journey = http.ok ? await inspectLiveUserJourney(args.url) : { status: "skip", summary: "Browser Code Agent journey skipped because HTTP fetch failed.", evidence: [] }; + addCheck(checks, blockers, "live-code-agent-browser-journey", journey.status, journey.summary, { + blocker: journey.status === "pass" ? null : "runtime_blocker", + evidence: journey.evidence, + observations: journey.observations + }); + + if (journey.status === "skip") { + blockers.push({ + type: "observability_blocker", + scope: "live-code-agent-browser-journey", + status: "open", + summary: journey.summary + }); + } + const status = blockers.length === 0 ? "pass" : "blocked"; return baseReport({ mode: "live", @@ -353,7 +373,7 @@ async function runLiveSmoke(args) { status, checks, blockers, - evidenceLevel: status === "pass" ? "DEV-LIVE-READONLY" : "BLOCKED", + evidenceLevel: status === "pass" ? "DEV-LIVE-BROWSER" : "BLOCKED", devLive: status === "pass", safety: { prodTouched: false, @@ -361,30 +381,95 @@ async function runLiveSmoke(args) { heavyE2E: false, hardwareWriteApis: false, sourceIsDevLive: false, - liveMode: "read-only-http-and-optional-dom", - statement: "Live smoke is read-only and must not be used to infer M3 DEV-LIVE hardware-loop acceptance." + liveMode: "browser-user-journey-with-code-agent-post", + retainedApiFields: ["status", "provider", "model", "backend", "traceId", "hasReply", "error.code", "error.missingEnv"], + statement: "Live smoke opens the deployed workbench and sends one controlled Code Agent message; it does not call hardware write APIs and must not be used to infer M3 DEV-LIVE hardware-loop acceptance." } }); } function baseReport({ mode, status, checks, blockers, evidenceLevel, devLive, help = undefined, safety, url = null }) { return { + $schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", + $id: "https://hwlab.pikastech.local/reports/dev-gate/dev-cloud-workbench-live.json", + reportVersion: "v1", status, + issue: "pikasTech/HWLAB#7", + taskId: "dev-cloud-workbench-live", + commitId: "ab252f5", + acceptanceLevel: "dev_cloud_workbench_live", + devOnly: true, + prodDisabled: true, + reportLifecycle: { + version: "v1", + state: "active", + activeEndpoint: runtime.endpoints.api, + activeBrowserEndpoint: runtime.endpoints.frontend, + deprecatedEndpoint: null, + summary: "Current deployed browser user journey report for the 16666 Cloud Workbench; it does not substitute for M3/M4/M5 hardware acceptance." + }, task: "DC-DCSN-P0-2026-003", refs: [ + "pikasTech/HWLAB#7", "pikasTech/HWLAB#108", "pikasTech/HWLAB#99", "pikasTech/HWLAB#78", "pikasTech/HWLAB#117", "pikasTech/HWLAB#118", "pikasTech/HWLAB#119", - "pikasTech/HWLAB#120" + "pikasTech/HWLAB#120", + "pikasTech/HWLAB#131", + "pikasTech/HWLAB#164" ], mode, url, generatedAt: new Date().toISOString(), evidenceLevel, devLive, + sourceContract: { + status: "pass", + documents: [ + "docs/cloud-web-workbench.md", + "docs/reference/cloud-workbench.md", + "docs/reference/code-agent-chat-readiness.md" + ], + summary: "The deployed browser journey is anchored to the Cloud Workbench and Code Agent readiness contracts." + }, + validationCommands: [ + "node --check scripts/dev-cloud-workbench-smoke.mjs", + "node --check scripts/src/dev-cloud-workbench-smoke-lib.mjs", + "node scripts/dev-cloud-workbench-smoke.mjs --static", + "node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json", + "node scripts/validate-dev-gate-report.mjs reports/dev-gate/dev-cloud-workbench-live.json", + "node scripts/validate-dev-gate-report.mjs" + ], + localSmoke: { + status: mode === "static" ? status : "not_run", + commands: ["node scripts/dev-cloud-workbench-smoke.mjs --static"], + evidence: ["Static mode verifies the source contract and same-origin Code Agent wiring only."], + summary: "Static workbench smoke is SOURCE-level evidence and does not prove the deployed browser journey." + }, + dryRun: { + status: "not_applicable", + commands: ["node scripts/dev-cloud-workbench-smoke.mjs --static"], + evidence: ["No dry-run substitute is accepted for the deployed browser journey."], + summary: "The journey evidence is collected from the real DEV browser endpoint, not a dry-run fixture." + }, + devPreconditions: { + status: status === "pass" ? "pass" : "blocked", + requirements: [ + "GET http://74.48.78.17:16666/ serves the Cloud Workbench HTML.", + "Browser DOM exposes the default workbench and core controls.", + "A controlled same-origin POST /v1/agent/chat returns completed UI-visible evidence or reports a blocker.", + "No hardware write API, service restart, PROD access, or secret read is performed." + ], + commands: [ + "node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json" + ], + summary: status === "pass" + ? "Deployed 16666 browser journey and same-origin Code Agent chat completed." + : "Deployed browser journey is blocked; see checks and blockers." + }, endpoints: { frontend: runtime.endpoints.frontend, api: runtime.endpoints.api, @@ -848,7 +933,19 @@ async function fetchText(url) { } function liveHtmlLooksLikeWorkbench(html) { - return /HWLAB 云工作台/u.test(html) && labelsPresent(html, workbenchMarkers) && !/]*>\s*<(?:main|section)[^>]*(?:gate|diagnostics|status|help)/iu.test(html); + const workspaceTag = firstTagForDataView(html, "workspace"); + const gateTag = firstTagForDataView(html, "gate"); + const helpTag = firstTagForDataView(html, "help"); + return ( + /HWLAB 云工作台/u.test(html) && + labelsPresent(html, workbenchMarkers) && + workspaceTag !== null && + !/\bhidden\b/u.test(workspaceTag) && + gateTag !== null && + /\bhidden\b/u.test(gateTag) && + helpTag !== null && + /\bhidden\b/u.test(helpTag) + ); } async function inspectLiveDom(url) { @@ -871,19 +968,35 @@ async function inspectLiveDom(url) { const dom = await page.evaluate(() => { const workspace = document.querySelector('[data-view="workspace"]'); const gate = document.querySelector('[data-view="gate"]'); - const diagnostics = document.querySelector('[data-side-panel="diagnostics"]'); + const help = document.querySelector('[data-view="help"]'); const bodyStyle = getComputedStyle(document.body); const htmlStyle = getComputedStyle(document.documentElement); const text = document.body.textContent ?? ""; + const visible = (selector) => { + const element = document.querySelector(selector); + if (!element) return false; + const style = getComputedStyle(element); + const box = element.getBoundingClientRect(); + return style.visibility !== "hidden" && style.display !== "none" && box.width > 0 && box.height > 0; + }; return { title: document.title, bodyOverflow: bodyStyle.overflow, htmlOverflow: htmlStyle.overflow, workspaceHidden: workspace ? workspace.hidden : null, gateHidden: gate ? gate.hidden : null, - diagnosticsHidden: diagnostics ? diagnostics.hidden : null, + helpHidden: help ? help.hidden : null, outerScrollLocked: document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2, - labelsPresent: ["硬件资源", "Agent 对话", "工作清单", "可信记录", "使用说明"].every((label) => text.includes(label)) + labelsPresent: ["硬件资源", "Agent 对话", "工作清单", "可信记录", "使用说明"].every((label) => text.includes(label)), + coreControlsVisible: { + commandInput: visible("#command-input"), + commandSend: visible("#command-send"), + agentChatStatus: visible("#agent-chat-status"), + conversationList: visible("#conversation-list"), + hardwareList: visible("#hardware-list"), + wiringTab: visible("#tab-wiring"), + recordsTab: visible("#tab-records") + } }; }); const pass = @@ -892,12 +1005,13 @@ async function inspectLiveDom(url) { dom.htmlOverflow === "hidden" && dom.workspaceHidden === false && dom.gateHidden === true && - dom.diagnosticsHidden === true && + dom.helpHidden === true && dom.outerScrollLocked && - dom.labelsPresent; + dom.labelsPresent && + Object.values(dom.coreControlsVisible).every(Boolean); return { status: pass ? "pass" : "blocked", - summary: pass ? "Browser DOM confirms the default workbench and outer scroll lock." : "Browser DOM did not satisfy the workbench contract.", + summary: pass ? "Browser DOM confirms the default workbench, core controls, and outer scroll lock." : "Browser DOM did not satisfy the workbench contract.", evidence: [`title=${dom.title}`, `bodyOverflow=${dom.bodyOverflow}`, `htmlOverflow=${dom.htmlOverflow}`], observations: dom }; @@ -912,6 +1026,171 @@ async function inspectLiveDom(url) { } } +async function inspectLiveUserJourney(url) { + let chromium; + try { + ({ chromium } = await import("playwright")); + } catch (error) { + return { + status: "skip", + summary: `Browser Code Agent journey skipped because Playwright is unavailable: ${error.message}`, + evidence: ["playwright import failed"] + }; + } + + let browser; + try { + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1366, height: 768 } }); + const agentResponses = []; + page.on("response", async (response) => { + if (!response.url().includes("/v1/agent/chat")) return; + let body = null; + try { + body = await response.json(); + } catch { + body = null; + } + agentResponses.push({ + method: response.request().method(), + status: response.status(), + urlPath: new URL(response.url()).pathname, + body: sanitizeAgentChatBody(body) + }); + }); + + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 12000 }); + await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 }); + const controls = await inspectJourneyControls(page); + if (!Object.values(controls).every(Boolean)) { + return { + status: "blocked", + summary: "Browser could not reliably locate deployed workbench controls for the Code Agent journey.", + evidence: ["selectors=#command-input,#command-send,#agent-chat-status,#conversation-list,#hardware-list"], + observations: { controls } + }; + } + + await page.locator("#command-input").fill("请用一句话确认 HWLAB 工作台 Code Agent 浏览器链路可用。"); + const responsePromise = page.waitForResponse( + (response) => response.url().includes("/v1/agent/chat") && response.request().method() === "POST", + { timeout: 35000 } + ); + await page.locator("#command-send").click(); + const response = await responsePromise; + let responseBody = null; + try { + responseBody = await response.json(); + } catch { + responseBody = null; + } + await page.waitForTimeout(500); + const ui = await inspectJourneyUi(page); + const responseSummary = sanitizeAgentChatBody(responseBody); + const backendCompleted = + response.ok() && + responseSummary?.status === "completed" && + Boolean(responseSummary.provider) && + Boolean(responseSummary.model) && + Boolean(responseSummary.traceId); + const uiCompleted = ui.agentChatStatus === "已回复" || ui.completedMessageVisible; + const pass = backendCompleted && uiCompleted && !ui.failedMessageVisible; + + return { + status: pass ? "pass" : "blocked", + summary: pass + ? "Deployed browser journey opened the workbench, sent a Code Agent message, and observed a completed UI response." + : "Deployed browser journey did not observe a completed non-error Code Agent response.", + evidence: [ + `POST /v1/agent/chat HTTP ${response.status()}`, + `status=${responseSummary?.status ?? "unknown"}`, + `provider=${responseSummary?.provider ?? "unknown"}`, + `model=${responseSummary?.model ?? "unknown"}`, + `traceId=${responseSummary?.traceId ?? "unknown"}`, + `uiStatus=${ui.agentChatStatus ?? "unknown"}` + ], + observations: { + controls, + request: { + method: response.request().method(), + status: response.status(), + urlPath: new URL(response.url()).pathname + }, + response: responseSummary, + ui, + networkEvents: agentResponses + } + }; + } catch (error) { + return { + status: "blocked", + summary: `Browser Code Agent journey failed: ${error.message}`, + evidence: ["browser navigation, selector, or POST failed"] + }; + } finally { + if (browser) await browser.close(); + } +} + +async function inspectJourneyControls(page) { + return page.evaluate(() => { + const visible = (selector) => { + const element = document.querySelector(selector); + if (!element) return false; + const style = getComputedStyle(element); + const box = element.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && box.width > 0 && box.height > 0; + }; + return { + appShell: visible("[data-app-shell]"), + workspace: document.querySelector('[data-view="workspace"]')?.hidden === false, + commandInput: visible("#command-input"), + commandSend: visible("#command-send"), + agentChatStatus: visible("#agent-chat-status"), + conversationList: visible("#conversation-list"), + hardwareList: visible("#hardware-list"), + wiringTab: visible("#tab-wiring"), + recordsTab: visible("#tab-records") + }; + }); +} + +async function inspectJourneyUi(page) { + return page.evaluate(() => ({ + title: document.title, + workspaceHidden: document.querySelector('[data-view="workspace"]')?.hidden ?? null, + gateHidden: document.querySelector('[data-view="gate"]')?.hidden ?? null, + helpHidden: document.querySelector('[data-view="help"]')?.hidden ?? null, + agentChatStatus: document.querySelector("#agent-chat-status")?.textContent?.trim() ?? null, + inputCleared: document.querySelector("#command-input")?.value === "", + completedMessageVisible: Boolean(document.querySelector(".message-card.status-completed")), + failedMessageVisible: Boolean(document.querySelector(".message-card.status-failed")), + messageCount: document.querySelectorAll(".message-card").length, + completedMessageHasNonSensitiveMeta: Boolean( + [...document.querySelectorAll(".message-card.status-completed .message-meta")] + .some((element) => /openai-responses|gpt-5\.5|trc_/u.test(element.textContent ?? "")) + ) + })); +} + +function sanitizeAgentChatBody(body) { + if (!body || typeof body !== "object") return null; + return { + status: typeof body.status === "string" ? body.status : null, + provider: typeof body.provider === "string" ? body.provider : null, + model: typeof body.model === "string" ? body.model : null, + backend: typeof body.backend === "string" ? body.backend : null, + traceId: typeof body.traceId === "string" ? body.traceId : null, + hasReply: Boolean(body.reply?.content), + error: body.error && typeof body.error === "object" + ? { + code: typeof body.error.code === "string" ? body.error.code : null, + missingEnv: Array.isArray(body.error.missingEnv) ? body.error.missingEnv.filter((item) => typeof item === "string") : [] + } + : null + }; +} + export async function runDevCloudWorkbenchMobileSmoke() { let chromium; try { @@ -1204,11 +1483,11 @@ function escapeRegExp(value) { export function printSmokeHelp() { return { status: "usage", - command: "node scripts/dev-cloud-workbench-smoke.mjs --static | --live --url http://74.48.78.17:16666/", + command: "node scripts/dev-cloud-workbench-smoke.mjs --static | --mobile | --live --url http://74.48.78.17:16666/ [--report reports/dev-gate/dev-cloud-workbench-live.json]", notes: [ "Static mode reads repository files and emits SOURCE-level evidence only.", "--mobile runs a local static 390x844 browser hit-test and still emits SOURCE-level evidence only.", - "Live mode is read-only and reports blocked/skip when optional browser DOM evidence is unavailable." + "Live mode opens the deployed workbench in a browser, verifies core controls, sends one Code Agent message, and retains only non-sensitive response fields." ] }; } diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index 1b86e14a..a6c57cc3 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -29,7 +29,8 @@ const issueFamily = Object.freeze({ DEV_M5_GATE_REPORT: "pikasTech/HWLAB#39", DEV_BASE_IMAGE_PREFLIGHT: "pikasTech/HWLAB#40", DEV_EVIDENCE_BLOCKER_AGGREGATOR: "pikasTech/HWLAB#41", - DEV_M5_GATE_AGGREGATOR_V2: "pikasTech/HWLAB#58" + DEV_M5_GATE_AGGREGATOR_V2: "pikasTech/HWLAB#58", + DEV_CLOUD_WORKBENCH_LIVE: "pikasTech/HWLAB#7" }); const allowedIssues = new Set([ contractIssue, @@ -115,6 +116,18 @@ const requiredDevM2Docs = [ "docs/dev-acceptance-matrix.md", "fixtures/dev-deploy-smoke/dev-deploy-smoke.json" ]; +const requiredDevCloudWorkbenchValidationCommands = [ + "node --check scripts/dev-cloud-workbench-smoke.mjs", + "node --check scripts/src/dev-cloud-workbench-smoke-lib.mjs", + "node scripts/dev-cloud-workbench-smoke.mjs --static", + "node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json", + "node scripts/validate-dev-gate-report.mjs reports/dev-gate/dev-cloud-workbench-live.json" +]; +const requiredDevCloudWorkbenchDocs = [ + "docs/cloud-web-workbench.md", + "docs/reference/cloud-workbench.md", + "docs/reference/code-agent-chat-readiness.md" +]; const reportFamilyTemplates = new Map([ [ "dev-gate-report-contract", @@ -456,6 +469,13 @@ async function validateReport(relativePath) { await validateDevM2Report(report, label); return; } + if ( + report.issue === issueFamily.DEV_CLOUD_WORKBENCH_LIVE || + report.taskId === "dev-cloud-workbench-live" + ) { + await validateDevCloudWorkbenchLiveReport(report, label); + return; + } const template = reportFamilyTemplates.get(report.taskId); assert.ok( @@ -1274,6 +1294,167 @@ async function validateDevM2Report(report, label) { ); } +async function validateDevCloudWorkbenchLiveReport(report, label) { + for (const field of [ + "$schema", + "$id", + "reportVersion", + "issue", + "taskId", + "commitId", + "acceptanceLevel", + "devOnly", + "prodDisabled", + "status", + "generatedAt", + "mode", + "url", + "evidenceLevel", + "devLive", + "sourceContract", + "validationCommands", + "localSmoke", + "dryRun", + "devPreconditions", + "checks", + "blockers", + "safety" + ]) { + assert.ok(Object.hasOwn(report, field), `${label} missing ${field}`); + } + + assertString(report.$schema, `${label}.$schema`); + assertString(report.$id, `${label}.$id`); + assert.equal(report.reportVersion, "v1", `${label}.reportVersion`); + assert.equal(report.issue, issueFamily.DEV_CLOUD_WORKBENCH_LIVE, `${label}.issue`); + assert.equal(report.taskId, "dev-cloud-workbench-live", `${label}.taskId`); + assert.match(report.commitId, /^([a-f0-9]{7,40}|unknown)$/, `${label}.commitId`); + assert.equal(report.acceptanceLevel, "dev_cloud_workbench_live", `${label}.acceptanceLevel`); + assert.equal(report.devOnly, true, `${label}.devOnly`); + assert.equal(report.prodDisabled, true, `${label}.prodDisabled`); + assertStatus(report.status, `${label}.status`); + assert.equal(report.status, report.blockers.length === 0 ? "pass" : "blocked", `${label}.status must match blockers`); + assertTimestamp(report.generatedAt, `${label}.generatedAt`); + assert.equal(report.mode, "live", `${label}.mode`); + assert.equal(report.url, "http://74.48.78.17:16666/", `${label}.url`); + assert.equal(report.evidenceLevel, report.status === "pass" ? "DEV-LIVE-BROWSER" : "BLOCKED", `${label}.evidenceLevel`); + assert.equal(report.devLive, report.status === "pass", `${label}.devLive`); + + assertObject(report.sourceContract, `${label}.sourceContract`); + assertStatus(report.sourceContract.status, `${label}.sourceContract.status`); + await assertDocumentSet( + report.sourceContract.documents, + `${label}.sourceContract.documents`, + requiredDevCloudWorkbenchDocs + ); + assertString(report.sourceContract.summary, `${label}.sourceContract.summary`); + + assertStringArray(report.validationCommands, `${label}.validationCommands`, { + minLength: requiredDevCloudWorkbenchValidationCommands.length + }); + assertUnique(report.validationCommands, `${label}.validationCommands`); + for (const requiredCommand of requiredDevCloudWorkbenchValidationCommands) { + assert.ok( + report.validationCommands.includes(requiredCommand), + `${label}.validationCommands missing ${requiredCommand}` + ); + } + + for (const section of ["localSmoke", "dryRun", "devPreconditions"]) { + assertObject(report[section], `${label}.${section}`); + assertStatus(report[section].status, `${label}.${section}.status`); + assertStringArray(report[section].commands, `${label}.${section}.commands`, { minLength: 1 }); + assertStringArray(report[section].evidence ?? [], `${label}.${section}.evidence`, { minLength: section === "devPreconditions" ? 0 : 1 }); + assertString(report[section].summary, `${label}.${section}.summary`); + } + assertStringArray(report.devPreconditions.requirements, `${label}.devPreconditions.requirements`, { + minLength: 4 + }); + + assertArray(report.checks, `${label}.checks`); + assert.ok(report.checks.length >= 3, `${label}.checks must include HTTP, DOM, and Code Agent journey checks`); + assertUnique(report.checks.map((check) => check.id), `${label}.checks ids`); + const checks = new Map(report.checks.map((check, index) => { + const checkLabel = `${label}.checks[${index}]`; + assertObject(check, checkLabel); + for (const field of ["id", "status", "summary"]) { + assert.ok(Object.hasOwn(check, field), `${checkLabel} missing ${field}`); + } + assertString(check.id, `${checkLabel}.id`); + assertStatus(check.status, `${checkLabel}.status`); + assertString(check.summary, `${checkLabel}.summary`); + if (Object.hasOwn(check, "evidence")) { + assertStringArray(check.evidence, `${checkLabel}.evidence`, { minLength: 1 }); + } + if (Object.hasOwn(check, "observations")) { + assertObject(check.observations, `${checkLabel}.observations`); + } + return [check.id, check]; + })); + for (const requiredCheck of [ + "live-http-html", + "live-browser-dom", + "live-code-agent-browser-journey" + ]) { + assert.ok(checks.has(requiredCheck), `${label}.checks missing ${requiredCheck}`); + } + + const httpCheck = checks.get("live-http-html"); + assert.equal(httpCheck.status, "pass", `${label}.live-http-html.status`); + assert.ok(httpCheck.evidence.includes("HTTP 200"), `${label}.live-http-html.evidence missing HTTP 200`); + assert.ok(httpCheck.evidence.includes("text/html; charset=utf-8"), `${label}.live-http-html.evidence missing content-type`); + + const dom = checks.get("live-browser-dom").observations; + assertObject(dom, `${label}.live-browser-dom.observations`); + assert.equal(dom.title, "HWLAB 云工作台", `${label}.live-browser-dom.title`); + assert.equal(dom.workspaceHidden, false, `${label}.live-browser-dom.workspaceHidden`); + assert.equal(dom.gateHidden, true, `${label}.live-browser-dom.gateHidden`); + assert.equal(dom.helpHidden, true, `${label}.live-browser-dom.helpHidden`); + assertObject(dom.coreControlsVisible, `${label}.live-browser-dom.coreControlsVisible`); + for (const control of ["commandInput", "commandSend", "agentChatStatus", "conversationList", "hardwareList"]) { + assert.equal(dom.coreControlsVisible[control], true, `${label}.live-browser-dom.coreControlsVisible.${control}`); + } + + const journey = checks.get("live-code-agent-browser-journey"); + assert.equal(journey.status, report.status, `${label}.live-code-agent-browser-journey.status`); + assertObject(journey.observations, `${label}.live-code-agent-browser-journey.observations`); + assertObject(journey.observations.request, `${label}.live-code-agent-browser-journey.request`); + assert.equal(journey.observations.request.method, "POST", `${label}.live-code-agent-browser-journey.request.method`); + assert.equal(journey.observations.request.urlPath, "/v1/agent/chat", `${label}.live-code-agent-browser-journey.request.urlPath`); + assertObject(journey.observations.response, `${label}.live-code-agent-browser-journey.response`); + assert.equal(journey.observations.response.status, "completed", `${label}.live-code-agent-browser-journey.response.status`); + for (const field of ["provider", "model", "backend", "traceId"]) { + assertString(journey.observations.response[field], `${label}.live-code-agent-browser-journey.response.${field}`); + } + assert.equal(journey.observations.response.hasReply, true, `${label}.live-code-agent-browser-journey.response.hasReply`); + assert.equal(journey.observations.response.error, null, `${label}.live-code-agent-browser-journey.response.error`); + for (const forbiddenField of ["conversationId", "sessionId", "messageId", "reply"]) { + assert.equal( + Object.hasOwn(journey.observations.response, forbiddenField), + false, + `${label}.live-code-agent-browser-journey.response must not retain ${forbiddenField}` + ); + } + assertObject(journey.observations.ui, `${label}.live-code-agent-browser-journey.ui`); + assert.equal(journey.observations.ui.agentChatStatus, "已回复", `${label}.live-code-agent-browser-journey.ui.agentChatStatus`); + assert.equal(journey.observations.ui.completedMessageVisible, true, `${label}.live-code-agent-browser-journey.ui.completedMessageVisible`); + assert.equal(journey.observations.ui.failedMessageVisible, false, `${label}.live-code-agent-browser-journey.ui.failedMessageVisible`); + + assertBlockers(report.blockers, `${label}.blockers`); + + assertObject(report.safety, `${label}.safety`); + for (const field of ["prodTouched", "servicesRestarted", "heavyE2E", "hardwareWriteApis", "sourceIsDevLive"]) { + assert.equal(report.safety[field], false, `${label}.safety.${field}`); + } + assert.equal(report.safety.liveMode, "browser-user-journey-with-code-agent-post", `${label}.safety.liveMode`); + assert.deepEqual( + report.safety.retainedApiFields, + ["status", "provider", "model", "backend", "traceId", "hasReply", "error.code", "error.missingEnv"], + `${label}.safety.retainedApiFields` + ); + assertString(report.safety.statement, `${label}.safety.statement`); +} + async function validateDevM3Report(report, label) { for (const field of [ "$schema",