import fs from "node:fs"; import http from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { gateSummary } from "../../web/hwlab-cloud-web/gate-summary.mjs"; import { runtime } from "../../web/hwlab-cloud-web/runtime.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const webRoot = path.join(repoRoot, "web/hwlab-cloud-web"); const defaultLiveUrl = "http://74.48.78.17:16666/"; const helpOwner = "codex_1779444232735_1"; const readOnlyRpcMethods = Object.freeze([ "system.health", "cloud.adapter.describe", "audit.event.query", "evidence.record.query" ]); const requiredWebAssets = Object.freeze([ "index.html", "styles.css", "app.mjs", "runtime.mjs", "gate-summary.mjs", "workbench-hardware-panel.mjs", "help.md", "third_party/marked/marked.esm.js", "third_party/marked/LICENSE" ]); const chineseWorkbenchLabels = Object.freeze([ "HWLAB 云工作台", "硬件资源", "Agent 对话", "工作清单", "可信记录", "内部复核", "使用说明" ]); const defaultHomepageForbiddenTerms = Object.freeze([ "Gate", "诊断", "验收", "M0-M5", "BLOCKED", "db-live", "m3-hardware-loop-runtime", "执行轨迹" ]); const diagnosticsRequiredTerms = Object.freeze([ "Gate / 诊断 / 验收", "M0-M5", "BLOCKED", "DB live readiness", "patch-panel DO1 -> DI1" ]); const gateRouteAliases = Object.freeze(["/gate", "/diagnostics/gate"]); const incompletePrimaryNavLabels = Object.freeze(["台", "证", "诊", "帮"]); const requiredWiringColumns = Object.freeze([ "源设备", "源端口", "接线盘", "目标设备", "目标端口", "状态", "证据来源", "trace/evidence" ]); const requiredTrustedRecordTerms = Object.freeze([ "Code Agent 对话记录", "接线/operation", "audit 记录", "evidence 证据", "trace/messageId", "audit.event.query + SOURCE 回退", "evidence.record.query + SOURCE 回退", "conversationId、traceId 和 messageId", "失败原因=", "只读 audit 缺少 M3 patch-panel live report / operation / trace / evidence 绑定", "只读 evidence 缺少 M3 patch-panel live report / operation / trace / audit 绑定", "当前显示 SOURCE 回退,不能冒充 DEV-LIVE" ]); const requiredHardwareStatusTerms = Object.freeze([ "hardware-source-status", "hardwareGroup", "hardwareRow", "Gateway/Box 在线", "BOX-SIMU 端口", "Patch Panel 连线", "DO1 -> patch-panel -> DI1", "AI/AO/FREQ", "future capability", "SOURCE", "DEV-LIVE", "BLOCKED" ]); const workbenchMarkers = Object.freeze([ "data-app-shell", "workbench-shell", "activity-rail", "explorer", "resource-tree", "center-workspace", "conversation-list", "agent-chat-status", "task-list", "right-sidebar", "hardware-list", "command-form" ]); const forbiddenWritePatterns = Object.freeze([ /callRpc\(\s*["']hardware\./u, /hardware\.operation\.request/u, /hardware\.invoke\.shell/u, /audit\.event\.write/u, /evidence\.record\.write/u, /\/v1\/rpc\//u, /\/wiring\/apply/u, /\/wiring\/reload/u, /--live --confirm-dev --confirmed-non-production/u ]); const markdownRendererPackages = Object.freeze([ "marked", "markdown-it", "micromark", "remark", "remark-parse", "unified", "commonmark" ]); export function runDevCloudWorkbenchStaticSmoke() { return runStaticSmoke(); } export async function runDevCloudWorkbenchSmoke(argv = []) { const args = parseSmokeArgs(argv); return args.mode === "live" ? runLiveSmoke(args) : runStaticSmoke(); } export function parseSmokeArgs(argv) { const args = { mode: "static", url: defaultLiveUrl }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--static") { args.mode = "static"; } else if (arg === "--live") { args.mode = "live"; } else if (arg === "--mobile") { args.mobile = true; } else if (arg === "--url") { index += 1; if (!argv[index]) throw new Error("--url requires a value"); args.url = argv[index]; } else if (arg === "--help") { args.help = true; } else { throw new Error(`unknown argument ${arg}`); } } return args; } function runStaticSmoke() { const checks = []; const blockers = []; const files = readStaticFiles(); const source = Object.values(files).join("\n"); const artifactPublisher = readText("scripts/dev-artifact-publish.mjs"); const buildScript = readText("web/hwlab-cloud-web/scripts/build.mjs"); const distContractScript = readText("web/hwlab-cloud-web/scripts/dist-contract.mjs"); addCheck(checks, blockers, "static-web-assets", assetsExist(), "Cloud Web source assets are present.", { evidence: requiredWebAssets.map((file) => `web/hwlab-cloud-web/${file}`) }); addCheck(checks, blockers, "default-workbench-shell", hasDefaultWorkbench(files), "Default route is the VS Code-style Cloud Workbench.", { blocker: "runtime_blocker", evidence: ["workspace view is visible by default", "Gate view is hidden by default", ...workbenchMarkers] }); addCheck(checks, blockers, "secondary-gate-status-help", secondaryViewsAreNotDefault(files), "Gate/status/diagnostics/help surfaces are not accepted as the default homepage.", { blocker: "runtime_blocker", evidence: ["routeFromLocation final fallback is workspace", "non-workspace data-view sections must be hidden"] }); addCheck(checks, blockers, "chinese-workbench-labels", labelsPresent(source, chineseWorkbenchLabels), "Current Chinese workbench labels are present.", { blocker: "contract_blocker", evidence: chineseWorkbenchLabels }); addCheck(checks, blockers, "feedback-117-default-no-backstage", defaultHomepageHidesBackstage(files), "Default workbench hides Gate, diagnostics, acceptance, BLOCKED, and M0-M5 backstage information.", { blocker: "runtime_blocker", evidence: defaultHomepageForbiddenTerms }); addCheck(checks, blockers, "feedback-118-complete-nav", hasCompletePrimaryNavigation(files.html), "Primary navigation uses complete Chinese labels and removes the left-rail trusted-records shortcut.", { blocker: "contract_blocker", evidence: ["工作台", "内部复核", "使用说明", "资源树"] }); addCheck(checks, blockers, "feedback-119-gate-retains-diagnostics", gateRetainsDiagnostics(files.html, files.app), "Internal Gate page retains BLOCKED/M0-M5 diagnostics and the DB live plus patch-panel DO1 -> DI1 unblock path.", { blocker: "contract_blocker", evidence: diagnosticsRequiredTerms }); addCheck(checks, blockers, "internal-gate-route-aliases", gateRouteAliasesAreServed(files.app, artifactPublisher, buildScript, distContractScript), "/gate and /diagnostics/gate are direct internal diagnostic aliases, not default routes.", { blocker: "runtime_blocker", evidence: gateRouteAliases }); addCheck(checks, blockers, "feedback-120-wiring-table", wiringTableContract(files.html), "Patch-panel wiring panel is a table with source, target, status, evidence source, and trace/evidence columns.", { blocker: "contract_blocker", evidence: requiredWiringColumns }); addCheck(checks, blockers, "trusted-record-groups", trustedRecordGroups(files), "Trusted Records panel groups Code Agent, wiring/operation, audit, and evidence summaries with source labels and live-failure fallback.", { blocker: "observability_blocker", evidence: requiredTrustedRecordTerms }); addCheck(checks, blockers, "hardware-status-rich-structure", hardwareStatusStructureContract(files), "Right hardware status renders Gateway/Box, DI/DO/AI/AO/FREQ, patch-panel, and DO1 -> DI1 source-labeled link state.", { blocker: "contract_blocker", evidence: requiredHardwareStatusTerms }); addCheck(checks, blockers, "m3-default-wiring-source", defaultTopologyIsM3Only(), "Default 16666 workbench topology exposes only the M3 res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 wiring.", { blocker: "runtime_blocker", evidence: ["res_boxsimu_1:DO1", "hwlab-patch-panel", "res_boxsimu_2:DI1"] }); addCheck(checks, blockers, "outer-scroll-contract", hasScrollLockContract(files.styles), "Outer page scrolling is locked and internal workbench panes own scrolling.", { blocker: "contract_blocker", evidence: ["html/body overflow hidden", "workbench-shell 100vh/100dvh overflow hidden", ".view overflow auto"] }); addCheck(checks, blockers, "mobile-workbench-layout-contract", hasMobileWorkbenchLayoutContract(files), "390x844 mobile layout keeps the default workbench reachable and moves the resource tree into a controlled drawer.", { blocker: "runtime_blocker", evidence: [ "syncMobileExplorer collapses resource tree at <=860px", "mobile rail spans full height", "center workspace and right sidebar share the content column", "resource explorer opens as a full-height drawer" ] }); addCheck(checks, blockers, "same-origin-readonly-boundary", hasSameOriginReadOnlyBoundary(files.app), "Workbench data access stays same-origin and JSON-RPC diagnostics stay read-only.", { blocker: "safety_blocker", evidence: ["/health/live", "/v1", "/v1/agent/chat", "/json-rpc", ...readOnlyRpcMethods] }); addCheck(checks, blockers, "code-agent-chat-primary-flow", hasCodeAgentChatContract(files), "Center Agent conversation sends to the controlled Code Agent chat endpoint and no longer uses 添加草稿 as the primary flow.", { blocker: "runtime_blocker", evidence: ["command-send", "agent-chat-status", "/v1/agent/chat", "conversationId/messageId/status/timestamps/error.message"] }); addCheck(checks, blockers, "code-agent-provider-readiness-visibility", hasCodeAgentReadinessVisibility(files), "Workbench shows provider credential blockers and only real completed replies can become DEV-LIVE.", { blocker: "observability_blocker", evidence: ["BLOCKED 凭证缺口", "provider_unavailable", "OPENAI_API_KEY", "hwlab-code-agent-provider/openai-api-key", "completed -> dev-live guard"] }); addCheck(checks, blockers, "no-hardware-write-api", noForbiddenWriteSurface(source), "Workbench source does not expose hardware write APIs or live mutation commands.", { blocker: "safety_blocker", evidence: forbiddenWritePatterns.map(String) }); addCheck(checks, blockers, "source-not-dev-live", sourceEvidenceNotDevLive(source), "SOURCE/LOCAL/DRY-RUN/fixture evidence is not promoted to DEV-LIVE.", { blocker: "observability_blocker", evidence: ["M3 remains blocked without live loop evidence", "checked-in evidence records remain dry-run"] }); addCheck(checks, blockers, "m3-fixture-counts-not-dev-live", m3FixtureCountsNotDevLive(files.app), "Fixture/source patch-panel wiring plus generic runtime counts cannot render M3 DEV-LIVE.", { blocker: "observability_blocker", evidence: [ "hasTrustedM3LiveEvidence requires patchPanelLiveReport", "operationId/traceId/auditId/evidenceId required", "runtime counts are SOURCE-only" ] }); addCheck(checks, blockers, "m3-rendered-workbench-not-m5-fixture", m3RenderedWorkbenchNotM5Fixture(files.app), "Right workbench hardware status and wiring table render the M3 chain, not M5 fixture rows or undefined DOM-node rows.", { blocker: "runtime_blocker", evidence: [ "renderHardwareStatus passes data objects into hardwareGroup", "renderWiringList uses M3_TRUSTED_ROUTE", "M5 activeConnections are not mapped into the workbench wiring table" ] }); const help = inspectHelpContract(files); addCheck(checks, blockers, "help-md-contract", help.status, help.summary, { blocker: help.blocker, owner: helpOwner, observations: help.observations }); return baseReport({ mode: "static", status: blockers.length === 0 ? "pass" : "blocked", checks, blockers, evidenceLevel: "SOURCE", devLive: false, help: { status: help.status, ...help.observations }, safety: staticSafety() }); } async function runLiveSmoke(args) { const checks = []; const blockers = []; 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 dom = http.ok ? await inspectLiveDom(args.url) : { 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 status = blockers.length === 0 ? "pass" : "blocked"; return baseReport({ mode: "live", url: args.url, status, checks, blockers, evidenceLevel: status === "pass" ? "DEV-LIVE-READONLY" : "BLOCKED", devLive: status === "pass", safety: { prodTouched: false, servicesRestarted: false, 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." } }); } function baseReport({ mode, status, checks, blockers, evidenceLevel, devLive, help = undefined, safety, url = null }) { return { status, task: "DC-DCSN-P0-2026-003", refs: [ "pikasTech/HWLAB#108", "pikasTech/HWLAB#99", "pikasTech/HWLAB#78", "pikasTech/HWLAB#117", "pikasTech/HWLAB#118", "pikasTech/HWLAB#119", "pikasTech/HWLAB#120" ], mode, url, generatedAt: new Date().toISOString(), evidenceLevel, devLive, endpoints: { frontend: runtime.endpoints.frontend, api: runtime.endpoints.api, edge: runtime.endpoints.edge }, checks, blockers, help, safety }; } function addCheck(checks, blockers, id, result, summary, options = {}) { const status = result === true ? "pass" : result === false ? "blocked" : result; const check = { id, status, summary, ...(options.owner ? { owner: options.owner } : {}), ...(options.evidence ? { evidence: options.evidence } : {}), ...(options.observations ? { observations: options.observations } : {}) }; checks.push(check); if (status === "blocked" && options.blocker) { blockers.push({ type: options.blocker, scope: id, status: "open", summary }); } return check; } function readStaticFiles() { return { html: readText("web/hwlab-cloud-web/index.html"), styles: readText("web/hwlab-cloud-web/styles.css"), app: readText("web/hwlab-cloud-web/app.mjs"), runtime: readText("web/hwlab-cloud-web/runtime.mjs"), panel: readText("web/hwlab-cloud-web/workbench-hardware-panel.mjs") }; } function readText(relativePath) { return fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); } function assetsExist() { return requiredWebAssets.every((file) => fs.existsSync(path.join(webRoot, file))); } function hasDefaultWorkbench({ html, app }) { const workspaceTag = firstTagForDataView(html, "workspace"); const gateTag = firstTagForDataView(html, "gate"); return ( labelsPresent(html, workbenchMarkers) && /\s*HWLAB 云工作台\s*<\/title>/u.test(html) && /data-route=["']workspace["']/u.test(html) && workspaceTag !== null && !/\bhidden\b/u.test(workspaceTag) && gateTag !== null && /\bhidden\b/u.test(gateTag) && finalRouteFallback(app) === "workspace" ); } function secondaryViewsAreNotDefault({ html, app }) { const finalFallback = finalRouteFallback(app); const unhiddenSecondaryViews = [...html.matchAll(/<section\b[^>]*\bdata-view=["']([^"']+)["'][^>]*>/gu)] .filter((match) => match[1] !== "workspace") .filter((match) => !/\bhidden\b/u.test(match[0])); return finalFallback === "workspace" && unhiddenSecondaryViews.length === 0; } function firstTagForDataView(html, view) { return html.match(new RegExp(`<section\\b[^>]*\\bdata-view=["']${escapeRegExp(view)}["'][^>]*>`, "u"))?.[0] ?? null; } function sectionSource(source, viewId) { const start = source.search(new RegExp(`<section\\b[^>]*\\bdata-view=["']${escapeRegExp(viewId)}["'][^>]*>`, "u")); if (start === -1) return ""; const next = source.slice(start + 1).search(/<section\b[^>]*\bdata-view=["'][^"']+["'][^>]*>/u); return next === -1 ? source.slice(start) : source.slice(start, start + 1 + next); } function beforeSection(source, viewId) { const index = source.search(new RegExp(`<section\\b[^>]*\\bdata-view=["']${escapeRegExp(viewId)}["'][^>]*>`, "u")); return index === -1 ? source : source.slice(0, index); } function defaultHomepageHidesBackstage({ html }) { const workspaceHtml = sectionSource(html, "workspace"); const visibleShellHtml = `${beforeSection(html, "help")}\n${workspaceHtml}`; return defaultHomepageForbiddenTerms.every((term) => !visibleShellHtml.includes(term)); } function hasCompletePrimaryNavigation(html) { const navLabelsPresent = ["工作台", "内部复核", "使用说明", "资源树"].every((label) => html.includes(`>${label}<`)); const legacyLabelsAbsent = incompletePrimaryNavLabels.every((label) => !html.includes(`>${label}<`)); const noLeftSideRecordsShortcut = !/data-side-tab-jump=["']records["']/u.test(html); return navLabelsPresent && legacyLabelsAbsent && noLeftSideRecordsShortcut; } function gateRetainsDiagnostics(html, app) { const gateHtml = sectionSource(html, "gate"); const gateSource = `${gateHtml}\n${app}`; return diagnosticsRequiredTerms.every((term) => gateSource.includes(term)); } function gateRouteAliasesAreServed(app, artifactPublisher, buildScript, distContractScript) { const buildCopiesAliases = /\["gate", "diagnostics\/gate"\]/u.test(buildScript) || (/gate\/index\.html/u.test(distContractScript) && /diagnostics\/gate\/index\.html/u.test(distContractScript)); return ( /internalGatePathnames\(\)\.has/u.test(app) && gateRouteAliases.every((route) => app.includes(`"${route}"`)) && gateRouteAliases.every((route) => artifactPublisher.includes(`routePath === "${route}"`)) && buildCopiesAliases ); } function wiringTableContract(html) { const wiringPanel = html.match(/<section\b[^>]*\bdata-side-panel=["']wiring["'][\s\S]*?<\/section>/u)?.[0] ?? ""; return requiredWiringColumns.every((column) => wiringPanel.includes(`<th>${column}</th>`)); } function hardwareStatusStructureContract({ html, app, styles }) { const source = `${html}\n${app}\n${styles}`; return ( requiredHardwareStatusTerms.every((term) => source.includes(term)) && /id=["']hardware-source-status["']/u.test(html) && /function\s+renderHardwareStatus\s*\(/u.test(app) && /function\s+hardwareGroup\s*\(/u.test(app) && /function\s+hardwareRow\s*\(/u.test(app) && /function\s+trustedM3Link\s*\(/u.test(app) && /function\s+hasTrustedM3LiveEvidence\s*\(/u.test(app) && /function\s+linkStatusLabel\s*\(/u.test(app) && /function\s+normalizePort\s*\(/u.test(app) && /Web 不提供硬件写 RPC;不会新增伪硬件写操作。/u.test(app) && /\.hardware-section\s*\{/u.test(styles) && /\.hardware-row\s*\{/u.test(styles) ); } function defaultTopologyIsM3Only() { const activeConnections = gateSummary.topology.patchPanel.activeConnections ?? []; return ( gateSummary.topology.projectId === "prj_m3_hardware_loop" && JSON.stringify(gateSummary.topology.gateways.map((gateway) => gateway.gatewayId)) === JSON.stringify(["gwsimu_1", "gwsimu_2"]) && JSON.stringify(gateSummary.topology.boxResources.map((resource) => resource.resourceId)) === JSON.stringify(["res_boxsimu_1", "res_boxsimu_2"]) && activeConnections.length === 1 && activeConnections[0].fromResourceId === "res_boxsimu_1" && activeConnections[0].fromPort === "DO1" && activeConnections[0].toResourceId === "res_boxsimu_2" && activeConnections[0].toPort === "DI1" && !/res_m5-control-relay|res_m5-target-board|out1|reset/u.test(JSON.stringify(gateSummary.topology)) ); } function trustedRecordGroups({ html, app, styles }) { const source = `${html}\n${app}\n${styles}`; return ( /id=["']records-list["']/u.test(html) && /function\s+renderRecords\s*\(/u.test(app) && /function\s+codeAgentRecordCards\s*\(/u.test(app) && /function\s+operationRecordCards\s*\(/u.test(app) && /function\s+auditRecordCards\s*\(/u.test(app) && /function\s+evidenceRecordCards\s*\(/u.test(app) && /function\s+auditCard\s*\(/u.test(app) && /function\s+evidenceCard\s*\(/u.test(app) && /function\s+recordGroup\s*\(/u.test(app) && /function\s+recordField\s*\(/u.test(app) && /function\s+sourceKindLabel\s*\(/u.test(app) && /function\s+liveFailureCard\s*\(/u.test(app) && /state\.liveSurface/u.test(app) && /renderRecords\(state\.liveSurface\)/u.test(app) && /traceId: error\.traceId \|\| traceId/u.test(app) && /error\.traceId = traceId/u.test(app) && requiredTrustedRecordTerms.every((term) => source.includes(term)) && /\.record-group\s*\{/u.test(styles) && /\.record-group-title\s*\{/u.test(styles) && /\.record-group-list\s*\{/u.test(styles) && /replaceChildren\(el\.recordsList, \.\.\.groups\)/u.test(app) && /gateSummary\.operations\.map/u.test(app) && /gateSummary\.auditEvents\.slice\(0,\s*4\)\.map/u.test(app) && /gateSummary\.evidenceRecords\.map/u.test(app) && /message\.status === "failed" \? "blocked" : "source"/u.test(app) && !/message\.status === "completed"\s*\?\s*"dev-live"/u.test(app) ); } function finalRouteFallback(app) { const body = app.match(/function\s+routeFromLocation\s*\(\)\s*\{([\s\S]*?)\n\}/u)?.[1] ?? ""; const returns = [...body.matchAll(/return\s+["']([^"']+)["']\s*;/gu)].map((match) => match[1]); return returns.at(-1) ?? null; } function labelsPresent(source, labels) { return labels.every((label) => source.includes(label)); } function hasScrollLockContract(styles) { return ( /html,\s*\nbody\s*\{[^\}]*height:\s*100%;[^\}]*overflow:\s*hidden;/su.test(styles) && /body\s*>\s*\[data-app-shell\]\s*\{[^\}]*min-height:\s*0;/su.test(styles) && /\.workbench-shell\s*\{[^\}]*height:\s*100(?:d)?vh;[^\}]*overflow:\s*hidden;/su.test(styles) && /\.view\s*\{[^\}]*min-height:\s*0;[^\}]*overflow:\s*auto;/su.test(styles) && /\.(?:resource-tree|compact-list|conversation-list|trace-list|task-list|hardware-list)[^\{]*\{[^\}]*min-height:\s*0;[^\}]*overflow:\s*auto;/su.test(styles) ); } function hasMobileWorkbenchLayoutContract({ html, app, styles }) { return ( /id=["']resource-explorer["']/u.test(html) && /aria-controls=["']resource-explorer["']/u.test(html) && /aria-expanded=["']true["']/u.test(html) && /function\s+syncMobileExplorer\s*\(/u.test(app) && /window\.matchMedia\(["']\(max-width:\s*860px\)["']\)/u.test(app) && /setExplorerCollapsed\(true\)/u.test(app) && /setExplorerCollapsed\(false\)/u.test(app) && /function\s+collapseExplorerAfterMobileAction\s*\(/u.test(app) && /aria-expanded/u.test(functionBody(app, "setExplorerCollapsed")) && /\.explorer-collapsed\s+\.explorer\s*\{[^\}]*pointer-events:\s*none;/su.test(styles) && /@media\s*\(max-width:\s*860px\)[\s\S]*?\.activity-rail\s*\{[\s\S]*?grid-row:\s*1\s*\/\s*3;/u.test(styles) && /@media\s*\(max-width:\s*860px\)[\s\S]*?\.explorer\s*\{[\s\S]*?grid-row:\s*1\s*\/\s*3;[\s\S]*?z-index:\s*4;/u.test(styles) && /@media\s*\(max-width:\s*860px\)[\s\S]*?\.center-workspace\s*\{[\s\S]*?grid-row:\s*1;/u.test(styles) && /@media\s*\(max-width:\s*860px\)[\s\S]*?\.right-sidebar\s*\{[\s\S]*?grid-row:\s*2;/u.test(styles) && /@media\s*\(max-width:\s*520px\)[\s\S]*?\.command-bar\s*\{[\s\S]*?grid-template-columns:\s*minmax\(0,\s*1fr\)\s+auto\s+auto;/u.test(styles) ); } function hasSameOriginReadOnlyBoundary(app) { const rpcCalls = [...app.matchAll(/callRpc\(\s*["']([^"']+)["']/gu)].map((match) => match[1]).sort(); return ( /fetchJson\("\/health\/live"\)/u.test(app) && /fetchJson\("\/v1"\)/u.test(app) && /fetchJson\("\/v1\/agent\/chat"/u.test(app) && /fetchJson\("\/json-rpc"/u.test(app) && JSON.stringify(rpcCalls) === JSON.stringify([...readOnlyRpcMethods].sort()) && !/fetchJson\(\s*["']https?:\/\//u.test(app) ); } function hasCodeAgentChatContract({ html, app }) { return ( /id=["']agent-chat-status["']/u.test(html) && /id=["']command-send["']/u.test(html) && />发送<\/button>/u.test(html) && !/>添加草稿<\/button>/u.test(html) && /placeholder=["']输入要发送给 Code Agent 的消息;不会直接触发硬件变更。["']/u.test(html) && /fetchJson\("\/v1\/agent\/chat"/u.test(app) && /conversationId/u.test(app) && /messageId/u.test(app) && /updatedAt/u.test(app) && /error\?\.message/u.test(app) && /Code Agent 调用失败/u.test(app) ); } function hasCodeAgentReadinessVisibility({ html, app }) { const source = `${html}\n${app}`; return ( /codeAgentAvailability/u.test(app) && /codeAgentAvailabilityFrom/u.test(app) && /latestCompletedAgentMessage/u.test(app) && /BLOCKED 凭证缺口/u.test(app) && /provider_unavailable/u.test(app) && /OPENAI_API_KEY/u.test(app) && /hwlab-code-agent-provider\/openai-api-key/u.test(app) && /只有真实 completed 回复才标 DEV-LIVE/u.test(app) && /不能因为只有 conversationId 标为开发实况/u.test(app) && /Boolean\(message\.provider\)/u.test(app) && /Boolean\(message\.model\)/u.test(app) && /Boolean\(message\.backend\)/u.test(app) && /status === "completed" \? "dev-live"/u.test(app) && !/status === "failed" \? "dev-live"/u.test(app) && !/tone:\s*state\.conversationId\s*\?\s*"dev-live"/u.test(app) && !/provider_unavailable[\s\S]{0,120}tone-[\w-]*dev-live/iu.test(source) && !/sk-[A-Za-z0-9._-]{8,}/u.test(source) ); } function noForbiddenWriteSurface(source) { return forbiddenWritePatterns.every((pattern) => !pattern.test(source)); } function sourceEvidenceNotDevLive(source) { const m3 = gateSummary.milestones.find((item) => item.id === "M3"); return ( m3?.status === "blocked" && gateSummary.operations.every((operation) => operation.dryRun === true) && gateSummary.auditEvents.every((event) => event.dryRun === true) && gateSummary.evidenceRecords.every((record) => record.dryRun === true) && gateSummary.safety.allowNetwork === false && ["来源 SOURCE", "演练 DRY-RUN", "开发实况 DEV-LIVE", "阻塞 BLOCKED"].every((label) => source.includes(label)) ); } function m3FixtureCountsNotDevLive(app) { const hardwareBody = functionBody(app, "renderHardwareStatus"); const linkDetailBody = functionBody(app, "m3LinkDetail"); const liveEvidenceBody = functionBody(app, "hasTrustedM3LiveEvidence"); const patchPanelReportBody = functionBody(app, "hasTrustedPatchPanelLiveReport"); const sourceOnlyRuntimeCounts = /runtimeCountsSourceKind\s*=\s*["']SOURCE["']/u.test(hardwareBody) && !/sourceKind:\s*counts\s*\?\s*["']DEV-LIVE["']/u.test(hardwareBody) && !/counts\s*&&\s*(?:liveLink|sourceLink|m3Link)[\s\S]{0,80}\?\s*["']DEV-LIVE["']/u.test(hardwareBody); return ( sourceOnlyRuntimeCounts && /hasTrustedM3LiveEvidence\(liveM3Evidence\)/u.test(hardwareBody) && /patchPanelLiveReport/u.test(liveEvidenceBody) && /LIVE_M3_ID_FIELDS\.every/u.test(liveEvidenceBody) && /hasTrustedPatchPanelLiveReport/u.test(liveEvidenceBody) && /serviceId\s*!==\s*M3_TRUSTED_ROUTE\.patchPanelServiceId/u.test(patchPanelReportBody) && /trustedM3Link\(\[link\]\)/u.test(patchPanelReportBody) && /counts 不是 patch-panel live report/u.test(linkDetailBody) && /SOURCE fixture 包含目标接线/u.test(linkDetailBody) ); } function m3RenderedWorkbenchNotM5Fixture(app) { const hardwareBody = functionBody(app, "renderHardwareStatus"); const hardwareGroupBody = functionBody(app, "hardwareGroup"); const wiringBody = functionBody(app, "renderWiringList"); return ( /group\.rows\.map\(hardwareRow\)/u.test(hardwareGroupBody) && !/hardwareRow\(\s*\{/u.test(hardwareBody) && /M3_TRUSTED_ROUTE\.fromResourceId/u.test(wiringBody) && /M3_TRUSTED_ROUTE\.patchPanelServiceId/u.test(wiringBody) && /M3_TRUSTED_ROUTE\.toResourceId/u.test(wiringBody) && !/activeConnections\.map/u.test(wiringBody) && !/link\.fromResourceId/u.test(wiringBody) ); } function functionBody(source, functionName) { const match = source.match(new RegExp(`function\\s+${escapeRegExp(functionName)}\\s*\\([^)]*\\)\\s*\\{`, "u")); if (!match) return ""; let depth = 0; for (let index = match.index + match[0].length - 1; index < source.length; index += 1) { const char = source[index]; if (char === "{") depth += 1; if (char === "}") { depth -= 1; if (depth === 0) return source.slice(match.index, index + 1); } } return ""; } function inspectHelpContract({ html, app }) { const markdownFiles = findHelpMarkdownFiles(); const rendererPackages = findMarkdownRenderers(); const route = inspectHelpRoute(html, app); const implemented = markdownFiles.includes("web/hwlab-cloud-web/help.md") && rendererPackages.length > 0 && route.nonDefaultRoute; const status = route.defaultRouteRisk ? "blocked" : implemented ? "pass" : "pending"; return { status, blocker: route.defaultRouteRisk ? "runtime_blocker" : null, summary: implemented ? "Markdown help route from PR #114 is present and remains non-default." : `Markdown help page contract is pending; owner=${helpOwner}.`, observations: { owner: helpOwner, markdownHelpFilesPresent: markdownFiles.length > 0, markdownHelpFiles: markdownFiles, matureMarkdownRendererPresent: rendererPackages.length > 0, markdownRenderers: rendererPackages, nonDefaultRoute: route.nonDefaultRoute, implemented, route } }; } function findHelpMarkdownFiles() { const files = [ "web/hwlab-cloud-web/help.md", "web/hwlab-cloud-web/help/README.md", "web/hwlab-cloud-web/help/index.md", "docs/dev-cloud-workbench-help.md", "docs/cloud-web-workbench.md" ]; return files.filter((relativePath) => fs.existsSync(path.join(repoRoot, relativePath))); } function findMarkdownRenderers() { const packages = ["package.json", "web/hwlab-cloud-web/package.json"] .filter((file) => fs.existsSync(path.join(repoRoot, file))) .flatMap((file) => { const json = JSON.parse(readText(file)); return Object.keys({ ...(json.dependencies ?? {}), ...(json.devDependencies ?? {}) }); }); const source = `${readText("web/hwlab-cloud-web/app.mjs")}\n${readText("web/hwlab-cloud-web/index.html")}`; const imported = markdownRendererPackages.filter((name) => new RegExp(`from\\s+["']${escapeRegExp(name)}["']|import\\(["']${escapeRegExp(name)}["']\\)`, "u").test(source)); const vendoredMarked = /from\s+["']\.\/third_party\/marked\/marked\.esm\.js["']/u.test(source) && fs.existsSync(path.join(repoRoot, "web/hwlab-cloud-web/third_party/marked/marked.esm.js")) && fs.existsSync(path.join(repoRoot, "web/hwlab-cloud-web/third_party/marked/LICENSE")); const renderers = [...packages, ...imported].filter((name) => markdownRendererPackages.includes(name)); if (vendoredMarked) renderers.push("marked:vendored"); return [...new Set(renderers)].sort(); } function inspectHelpRoute(html, app) { const helpViewTag = firstTagForDataView(html, "help"); const explicitRoutePresent = /data-route=["']help["']/u.test(html) || /data-view=["']help["']/u.test(html) || /#help\b/u.test(html + app) || /pathname[\s\S]{0,160}\/help/u.test(app); const finalFallback = finalRouteFallback(app); const helpViewHidden = helpViewTag ? /\bhidden\b/u.test(helpViewTag) : false; return { explicitRoutePresent, finalFallback, helpViewHidden, routePolicy: html.match(/\bdata-help-route-policy=["']([^"']+)["']/u)?.[1] ?? null, nonDefaultRoute: explicitRoutePresent && finalFallback === "workspace" && (helpViewTag ? helpViewHidden : true) && /data-help-route-policy=["']non-default-internal-help["']/u.test(html), defaultRouteRisk: finalFallback === "help" || (helpViewTag !== null && !helpViewHidden) }; } function staticSafety() { return { prodTouched: false, servicesRestarted: false, heavyE2E: false, hardwareWriteApis: false, devLive: false, sourceIsDevLive: false, statement: "Static repository/source evidence is not DEV-LIVE and cannot promote SOURCE, LOCAL, DRY-RUN, or fixture observations." }; } async function fetchText(url) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 6000); try { const response = await fetch(url, { signal: controller.signal, headers: { Accept: "text/html" } }); const body = await response.text(); return { ok: response.ok, status: response.status, contentType: response.headers.get("content-type") ?? "", body }; } catch (error) { return { ok: false, status: null, contentType: "", body: "", error: error.name === "AbortError" ? "timeout" : error.message }; } finally { clearTimeout(timer); } } function liveHtmlLooksLikeWorkbench(html) { return /HWLAB 云工作台/u.test(html) && labelsPresent(html, workbenchMarkers) && !/<body[^>]*>\s*<(?:main|section)[^>]*(?:gate|diagnostics|status|help)/iu.test(html); } async function inspectLiveDom(url) { let chromium; try { ({ chromium } = await import("playwright")); } catch (error) { return { status: "skip", summary: `Browser DOM 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 } }); await page.goto(url, { waitUntil: "domcontentloaded", timeout: 8000 }); 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 bodyStyle = getComputedStyle(document.body); const htmlStyle = getComputedStyle(document.documentElement); const text = document.body.textContent ?? ""; 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, outerScrollLocked: document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2, labelsPresent: ["硬件资源", "Agent 对话", "工作清单", "可信记录", "使用说明"].every((label) => text.includes(label)) }; }); const pass = dom.title === "HWLAB 云工作台" && dom.bodyOverflow === "hidden" && dom.htmlOverflow === "hidden" && dom.workspaceHidden === false && dom.gateHidden === true && dom.diagnosticsHidden === true && dom.outerScrollLocked && dom.labelsPresent; 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.", evidence: [`title=${dom.title}`, `bodyOverflow=${dom.bodyOverflow}`, `htmlOverflow=${dom.htmlOverflow}`], observations: dom }; } catch (error) { return { status: "skip", summary: `Browser DOM check skipped because the browser could not run: ${error.message}`, evidence: ["browser launch or navigation failed"] }; } finally { if (browser) await browser.close(); } } export async function runDevCloudWorkbenchMobileSmoke() { let chromium; try { ({ chromium } = await import("playwright")); } catch (error) { return { status: "skip", task: "DC-DCSN-P0-2026-003", mode: "static-mobile-browser", viewport: { width: 390, height: 844 }, evidenceLevel: "SOURCE", devLive: false, summary: `Mobile browser smoke skipped because Playwright is unavailable: ${error.message}`, checks: [], blockers: [], safety: staticSafety() }; } const server = await startStaticWebServer(); let browser; try { browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 1, isMobile: true }); await page.goto(server.url, { waitUntil: "networkidle", timeout: 15000 }); const closed = await inspectMobileWorkbench(page, { explorerOpen: false }); const workspaceScroll = await inspectWorkbenchScrollContract(page, { panelSelector: "#conversation-list" }); await page.click('[data-route="help"]'); await page.waitForFunction(() => document.querySelector("#help-content")?.dataset.helpState === "ready", null, { timeout: 8000 }); const help = await inspectHelpMarkdownRoute(page); await page.click('[data-route="workspace"]'); await page.waitForFunction(() => document.querySelector('[data-view="workspace"]')?.hidden === false, null, { timeout: 8000 }); await page.click("#explorer-toggle"); const open = await inspectMobileWorkbench(page, { explorerOpen: true }); await page.click('[data-side-tab-jump="wiring"]'); const afterQuickAction = await inspectMobileWorkbench(page, { explorerOpen: false }); const checks = [ { id: "mobile-default-workbench", status: closed.defaultWorkbenchReachable ? "pass" : "blocked", summary: "390x844 default route remains the user workbench with Gate/help as secondary entries.", observations: { title: closed.title, workspaceHidden: closed.workspaceHidden, gateHidden: closed.gateHidden, helpHidden: closed.helpHidden, explorerCollapsed: closed.explorerCollapsed, visibleText: closed.visibleText } }, { id: "mobile-outer-scroll-lock", status: workspaceScroll.outerScrollLocked && help.rootStillLocked && help.helpPanelScrolls ? "pass" : "blocked", summary: "390x844 page locks outer scrolling while the internal Markdown help pane owns overflow scrolling.", observations: { workspace: workspaceScroll, help: { rootStillLocked: help.rootStillLocked, helpPanelScrolls: help.helpPanelScrolls, helpScroll: help.helpScroll } } }, { id: "mobile-help-markdown-route", status: help.nonDefaultMarkdownHelp && help.termsPresent ? "pass" : "blocked", summary: "Help opens as a non-default internal route rendered from help.md by the vendored Markdown renderer.", observations: help }, { id: "mobile-default-hit-test", status: closed.primaryControlsReachable ? "pass" : "blocked", summary: "Primary workbench controls are center-click reachable with the resource tree closed.", observations: { reachable: closed.reachable, blocked: closed.blocked } }, { id: "mobile-resource-drawer-hit-test", status: open.resourceControlsReachable ? "pass" : "blocked", summary: "Opened resource drawer keeps quick actions and resource rows center-click reachable.", observations: { drawer: open.drawer, reachable: open.reachable, blocked: open.blocked } }, { id: "mobile-resource-action-collapse", status: afterQuickAction.resourceActionCollapsed ? "pass" : "blocked", summary: "Mobile resource quick actions collapse the drawer so the target workspace panel remains reachable.", observations: { explorerCollapsed: afterQuickAction.explorerCollapsed, wiringSelected: afterQuickAction.wiringSelected, tabHit: afterQuickAction.reachable.find((target) => target.label === "接线") } } ]; const blockers = checks .filter((check) => check.status !== "pass") .map((check) => ({ type: "runtime_blocker", scope: check.id, status: "open", summary: check.summary })); return { status: blockers.length === 0 ? "pass" : "blocked", task: "DC-DCSN-P0-2026-003", mode: "static-mobile-browser", viewport: { width: 390, height: 844 }, url: server.url, generatedAt: new Date().toISOString(), evidenceLevel: "SOURCE", devLive: false, summary: "Static local browser smoke only; deployment still requires post-deploy live smoke.", checks, blockers, safety: staticSafety() }; } catch (error) { return { status: "blocked", task: "DC-DCSN-P0-2026-003", mode: "static-mobile-browser", viewport: { width: 390, height: 844 }, url: server.url, generatedAt: new Date().toISOString(), evidenceLevel: "SOURCE", devLive: false, summary: `Mobile browser smoke failed: ${error.message}`, checks: [], blockers: [ { type: "runtime_blocker", scope: "mobile-browser-smoke", status: "open", summary: error.message } ], safety: staticSafety() }; } finally { if (browser) await browser.close(); await server.close(); } } async function startStaticWebServer() { const server = http.createServer(async (request, response) => { const url = new URL(request.url ?? "/", "http://127.0.0.1"); let pathname = decodeURIComponent(url.pathname); if (pathname === "/" || pathname === "/workbench" || pathname === "/gate" || pathname === "/diagnostics/gate") { pathname = "/index.html"; } const filePath = path.resolve(webRoot, pathname.replace(/^\/+/, "")); if (!filePath.startsWith(webRoot)) { response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }); response.end("forbidden"); return; } try { const body = await fs.promises.readFile(filePath); response.writeHead(200, { "content-type": contentTypeFor(filePath) }); response.end(body); } catch { response.writeHead(404, { "content-type": "application/json; charset=utf-8" }); response.end(JSON.stringify({ ok: false, error: "not_found" })); } }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); 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())) }; } function contentTypeFor(filePath) { if (filePath.endsWith(".html")) return "text/html; charset=utf-8"; if (filePath.endsWith(".css")) return "text/css; charset=utf-8"; if (filePath.endsWith(".mjs") || filePath.endsWith(".js")) return "text/javascript; charset=utf-8"; if (filePath.endsWith(".md")) return "text/markdown; charset=utf-8"; return "application/octet-stream"; } async function inspectMobileWorkbench(page, { explorerOpen }) { return page.evaluate(async ({ explorerOpen }) => { function visibleTextFor(selector) { const element = document.querySelector(selector); return element?.textContent?.replace(/\s+/gu, " ").trim() ?? ""; } function boxFor(selector) { const element = document.querySelector(selector); if (!element) return null; const box = element.getBoundingClientRect(); return { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height, display: getComputedStyle(element).display }; } function ownsHit(element, hit) { return hit === element || element.contains(hit); } function inspectTarget(element, label = element.id || element.textContent?.trim() || element.tagName) { const box = element.getBoundingClientRect(); const cx = box.left + box.width / 2; const cy = box.top + box.height / 2; const hit = document.elementFromPoint(cx, cy); const hitLabel = hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}${hit.className ? `.${String(hit.className).trim().replace(/\s+/gu, ".")}` : ""}` : "none"; return { label: label.replace(/\s+/gu, " ").trim(), ok: box.width > 0 && box.height > 0 && cx >= 0 && cx <= window.innerWidth && cy >= 0 && cy <= window.innerHeight && ownsHit(element, hit), center: { x: cx, y: cy }, box: { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height }, hit: hitLabel }; } function inspectSelector(selector, label) { const element = document.querySelector(selector); if (!element) { return { label, ok: false, missing: true }; } return inspectTarget(element, label); } async function inspectScrollableTargets(selector) { const targets = [...document.querySelectorAll(selector)]; const results = []; for (const target of targets) { target.scrollIntoView({ block: "center", inline: "nearest" }); await new Promise((resolve) => requestAnimationFrame(resolve)); results.push(inspectTarget(target)); } return results; } const defaultTargets = [ inspectSelector('[data-route="workspace"]', "工作台"), inspectSelector('[data-route="gate"]', "内部复核"), inspectSelector('[data-route="help"]', "使用说明"), inspectSelector("#explorer-toggle", "资源树"), inspectSelector("#command-input", "Agent 输入"), inspectSelector("#command-send", "发送"), inspectSelector("#command-clear", "清空"), inspectSelector("#tab-control", "控制"), inspectSelector("#tab-wiring", "接线"), inspectSelector("#tab-records", "可信记录") ]; const drawerTargets = explorerOpen ? [ ...[...document.querySelectorAll(".quick-actions button")].map((element) => inspectTarget(element)), ...(await inspectScrollableTargets("#resource-tree summary, #resource-tree .tree-row")) ] : []; const reachable = explorerOpen ? drawerTargets : defaultTargets; const blocked = reachable.filter((target) => !target.ok); const text = document.body.textContent ?? ""; const workspace = document.querySelector('[data-view="workspace"]'); const gate = document.querySelector('[data-view="gate"]'); const help = document.querySelector('[data-view="help"]'); const shell = document.querySelector("[data-app-shell]"); const drawer = boxFor("#resource-explorer"); return { title: document.title, workspaceHidden: workspace ? workspace.hidden : null, gateHidden: gate ? gate.hidden : null, helpHidden: help ? help.hidden : null, explorerCollapsed: shell?.classList.contains("explorer-collapsed") ?? null, visibleText: { bodyHasWorkbench: text.includes("用户工作台") && text.includes("Agent 对话"), gateIsSecondaryLabel: text.includes("内部复核"), helpIsSecondaryLabel: text.includes("使用说明"), visibleWorkspaceText: visibleTextFor(".center-workspace").slice(0, 160) }, drawer, defaultWorkbenchReachable: document.title === "HWLAB 云工作台" && workspace?.hidden === false && gate?.hidden === true && help?.hidden === true && text.includes("用户工作台") && text.includes("Agent 对话") && text.includes("内部复核") && text.includes("使用说明"), primaryControlsReachable: defaultTargets.every((target) => target.ok), resourceControlsReachable: drawerTargets.length >= 8 && drawerTargets.every((target) => target.ok), resourceActionCollapsed: !explorerOpen && shell?.classList.contains("explorer-collapsed") === true && document.querySelector("#tab-wiring")?.getAttribute("aria-selected") === "true", wiringSelected: document.querySelector("#tab-wiring")?.getAttribute("aria-selected") === "true", reachable, blocked }; }, { explorerOpen }); } async function inspectWorkbenchScrollContract(page, { panelSelector }) { return page.evaluate(async ({ panelSelector }) => { function metrics(selector) { const element = selector === "html" ? document.documentElement : selector === "body" ? document.body : document.querySelector(selector); if (!element) return null; const style = getComputedStyle(element); return { selector, overflow: style.overflow, overflowY: style.overflowY, height: element.getBoundingClientRect().height, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, scrollTop: element.scrollTop }; } window.scrollTo(0, 240); document.documentElement.scrollTop = 240; document.body.scrollTop = 240; await new Promise((resolve) => requestAnimationFrame(resolve)); const rootAfterScrollAttempt = { windowScrollY: window.scrollY, htmlScrollTop: document.documentElement.scrollTop, bodyScrollTop: document.body.scrollTop }; const panel = document.querySelector(panelSelector); const beforePanelScroll = panel?.scrollTop ?? null; if (panel) { panel.scrollTop = 0; await new Promise((resolve) => requestAnimationFrame(resolve)); panel.scrollTop = panel.scrollHeight; await new Promise((resolve) => requestAnimationFrame(resolve)); } const afterPanelScroll = panel?.scrollTop ?? null; const html = metrics("html"); const body = metrics("body"); const shell = metrics("[data-app-shell]"); const panelMetrics = metrics(panelSelector); const rootScrollLocked = html?.overflow === "hidden" && body?.overflow === "hidden" && Math.abs((shell?.height ?? 0) - window.innerHeight) <= 2 && document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 && document.body.scrollHeight <= document.body.clientHeight + 2 && window.scrollY === 0 && document.documentElement.scrollTop === 0 && document.body.scrollTop === 0; return { viewport: { width: window.innerWidth, height: window.innerHeight }, outerScrollLocked: rootScrollLocked, panelScrolls: Boolean(panelMetrics && panelMetrics.scrollHeight > panelMetrics.clientHeight && afterPanelScroll > beforePanelScroll), rootAfterScrollAttempt, html, body, shell, panel: panelMetrics }; }, { panelSelector }); } async function inspectHelpMarkdownRoute(page) { return page.evaluate(async () => { const workspace = document.querySelector('[data-view="workspace"]'); const gate = document.querySelector('[data-view="gate"]'); const help = document.querySelector('[data-view="help"]'); const helpContent = document.querySelector("#help-content"); const heading = helpContent?.querySelector("h1"); const renderedLists = helpContent?.querySelectorAll("li").length ?? 0; const codeCount = helpContent?.querySelectorAll("code").length ?? 0; const text = helpContent?.textContent ?? ""; const helpScroll = await (async () => { if (!helpContent) return null; helpContent.scrollTop = 0; await new Promise((resolve) => requestAnimationFrame(resolve)); const before = helpContent.scrollTop; helpContent.scrollTop = helpContent.scrollHeight; await new Promise((resolve) => requestAnimationFrame(resolve)); return { before, after: helpContent.scrollTop, clientHeight: helpContent.clientHeight, scrollHeight: helpContent.scrollHeight, overflowY: getComputedStyle(helpContent).overflowY }; })(); const rootStillLocked = getComputedStyle(document.documentElement).overflow === "hidden" && getComputedStyle(document.body).overflow === "hidden" && document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 && document.body.scrollHeight <= document.body.clientHeight + 2; return { hash: window.location.hash, helpState: helpContent?.dataset.helpState ?? null, workspaceHidden: workspace ? workspace.hidden : null, gateHidden: gate ? gate.hidden : null, helpHidden: help ? help.hidden : null, heading: heading?.textContent?.trim() ?? null, renderedLists, codeCount, hasRawMarkdownHeading: Boolean(helpContent?.textContent?.includes("# 云工作台内部使用说明")), termsPresent: ["左侧资源与功能导航", "Agent 对话与工作清单", "same-origin", "/v1", ":16666"].every((term) => text.includes(term)) && codeCount >= 8, helpPanelScrolls: Boolean(helpScroll && helpScroll.scrollHeight > helpScroll.clientHeight && helpScroll.after > helpScroll.before), rootStillLocked, helpScroll, nonDefaultMarkdownHelp: window.location.hash === "#help" && workspace?.hidden === true && gate?.hidden === true && help?.hidden === false && helpContent?.dataset.helpState === "ready" && heading?.textContent?.trim() === "云工作台内部使用说明" && renderedLists >= 8 && codeCount >= 8 && !helpContent?.textContent?.includes("# 云工作台内部使用说明") && rootStillLocked && Boolean(helpScroll && helpScroll.scrollHeight > helpScroll.clientHeight && helpScroll.after > helpScroll.before) }; }); } function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); } export function printSmokeHelp() { return { status: "usage", command: "node scripts/dev-cloud-workbench-smoke.mjs --static | --live --url http://74.48.78.17:16666/", 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." ] }; }