diff --git a/docs/dev-acceptance-matrix.md b/docs/dev-acceptance-matrix.md index 41481627..96e494d3 100644 --- a/docs/dev-acceptance-matrix.md +++ b/docs/dev-acceptance-matrix.md @@ -94,15 +94,20 @@ listed above. Cloud Web workbench smoke is covered by: - `node scripts/dev-cloud-workbench-smoke.mjs --static` +- `node scripts/dev-cloud-workbench-smoke.mjs --dom-only --url http://74.48.78.17:16666/` - `node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/` The static mode is SOURCE-level contract evidence only and must report `devLive=false`; it cannot promote SOURCE, LOCAL, DRY-RUN, or fixture evidence to DEV-LIVE. It observes the PR #114 Markdown help surface as ready when `web/hwlab-cloud-web/help.md`, the vendored `marked` renderer, and the -non-default internal help route are present. The live mode is optional -read-only HTTP plus browser DOM observation and must report `blocked` or `skip` -structure instead of a false green when the browser check is unavailable. +non-default internal help route are present. The `--dom-only` mode is a +live-read-only DOM validation path: it preserves runtime/web-asset identity +preflight semantics, may still inspect deployed DOM wiring after identity drift, +and records Code Agent as `not_applicable` without sending `/v1/agent/chat`. +The live mode is optional read-only HTTP plus browser DOM observation and must +report `blocked` or `skip` structure instead of a false green when the browser +check is unavailable. | Step | Probe | Success Criteria | Failure Criteria | Blocker Class | | --- | --- | --- | --- | --- | diff --git a/docs/reference/cloud-workbench.md b/docs/reference/cloud-workbench.md index 443e8ffe..273eccce 100644 --- a/docs/reference/cloud-workbench.md +++ b/docs/reference/cloud-workbench.md @@ -78,6 +78,7 @@ heavier browser run: node web/hwlab-cloud-web/scripts/check.mjs node web/hwlab-cloud-web/scripts/build.mjs node scripts/dev-cloud-workbench-smoke.mjs --static +node scripts/dev-cloud-workbench-smoke.mjs --dom-only --url http://74.48.78.17:16666/ ``` The static smoke checks both sides of the route contract: `/` remains the @@ -89,6 +90,13 @@ Live checks against `http://74.48.78.17:16666/` are read-only and must report blocked/skip instead of false green when the browser or network path is not available. +`--dom-only` keeps the runtime and web-asset identity preflight checks, then +continues with read-only DOM/help validation for the deployed workbench even +when identity drift blocks the full journey. It never sends `POST +/v1/agent/chat`; the Code Agent journey is recorded as `not_applicable` and +must not be used as a DEV-LIVE reply, Secret, mutation, or M3/M4/M5 acceptance +claim. + ## Stable Sources - [pikasTech/HWLAB#99](https://github.com/pikasTech/HWLAB/issues/99): default diff --git a/docs/reference/code-agent-chat-readiness.md b/docs/reference/code-agent-chat-readiness.md index b3b5f8d4..9774adb8 100644 --- a/docs/reference/code-agent-chat-readiness.md +++ b/docs/reference/code-agent-chat-readiness.md @@ -60,10 +60,13 @@ Workbench 静态接线检查: ```sh node scripts/dev-cloud-workbench-smoke.mjs --static +node scripts/dev-cloud-workbench-smoke.mjs --dom-only --url http://74.48.78.17:16666/ ``` 该命令验证 Workbench 默认页、同源只读边界和 `/v1/agent/chat` 前端主流程接线。它只产出 -`SOURCE` 级证据。 +`SOURCE` 级证据。`--dom-only` 会保留部署 runtime/web-asset identity preflight, +但只做真实 DEV DOM/help 只读观察;它不会发送 `/v1/agent/chat`,Code Agent journey +必须记录为 `not_applicable`,不能冒充真实 DEV-LIVE reply。 授权凭证注入后的真实 DEV 复测: diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index f4263359..ae0b7c63 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -147,6 +147,13 @@ test("workbench smoke CLI allows live evidence to write the live report", () => assert.equal(smokeCliExitCode(report, decision), 0); }); +test("smoke args include a DOM-only live read-only mode", () => { + assert.deepEqual(parseSmokeArgs(["--dom-only", "--url", "http://74.48.78.17:16666/"]), { + mode: "dom-only", + url: "http://74.48.78.17:16666/" + }); +}); + test("live workbench identity passes only when runtime commit or image tag matches current source", () => { assert.equal( classifyLiveDeploymentIdentity(sourceIdentity, { diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 8706f280..c8dbe025 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -219,6 +219,7 @@ export async function runDevCloudWorkbenchSmoke(argv = []) { const args = parseSmokeArgs(argv); if (args.mode === "live") return runLiveSmoke(args); if (args.mode === "local-agent-timeout-fixture") return runDevCloudWorkbenchTimeoutFixtureSmoke(); + if (args.mode === "dom-only") return runLiveDomOnlySmoke(args); if (args.mode === "local-agent-fixture") return runDevCloudWorkbenchLocalAgentFixtureSmoke(); if (args.mode === "mobile") return runDevCloudWorkbenchMobileSmoke(); return runStaticSmoke(); @@ -268,6 +269,8 @@ export function parseSmokeArgs(argv) { args.mode = "live"; } else if (arg === "--confirm-dev-live") { args.confirmDevLive = true; + } else if (arg === "--dom-only") { + args.mode = "dom-only"; } else if (arg === "--mobile") { args.mode = "mobile"; args.mobile = true; @@ -670,6 +673,162 @@ async function runLiveSmoke(args) { }); } +async function runLiveDomOnlySmoke(args) { + const checks = []; + const blockers = []; + const sourceIdentity = observeSourceIdentity(); + const runtimeIdentity = await observeLiveRuntimeIdentity(runtime.endpoints.api); + const apiRuntimeReadiness = classifyLiveApiRuntimeReadiness(runtimeIdentity); + addCheck(checks, blockers, "live-api-runtime-readiness", apiRuntimeReadiness.status, apiRuntimeReadiness.summary, { + blocker: "runtime_blocker", + evidence: apiRuntimeReadiness.evidence, + observations: apiRuntimeReadiness + }); + + const expectedRuntimeIdentity = observeExpectedRuntimeIdentity("hwlab-cloud-api"); + const deploymentIdentity = classifyLiveDeploymentIdentity(sourceIdentity, runtimeIdentity, expectedRuntimeIdentity); + addCheck(checks, blockers, "live-runtime-current-main", deploymentIdentity.status, deploymentIdentity.summary, { + blocker: "observability_blocker", + evidence: deploymentIdentity.evidence, + observations: deploymentIdentity + }); + + const http = await fetchText(args.url); + addCheck(checks, blockers, "live-http-html", http.ok && http.status === 200 && /text\/html/u.test(http.contentType) && liveHtmlLooksLikeWorkbench(http.body), "Live URL serves the Cloud Workbench HTML.", { + blocker: "runtime_blocker", + evidence: [args.url, `HTTP ${http.status ?? "none"}`, http.contentType ?? "no content-type"] + }); + + const webAssetIdentity = http.ok + ? await inspectLiveWebAssetIdentity(args.url, http.body) + : { + status: "blocked", + summary: "Live web asset identity could not be checked because the 16666 HTML fetch failed.", + assets: [], + mismatches: ["index.html"] + }; + addCheck(checks, blockers, "live-web-assets-current-main", webAssetIdentity.status, webAssetIdentity.summary, { + blocker: "observability_blocker", + evidence: webAssetIdentity.assets.length > 0 + ? webAssetIdentity.assets.map((asset) => `${asset.path}: ${asset.status}`) + : ["web asset identity not observed"], + observations: webAssetIdentity + }); + + const dom = http.ok ? await inspectLiveDom(args.url, { noCodeAgentPost: true }) : { status: "skip", summary: "Browser DOM check skipped because HTTP fetch failed.", evidence: [] }; + addCheck(checks, blockers, "live-browser-dom", dom.status, dom.summary, { + blocker: dom.status === "pass" ? null : "observability_blocker", + evidence: dom.evidence, + observations: dom.observations + }); + + if (dom.status === "skip") { + blockers.push({ + type: "observability_blocker", + scope: "live-browser-dom", + status: "open", + summary: dom.summary + }); + } + + const gate = http.ok ? await inspectLiveGateRoute(args.url, { noCodeAgentPost: true }) : { status: "skip", summary: "Browser Gate route check skipped because HTTP fetch failed.", evidence: [] }; + addCheck(checks, blockers, "live-gate-route", gate.status, gate.summary, { + blocker: gate.status === "pass" ? null : "runtime_blocker", + evidence: gate.evidence, + observations: gate.observations + }); + + if (gate.status === "skip") { + blockers.push({ + type: "observability_blocker", + scope: "live-gate-route", + status: "open", + summary: gate.summary + }); + } + + const help = http.ok ? await inspectLiveHelpRoute(args.url, { noCodeAgentPost: true }) : { status: "skip", summary: "Browser Markdown help check skipped because HTTP fetch failed.", evidence: [] }; + addCheck(checks, blockers, "live-help-markdown-route", help.status, help.summary, { + blocker: help.status === "pass" ? null : "runtime_blocker", + evidence: help.evidence, + observations: help.observations + }); + + if (help.status === "skip") { + blockers.push({ + type: "observability_blocker", + scope: "live-help-markdown-route", + status: "open", + summary: help.summary + }); + } + + addDomOnlyCodeAgentCheck(checks); + + const status = blockers.length === 0 ? "pass" : "blocked"; + return baseReport({ + mode: "dom-only", + url: args.url, + status, + checks, + blockers, + evidenceLevel: status === "pass" ? "DEV-LIVE-DOM-READONLY" : "BLOCKED", + devLive: false, + runtimeIdentity, + sourceIdentity, + deploymentIdentity, + expectedRuntimeIdentity, + webAssetIdentity, + safety: { + prodTouched: false, + servicesRestarted: false, + heavyE2E: false, + hardwareWriteApis: false, + sourceIsDevLive: false, + liveMode: "dom-only-readonly-no-code-agent-post", + codeAgentBrowserJourneySkipped: true, + codeAgentPostSent: false, + retainedApiFields: ["runtime.commitId", "runtime.imageTag", "web asset hash/status", "DOM route/control/wiring observations"], + statement: "DOM-only live smoke preserves runtime and web-asset identity preflight semantics, continues with read-only browser DOM/help inspection even when identity is blocked, and never posts /v1/agent/chat, reads Secret values, mutates live DEV, or claims M3/M4/M5 acceptance." + } + }); +} + +function addDomOnlyCodeAgentCheck(checks) { + checks.push({ + id: "live-code-agent-browser-journey", + status: "not_applicable", + summary: "DOM-only mode never posts the browser Code Agent journey; /v1/agent/chat is not sent.", + evidence: [ + "POST /v1/agent/chat not_applicable", + "POST /v1/agent/chat not sent", + "DOM-only read-only mode" + ], + observations: { + request: { + method: "POST", + status: "not_applicable", + urlPath: "/v1/agent/chat" + }, + response: { + status: "not_applicable", + provider: "not_observed", + model: "not_observed", + backend: "not_observed", + traceId: "not_observed", + hasReply: false, + error: null + }, + classification: { + status: "not_applicable", + reason: "dom_only_no_code_agent_post", + codeAgentBrowserJourneySkipped: true + }, + networkEvents: [] + } + }); +} + function baseReport({ mode, status, @@ -755,8 +914,11 @@ function baseReport({ "node scripts/dev-cloud-workbench-smoke.mjs --mobile", "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-fixture", "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-timeout-fixture", + "node scripts/dev-cloud-workbench-smoke.mjs --dom-only --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-dom-only.json", "node scripts/dev-cloud-workbench-smoke.mjs --live --confirm-dev-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", + mode === "dom-only" + ? "node scripts/validate-dev-gate-report.mjs reports/dev-gate/dev-cloud-workbench-dom-only.json" + : "node scripts/validate-dev-gate-report.mjs reports/dev-gate/dev-cloud-workbench-live.json", "node scripts/validate-dev-gate-report.mjs" ], localSmoke: { @@ -781,16 +943,20 @@ function baseReport({ requirements: [ "GET http://74.48.78.17:16666/ serves the Cloud Workbench HTML.", "Browser DOM exposes the default workbench and core controls.", + "DOM-only mode may inspect read-only deployed DOM wiring after identity preflight blocks, but must not send Code Agent chat.", "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 --dom-only --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-dom-only.json", "node scripts/dev-cloud-workbench-smoke.mjs --live --confirm-dev-live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json" ], summary: liveJourneyPassed ? "Deployed 16666 browser journey and same-origin Code Agent chat completed." : liveJourneyDegraded ? "Deployed UI usable in degraded/read-only mode; API health/runtime durability is degraded, so this is not full DEV-LIVE acceptance." + : mode === "dom-only" + ? "Deployed browser DOM was inspected read-only; Code Agent chat was not posted." : mode === "live" ? "Deployed browser journey is blocked; see checks and blockers." : "Static/local fixture evidence does not establish the deployed 16666 browser journey." @@ -1886,7 +2052,7 @@ function liveHtmlLooksLikeWorkbench(html) { ); } -async function inspectLiveDom(url) { +async function inspectLiveDom(url, options = {}) { let chromium; try { ({ chromium } = await importPlaywright()); @@ -1902,6 +2068,7 @@ async function inspectLiveDom(url) { try { browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 1366, height: 768 } }); + const postGuard = options.noCodeAgentPost ? await installNoCodeAgentPostGuard(page) : null; await page.goto(url, { waitUntil: "domcontentloaded", timeout: 8000 }); const dom = await page.evaluate(async (forbiddenTerms) => { const workspace = document.querySelector('[data-view="workspace"]'); @@ -1964,6 +2131,15 @@ async function inspectLiveDom(url) { firstViewportForbiddenPresent: forbiddenTerms.filter((term) => textHasForbiddenTerm(firstViewportText, term)), firstViewportTextSample: firstViewportText.slice(0, 400), labelsPresent: ["硬件资源", "Agent 对话", "工作清单", "可信记录", "使用说明"].every((label) => text.includes(label)), + navLabelsPresent: ["工作台", "内部复核", "使用说明", "资源树"].every((label) => + [...document.querySelectorAll(".activity-rail button")].some((button) => button.textContent?.trim() === label) + ), + defaultBackstageHidden: !/(Gate|诊断|验收|BLOCKED|M0-M5|执行轨迹)/u.test(document.querySelector(".center-workspace")?.innerText ?? ""), + gateRouteAvailable: [...document.querySelectorAll("[data-route='gate']")].some((button) => button.textContent?.trim() === "内部复核"), + wiringHeaders: [...document.querySelectorAll(".wiring-table thead th")].map((cell) => cell.textContent?.trim() ?? ""), + m3WiringRows: [...document.querySelectorAll("#wiring-body tr")].map((row) => + [...row.querySelectorAll("td")].map((cell) => cell.textContent?.replace(/\s+/gu, " ").trim() ?? "") + ), coreControlsVisible: { commandInput: visible("#command-input"), commandSend: visible("#command-send"), @@ -1989,6 +2165,18 @@ async function inspectLiveDom(url) { dom.rootAfterScrollAttempt.bodyScrollTop === 0 && dom.firstViewportForbiddenPresent.length === 0 && dom.labelsPresent && + dom.navLabelsPresent && + dom.defaultBackstageHidden && + dom.gateRouteAvailable && + requiredWiringColumns.every((column) => dom.wiringHeaders.includes(column)) && + dom.m3WiringRows.some((row) => + row.includes("res_boxsimu_1") && + row.includes("DO1") && + row.includes("hwlab-patch-panel") && + row.includes("res_boxsimu_2") && + row.includes("DI1") + ) && + (postGuard ? postGuard.attempts.length === 0 : true) && Object.values(dom.coreControlsVisible).every(Boolean); return { status: pass ? "pass" : "blocked", @@ -1997,9 +2185,15 @@ async function inspectLiveDom(url) { `title=${dom.title}`, `bodyOverflow=${dom.bodyOverflow}`, `htmlOverflow=${dom.htmlOverflow}`, - `firstViewportForbidden=${dom.firstViewportForbiddenPresent.join(",") || "none"}` + `firstViewportForbidden=${dom.firstViewportForbiddenPresent.join(",") || "none"}`, + `gateRouteAvailable=${dom.gateRouteAvailable}`, + `m3WiringRows=${dom.m3WiringRows.length}`, + ...(postGuard ? [`codeAgentPostAttempts=${postGuard.attempts.length}`] : []) ], - observations: dom + observations: { + ...dom, + ...(postGuard ? { codeAgentPostGuard: postGuard.observation() } : {}) + } }; } catch (error) { return { @@ -2012,7 +2206,7 @@ async function inspectLiveDom(url) { } } -async function inspectLiveHelpRoute(url) { +async function inspectLiveHelpRoute(url, options = {}) { let chromium; try { ({ chromium } = await importPlaywright()); @@ -2028,11 +2222,12 @@ async function inspectLiveHelpRoute(url) { try { browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 1366, height: 768 } }); + const postGuard = options.noCodeAgentPost ? await installNoCodeAgentPostGuard(page) : null; await page.goto(url, { waitUntil: "domcontentloaded", timeout: 12000 }); await page.locator('[data-route="help"]').click(); await page.waitForFunction(() => document.querySelector("#help-content")?.dataset.helpState === "ready", null, { timeout: 8000 }); const help = await inspectHelpMarkdownRoute(page); - const pass = help.nonDefaultMarkdownHelp && help.termsPresent; + const pass = help.nonDefaultMarkdownHelp && help.termsPresent && (postGuard ? postGuard.attempts.length === 0 : true); return { status: pass ? "pass" : "blocked", summary: pass @@ -2042,9 +2237,13 @@ async function inspectLiveHelpRoute(url) { `hash=${help.hash ?? "unknown"}`, `heading=${help.heading ?? "unknown"}`, `rootStillLocked=${help.rootStillLocked}`, - `helpPanelScrolls=${help.helpPanelScrolls}` + `helpPanelScrolls=${help.helpPanelScrolls}`, + ...(postGuard ? [`codeAgentPostAttempts=${postGuard.attempts.length}`] : []) ], - observations: help + observations: { + ...help, + ...(postGuard ? { codeAgentPostGuard: postGuard.observation() } : {}) + } }; } catch (error) { return { @@ -2057,6 +2256,97 @@ async function inspectLiveHelpRoute(url) { } } +async function inspectLiveGateRoute(url, options = {}) { + let chromium; + try { + ({ chromium } = await importPlaywright()); + } catch (error) { + return { + status: "skip", + summary: `Browser Gate route check 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 postGuard = options.noCodeAgentPost ? await installNoCodeAgentPostGuard(page) : null; + await page.goto(new URL("/gate", url).toString(), { waitUntil: "domcontentloaded", timeout: 12000 }); + const gate = await page.evaluate(() => { + const workspace = document.querySelector('[data-view="workspace"]'); + const gateView = document.querySelector('[data-view="gate"]'); + const help = document.querySelector('[data-view="help"]'); + const text = document.body.textContent ?? ""; + return { + pathname: window.location.pathname, + hash: window.location.hash, + title: document.title, + workspaceHidden: workspace ? workspace.hidden : null, + gateHidden: gateView ? gateView.hidden : null, + helpHidden: help ? help.hidden : null, + diagnosticsTermsPresent: ["Gate / 诊断 / 验收", "M0-M5", "BLOCKED", "DB live readiness", "patch-panel DO1 -> DI1"].every((term) => text.includes(term)) + }; + }); + const pass = + gate.pathname === "/gate" && + gate.title === "HWLAB 云工作台" && + gate.workspaceHidden === true && + gate.gateHidden === false && + (gate.helpHidden === true || gate.helpHidden === null) && + gate.diagnosticsTermsPresent && + (postGuard ? postGuard.attempts.length === 0 : true); + return { + status: pass ? "pass" : "blocked", + summary: pass + ? "Direct /gate route opens the internal diagnostics view without becoming the default workbench." + : "Direct /gate route did not satisfy the internal diagnostics route contract.", + evidence: [ + `pathname=${gate.pathname}`, + `gateHidden=${gate.gateHidden}`, + `diagnosticsTermsPresent=${gate.diagnosticsTermsPresent}`, + ...(postGuard ? [`codeAgentPostAttempts=${postGuard.attempts.length}`] : []) + ], + observations: { + ...gate, + ...(postGuard ? { codeAgentPostGuard: postGuard.observation() } : {}) + } + }; + } catch (error) { + return { + status: "blocked", + summary: `Browser Gate route failed: ${error.message}`, + evidence: ["browser navigation or route inspection failed"] + }; + } finally { + if (browser) await browser.close(); + } +} + +async function installNoCodeAgentPostGuard(page) { + const attempts = []; + await page.route("**/v1/agent/chat", async (route) => { + const request = route.request(); + const attempt = { + method: request.method(), + urlPath: new URL(request.url()).pathname, + status: "aborted_by_dom_only_guard" + }; + attempts.push(attempt); + await route.abort("blockedbyclient"); + }); + return { + attempts, + observation: () => ({ + installed: true, + blockedPath: "/v1/agent/chat", + attemptedCount: attempts.length, + attempts + }) + }; +} + async function inspectLiveCodeAgentE2e(url) { let chromium; try { @@ -3364,12 +3654,13 @@ function escapeRegExp(value) { export function printSmokeHelp() { return { status: "usage", - command: "node scripts/dev-cloud-workbench-smoke.mjs --source | --mobile | --local-agent-fixture | --local-agent-timeout-fixture | --live --confirm-dev-live --url http://74.48.78.17:16666/ [--report reports/dev-gate/dev-cloud-workbench-live.json]", + command: "node scripts/dev-cloud-workbench-smoke.mjs --source | --mobile | --local-agent-fixture | --local-agent-timeout-fixture | --dom-only --url http://74.48.78.17:16666/ [--report reports/dev-gate/dev-cloud-workbench-dom-only.json] | --live --confirm-dev-live --url http://74.48.78.17:16666/ [--report reports/dev-gate/dev-cloud-workbench-live.json]", notes: [ "Default/source mode reads repository files and emits SOURCE-level evidence only; it never calls the live provider and must not claim DEV-LIVE.", "--mobile runs a local static 390x844 browser hit-test and still emits SOURCE-level evidence only.", "--local-agent-fixture runs two local browser send/reply paths with a sanitized SOURCE fixture delayed beyond 4500ms and does not claim DEV-LIVE.", "--local-agent-timeout-fixture runs a local slow SOURCE fixture to verify bounded timeout UI classification and retry preservation without live acceptance.", + "--dom-only preserves runtime/web-asset identity preflight, inspects read-only deployed DOM/help wiring, and never posts /v1/agent/chat.", "DEV-LIVE mode requires --live --confirm-dev-live, opens the deployed workbench in a browser, sends the simple Chinese prompt and 列出你能使用的所有skill, and retains only non-sensitive response fields." ] }; diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index 26c9ea53..83deca95 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -127,7 +127,15 @@ 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 --source", + "node scripts/dev-cloud-workbench-smoke.mjs --mobile", "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-fixture", + "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-timeout-fixture" +]; +const requiredDevCloudWorkbenchDomOnlyValidationCommands = [ + "node scripts/dev-cloud-workbench-smoke.mjs --dom-only --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-dom-only.json", + "node scripts/validate-dev-gate-report.mjs reports/dev-gate/dev-cloud-workbench-dom-only.json" +]; +const requiredDevCloudWorkbenchLiveValidationCommands = [ "node scripts/dev-cloud-workbench-smoke.mjs --live --confirm-dev-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" ]; @@ -243,7 +251,7 @@ const reportFamilyTemplates = new Map([ ] ]); -const statusValues = new Set(["pass", "blocked", "degraded", "not_run", "not_applicable", "failed"]); +const statusValues = new Set(["pass", "blocked", "degraded", "not_run", "not_applicable", "not_sent", "failed"]); const blockerTypes = new Set([ "contract_blocker", "environment_blocker", @@ -1359,16 +1367,21 @@ async function validateDevCloudWorkbenchLiveReport(report, label, raw) { `${label}.status must match blockers` ); assertTimestamp(report.generatedAt, `${label}.generatedAt`); - assert.equal(report.mode, "live", `${label}.mode`); + assert.ok(["live", "dom-only"].includes(report.mode), `${label}.mode`); assert.equal(report.url, "http://74.48.78.17:16666/", `${label}.url`); - assert.equal( - report.evidenceLevel, - report.status === "pass" - ? "DEV-LIVE-BROWSER" - : report.status === "degraded" ? "DEV-LIVE-BROWSER-DEGRADED" : "BLOCKED", - `${label}.evidenceLevel` - ); - assert.equal(report.devLive, report.status === "pass", `${label}.devLive`); + if (report.mode === "dom-only") { + assert.equal(report.evidenceLevel, report.status === "pass" ? "DEV-LIVE-DOM-READONLY" : "BLOCKED", `${label}.evidenceLevel`); + assert.equal(report.devLive, false, `${label}.devLive`); + } else { + assert.equal( + report.evidenceLevel, + report.status === "pass" + ? "DEV-LIVE-BROWSER" + : report.status === "degraded" ? "DEV-LIVE-BROWSER-DEGRADED" : "BLOCKED", + `${label}.evidenceLevel` + ); + assert.equal(report.devLive, report.status === "pass", `${label}.devLive`); + } assertDevCloudWorkbenchIdentity(report, label); const apiRuntimeReadiness = assertDevCloudWorkbenchApiRuntimeReadiness(report, label); assertDevCloudWorkbenchEndpoints(report, label); @@ -1385,8 +1398,18 @@ async function validateDevCloudWorkbenchLiveReport(report, label, raw) { assertStringArray(report.validationCommands, `${label}.validationCommands`, { minLength: requiredDevCloudWorkbenchValidationCommands.length }); + const requiredModeCommands = report.mode === "dom-only" + ? requiredDevCloudWorkbenchDomOnlyValidationCommands + : requiredDevCloudWorkbenchLiveValidationCommands; + const requiredCommands = [ + ...requiredDevCloudWorkbenchValidationCommands, + ...requiredModeCommands + ]; + assertStringArray(report.validationCommands, `${label}.validationCommands`, { + minLength: requiredCommands.length + }); assertUnique(report.validationCommands, `${label}.validationCommands`); - for (const requiredCommand of requiredDevCloudWorkbenchValidationCommands) { + for (const requiredCommand of requiredCommands) { assert.ok( report.validationCommands.includes(requiredCommand), `${label}.validationCommands missing ${requiredCommand}` @@ -1434,6 +1457,11 @@ async function validateDevCloudWorkbenchLiveReport(report, label, raw) { return; } + if (report.mode === "dom-only") { + assertDevCloudWorkbenchDomOnlyReport(report, label, checks); + return; + } + for (const requiredCheck of [ "live-http-html", "live-browser-dom", @@ -1907,6 +1935,171 @@ function assertDevCloudWorkbenchDeploymentPreflightJourney(report, label, journe ); } +function assertDevCloudWorkbenchDomOnlyReport(report, label, checks) { + assert.equal(report.mode, "dom-only", `${label}.mode`); + for (const requiredCheck of [ + "live-runtime-current-main", + "live-api-runtime-readiness", + "live-http-html", + "live-web-assets-current-main", + "live-browser-dom", + "live-gate-route", + "live-help-markdown-route", + "live-code-agent-browser-journey" + ]) { + assert.ok(checks.has(requiredCheck), `${label}.checks missing ${requiredCheck}`); + } + + const runtimeCheck = checks.get("live-runtime-current-main"); + const apiRuntimeReadinessCheck = checks.get("live-api-runtime-readiness"); + const webAssetCheck = checks.get("live-web-assets-current-main"); + const apiRuntimeReadiness = assertDevCloudWorkbenchApiRuntimeReadiness(report, label); + assert.ok(["pass", "blocked"].includes(runtimeCheck.status), `${label}.live-runtime-current-main.status`); + assert.equal( + apiRuntimeReadinessCheck.status, + apiRuntimeReadiness.status, + `${label}.live-api-runtime-readiness.status` + ); + assert.deepEqual( + apiRuntimeReadinessCheck.observations, + apiRuntimeReadiness, + `${label}.live-api-runtime-readiness.observations` + ); + assert.ok(["pass", "blocked"].includes(webAssetCheck.status), `${label}.live-web-assets-current-main.status`); + assertObject(runtimeCheck.observations, `${label}.live-runtime-current-main.observations`); + assertObject(webAssetCheck.observations, `${label}.live-web-assets-current-main.observations`); + assertObject(report.expectedRuntimeIdentity, `${label}.expectedRuntimeIdentity`); + assertObject(report.deploymentIdentity, `${label}.deploymentIdentity`); + assert.equal(report.deploymentIdentity.status, runtimeCheck.status, `${label}.deploymentIdentity.status`); + assertObject(report.webAssetIdentity, `${label}.webAssetIdentity`); + assert.equal(report.webAssetIdentity.status, webAssetCheck.status, `${label}.webAssetIdentity.status`); + assertArray(report.webAssetIdentity.assets, `${label}.webAssetIdentity.assets`); + assertString(report.webAssetIdentity.summary, `${label}.webAssetIdentity.summary`); + + const domCheck = checks.get("live-browser-dom"); + if (domCheck.status === "pass") { + assertDevCloudWorkbenchDomObservations(domCheck.observations, `${label}.live-browser-dom`); + } else { + assert.ok(["blocked", "skip"].includes(domCheck.status), `${label}.live-browser-dom.status`); + } + + const helpCheck = checks.get("live-help-markdown-route"); + assert.ok(["pass", "blocked", "skip"].includes(helpCheck.status), `${label}.live-help-markdown-route.status`); + + const gateCheck = checks.get("live-gate-route"); + if (gateCheck.status === "pass") { + assertDevCloudWorkbenchGateRoute(gateCheck.observations, `${label}.live-gate-route`); + } else { + assert.ok(["blocked", "skip"].includes(gateCheck.status), `${label}.live-gate-route.status`); + } + + assertDevCloudWorkbenchDomOnlyJourney(report, label, checks.get("live-code-agent-browser-journey")); + 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, "dom-only-readonly-no-code-agent-post", `${label}.safety.liveMode`); + assert.equal(report.safety.codeAgentBrowserJourneySkipped, true, `${label}.safety.codeAgentBrowserJourneySkipped`); + assert.equal(report.safety.codeAgentPostSent, false, `${label}.safety.codeAgentPostSent`); + assertStringArray(report.safety.retainedApiFields, `${label}.safety.retainedApiFields`, { minLength: 4 }); + assertString(report.safety.statement, `${label}.safety.statement`); + assert.equal(/Secret|M3\/M4\/M5|\/v1\/agent\/chat/u.test(report.safety.statement), true, `${label}.safety.statement`); + if (apiRuntimeReadiness.status === "degraded") { + assert.equal(report.status, "blocked", `${label}.status`); + assert.match( + report.safety.statement, + /must not be used to infer M3\/M4\/M5 acceptance|claims M3\/M4\/M5 acceptance/u, + `${label}.safety.statement` + ); + } + + const raw = JSON.stringify(report); + assert.equal(/"method"\s*:\s*"POST"[\s\S]{0,200}"status"\s*:\s*(200|201|202)|"completed"/u.test(raw), false, `${label} must not include a sent or completed Code Agent POST`); +} + +function assertDevCloudWorkbenchDomObservations(dom, label) { + assertObject(dom, `${label}.observations`); + assert.equal(dom.title, "HWLAB 云工作台", `${label}.title`); + assert.equal(dom.workspaceHidden, false, `${label}.workspaceHidden`); + assert.equal(dom.gateHidden, true, `${label}.gateHidden`); + assert.equal(dom.helpHidden, true, `${label}.helpHidden`); + assert.equal(dom.navLabelsPresent, true, `${label}.navLabelsPresent`); + assert.equal(dom.defaultBackstageHidden, true, `${label}.defaultBackstageHidden`); + assert.equal(dom.gateRouteAvailable, true, `${label}.gateRouteAvailable`); + if (Object.hasOwn(dom, "codeAgentPostGuard")) { + assertObject(dom.codeAgentPostGuard, `${label}.codeAgentPostGuard`); + assert.equal(dom.codeAgentPostGuard.installed, true, `${label}.codeAgentPostGuard.installed`); + assert.equal(dom.codeAgentPostGuard.blockedPath, "/v1/agent/chat", `${label}.codeAgentPostGuard.blockedPath`); + assert.equal(dom.codeAgentPostGuard.attemptedCount, 0, `${label}.codeAgentPostGuard.attemptedCount`); + } + assertStringArray(dom.wiringHeaders, `${label}.wiringHeaders`, { minLength: 8 }); + for (const column of ["源设备", "源端口", "接线盘", "目标设备", "目标端口", "状态", "证据来源", "trace/evidence"]) { + assert.ok(dom.wiringHeaders.includes(column), `${label}.wiringHeaders missing ${column}`); + } + assertArray(dom.m3WiringRows, `${label}.m3WiringRows`); + assert.ok( + dom.m3WiringRows.some((row) => + Array.isArray(row) && + row.includes("res_boxsimu_1") && + row.includes("DO1") && + row.includes("hwlab-patch-panel") && + row.includes("res_boxsimu_2") && + row.includes("DI1") + ), + `${label}.m3WiringRows missing required M3 route` + ); + assertObject(dom.coreControlsVisible, `${label}.coreControlsVisible`); + for (const control of ["commandInput", "commandSend", "agentChatStatus", "conversationList", "hardwareList", "wiringTab", "recordsTab"]) { + assert.equal(dom.coreControlsVisible[control], true, `${label}.coreControlsVisible.${control}`); + } +} + +function assertDevCloudWorkbenchGateRoute(gate, label) { + assertObject(gate, `${label}.observations`); + assert.equal(gate.pathname, "/gate", `${label}.pathname`); + assert.equal(gate.title, "HWLAB 云工作台", `${label}.title`); + assert.equal(gate.workspaceHidden, true, `${label}.workspaceHidden`); + assert.equal(gate.gateHidden, false, `${label}.gateHidden`); + assert.equal(gate.helpHidden, true, `${label}.helpHidden`); + assert.equal(gate.diagnosticsTermsPresent, true, `${label}.diagnosticsTermsPresent`); + if (Object.hasOwn(gate, "codeAgentPostGuard")) { + assertObject(gate.codeAgentPostGuard, `${label}.codeAgentPostGuard`); + assert.equal(gate.codeAgentPostGuard.installed, true, `${label}.codeAgentPostGuard.installed`); + assert.equal(gate.codeAgentPostGuard.blockedPath, "/v1/agent/chat", `${label}.codeAgentPostGuard.blockedPath`); + assert.equal(gate.codeAgentPostGuard.attemptedCount, 0, `${label}.codeAgentPostGuard.attemptedCount`); + } +} + +function assertDevCloudWorkbenchDomOnlyJourney(report, label, journey) { + assert.equal(journey.status, "not_applicable", `${label}.live-code-agent-browser-journey.status`); + assertStringArray(journey.evidence, `${label}.live-code-agent-browser-journey.evidence`, { minLength: 3 }); + assert.ok( + journey.evidence.includes("POST /v1/agent/chat not sent"), + `${label}.live-code-agent-browser-journey.evidence missing not-sent proof` + ); + 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.status, "not_applicable", `${label}.live-code-agent-browser-journey.request.status`); + 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, "not_applicable", `${label}.live-code-agent-browser-journey.response.status`); + assert.equal(journey.observations.response.hasReply, false, `${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 field of ["provider", "model", "backend", "traceId"]) { + assert.equal(journey.observations.response[field], "not_observed", `${label}.live-code-agent-browser-journey.response.${field}`); + } + assertObject(journey.observations.classification, `${label}.live-code-agent-browser-journey.classification`); + assert.equal(journey.observations.classification.status, "not_applicable", `${label}.live-code-agent-browser-journey.classification.status`); + assert.equal(journey.observations.classification.reason, "dom_only_no_code_agent_post", `${label}.live-code-agent-browser-journey.classification.reason`); + assert.equal(journey.observations.classification.codeAgentBrowserJourneySkipped, true, `${label}.live-code-agent-browser-journey.classification.codeAgentBrowserJourneySkipped`); + assertArray(journey.observations.networkEvents ?? [], `${label}.live-code-agent-browser-journey.networkEvents`); + assert.equal((journey.observations.networkEvents ?? []).length, 0, `${label}.live-code-agent-browser-journey.networkEvents`); + assert.equal(report.devLive, false, `${label}.devLive`); +} + function assertDevCloudWorkbenchIdentity(report, label) { assertObject(report.sourceIdentity, `${label}.sourceIdentity`); for (const field of ["status", "source", "commitId", "shortCommitId", "ref", "worktreeState", "reportCommitId", "summary"]) {