Files
pikasTech-HWLAB/scripts/src/dev-cloud-workbench-smoke-lib.mjs
T
2026-05-23 09:05:41 +00:00

5638 lines
230 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from "node:fs";
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import http from "node:http";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { gateSummary } from "../../web/hwlab-cloud-web/gate-summary.mjs";
import { runtime } from "../../web/hwlab-cloud-web/runtime.mjs";
import {
classifyCodeAgentBrowserJourney,
classifyCodeAgentBrowserFailure,
summarizeCodeAgentPayload
} from "./code-agent-response-contract.mjs";
export { classifyCodeAgentBrowserJourney };
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 legacyFailureWindowMs = 4500;
const codeAgentLongTimeoutMs = 150000;
const localAgentFixtureDelayMs = 5200;
const codeAgentE2ePrompts = Object.freeze([
{
id: "simple-chinese",
label: "简单中文提示",
text: "请用一句话说明当前 HWLAB 工作台可以做什么。"
},
{
id: "list-skills",
label: "列出技能提示",
text: "列出你能使用的所有skill"
}
]);
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 liveIdentityWebAssets = Object.freeze(["index.html", "styles.css", "app.mjs"]);
const chineseWorkbenchLabels = Object.freeze([
"HWLAB 云工作台",
"硬件资源",
"Agent 对话",
"工作清单",
"可信记录",
"内部复核",
"使用说明"
]);
const defaultHomepageForbiddenTerms = Object.freeze([
"Gate",
"诊断",
"验收",
"M0-M5",
"M3 基本",
"BLOCKED",
"SOURCE",
"DRY-RUN",
"DEV-LIVE",
"db-live",
"m3-hardware-loop-runtime",
"trace/evidence",
"执行轨迹",
"runtime_durable_adapter_query_blocked",
"OPENAI_API_KEY",
"Secret",
"secretRef",
"hwlab-code-agent-provider",
"m3-route-required",
"waiting-for-dev-live"
]);
const diagnosticsRequiredTerms = Object.freeze([
"Gate / 诊断 / 验收",
"M0-M5",
"BLOCKED",
"DB live readiness",
"patch-panel DO1 -> DI1"
]);
const gateRouteAliases = Object.freeze(["/gate", "/diagnostics/gate"]);
const helpRouteAliases = Object.freeze(["/help"]);
const incompletePrimaryNavLabels = Object.freeze(["台", "证", "诊", "帮"]);
const requiredWiringLongTableHeaders = Object.freeze(["res_boxsimu_1", "res_boxsimu_2"]);
const forbiddenLegacyWiringColumns = Object.freeze([
"源设备",
"源端口",
"接线盘",
"目标设备",
"目标端口",
"状态",
"证据来源",
"轨迹/证据"
]);
const requiredWiringSummaryTerms = Object.freeze(["hwlab-patch-panel", "状态", "证据来源", "轨迹/证据"]);
const requiredTrustedRecordTerms = Object.freeze([
"Code Agent 对话记录",
"接线/operation",
"audit 记录",
"evidence 证据",
"trace/messageId",
"audit.event.query + SOURCE 回退",
"evidence.record.query + SOURCE 回退",
"conversationId、traceId 和 messageId",
"provider/model/trace/conversation",
"失败原因=",
"只读 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",
"hardware-status-tabs",
"hardwareKvGroup",
"/v1/m3/status",
"Gateway-SIMU",
"BOX-SIMU-1",
"BOX-SIMU-2",
"Patch Panel",
"Gateway 在线",
"BOX 在线",
"patch-panel",
"runtime durable",
"DISABLED 后续能力",
"SOURCE",
"DEV-LIVE",
"unverified_cloud_api_m3_status"
]);
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 requiredCodeAgentEvidenceTerms = Object.freeze([
"provider",
"model",
"backend",
"runner",
"workspace",
"sandbox",
"capability",
"toolCalls",
"skills",
"runnerTrace",
"conversation",
"trace",
"message",
"providerTrace",
"openai-responses",
"codex-cli",
"echo/mock/stub",
"untrusted_completion",
"SOURCE fixture",
"Code Agent SOURCE 回复",
"message-evidence-chip"
]);
const blockedCodeAgentUiLabels = Object.freeze(["服务受阻", "BLOCKED 凭证缺口"]);
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"
]);
const playwrightDependencyCommand = "npm install";
const playwrightDependencySummary =
"Install repository dependencies before running browser smokes: npm install provides the declared playwright package; the Code Queue image may also provide the same package globally.";
export function runDevCloudWorkbenchStaticSmoke() {
return runStaticSmoke();
}
export async function runDevCloudWorkbenchTimeoutFixtureSmoke(options = {}) {
return runLocalAgentFixtureSmoke({
mode: "local-agent-timeout-fixture-browser",
responseDelayMs: options.responseDelayMs ?? 120,
timeoutConfigMs: options.timeoutConfigMs ?? 50,
expectTimeout: true
});
}
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 === "m3-status-fixture") return runM3StatusFixtureSmoke();
if (args.mode === "layout") return runDevCloudWorkbenchLayoutSmoke(args);
if (args.mode === "local-agent-fixture") return runDevCloudWorkbenchLocalAgentFixtureSmoke();
if (args.mode === "mobile") return runDevCloudWorkbenchMobileSmoke();
return runStaticSmoke();
}
async function importPlaywright() {
try {
return normalizePlaywrightModule(await import("playwright"));
} catch (localError) {
const globalRoot = npmGlobalRoot();
if (globalRoot) {
try {
return normalizePlaywrightModule(await import(pathToFileURL(path.join(globalRoot, "playwright/index.js")).href));
} catch {
// Fall through to the original local resolution error so the remediation points at package.json.
}
}
throw localError;
}
}
function normalizePlaywrightModule(module) {
if (module?.chromium) return module;
if (module?.default?.chromium) return module.default;
return module;
}
function npmGlobalRoot() {
try {
return execFileSync("npm", ["root", "-g"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5000
}).trim();
} catch {
return "";
}
}
export function parseSmokeArgs(argv) {
const args = { mode: "source", url: defaultLiveUrl, confirmDevLive: false };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--source" || arg === "--static") {
args.mode = "source";
} else if (arg === "--live") {
args.mode = "live";
} else if (arg === "--confirm-dev-live") {
args.confirmDevLive = true;
} else if (arg === "--dom-only") {
args.mode = "dom-only";
} else if (arg === "--m3-status-fixture") {
args.mode = "m3-status-fixture";
} else if (arg === "--layout") {
args.mode = "layout";
} else if (arg === "--build") {
args.mode = "layout";
args.build = true;
} else if (arg === "--mobile") {
args.mode = "mobile";
args.mobile = true;
} else if (arg === "--local-agent-fixture") {
args.mode = "local-agent-fixture";
} else if (arg === "--local-agent-timeout-fixture") {
args.mode = "local-agent-timeout-fixture";
} else if (arg === "--url") {
index += 1;
if (!argv[index]) throw new Error("--url requires a value");
args.url = argv[index];
args.urlExplicit = true;
} 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 {
throw new Error(`unknown argument ${arg}`);
}
}
if (args.mode === "live" && !args.confirmDevLive) {
throw new Error("DEV-LIVE mode requires explicit --live --confirm-dev-live; default/source mode never calls the live provider.");
}
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, "direct-help-route-alias", helpRouteAliasesAreServed(files.app, artifactPublisher, buildScript, distContractScript), "/help is a direct user-facing help alias and does not become the default route.", {
blocker: "runtime_blocker",
evidence: [...helpRouteAliases, "routeFromLocation final fallback is workspace"]
});
addCheck(checks, blockers, "feedback-276-wiring-long-table", wiringTableContract(files.html, files.styles, files.app), "Patch-panel wiring panel is a two-column long table: device headers first, IO mapping rows after, and metadata moved to Chinese summary/detail text.", {
blocker: "contract_blocker",
evidence: [
...requiredWiringLongTableHeaders,
"DO1 对 DI1",
"data-wiring-layout=\"two-column-long-table\"",
"legacy 8-column table forbidden",
...requiredWiringSummaryTerms
]
});
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, "feedback-278-sidebar-collapse-contract", hasSidebarCollapseContract(files), "Left resource tree exposes explicit Chinese collapse/expand semantics and releases width for the Agent workspace plus M3 panel.", {
blocker: "runtime_blocker",
evidence: [
"收起左侧资源树 / 展开左侧资源树 aria-label",
"收起资源树 / 展开资源树 visible button text",
"explorer-collapsed sets explorer column to 0",
"collapsed desktop center column minmax(420px, 1fr)",
"right side keeps a 560-740px M3 status workspace boundary through --right-width"
]
});
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, "api-degraded-default-status", hasApiDegradedDefaultStatus(files), "Default workbench status distinguishes reachable blocked API from fully OK with concrete blocker copy.", {
blocker: "observability_blocker",
evidence: ["healthLive.data.status === degraded", "ready false", "runtime_durable_adapter_query_blocked", "runtime_durable_adapter_auth_blocked", "API 错误 / 只读模式", "可信记录受阻 / 只读模式", "no Gate/diagnostics copy in default status classifier"]
});
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-long-timeout-contract", hasCodeAgentLongTimeoutContract(files.app), "Code Agent chat uses a dedicated long timeout and does not reuse the old 4500ms API timeout.", {
blocker: "runtime_blocker",
evidence: ["DEFAULT_API_TIMEOUT_MS=4500 for light probes", `DEFAULT_CODE_AGENT_TIMEOUT_MS=${codeAgentLongTimeoutMs}`, "sendAgentMessage passes timeoutMs"]
});
addCheck(checks, blockers, "code-agent-provider-readiness-visibility", hasCodeAgentReadinessVisibility(files), "Workbench shows provider blockers without exposing credential internals and only real completed replies can become DEV-LIVE.", {
blocker: "observability_blocker",
evidence: ["服务受阻 or legacy BLOCKED 凭证缺口", "provider_unavailable classifier", "completed -> dev-live guard", "default workspace hides credential internals"]
});
addCheck(checks, blockers, "default-workspace-sanitized", defaultWorkspaceIsSanitized(files), "Default user workspace hides raw blocker codes, internal route markers, and credential reference material.", {
blocker: "observability_blocker",
evidence: defaultHomepageForbiddenTerms
});
addCheck(checks, blockers, "route-control-stable-dimensions", hasStableRouteControls(files), "Route controls and status tags have stable dimensions, readable wrapping, and usable labels on small screens.", {
blocker: "runtime_blocker",
evidence: ["rail-button min-width/min-height", "state-tag wrapping", "side-tab stable labels", "mobile message evidence column"]
});
addCheck(checks, blockers, "code-agent-completed-evidence-visible", hasCodeAgentCompletedEvidenceVisibility(files), "Completed Code Agent replies expose provider/model/trace/conversation evidence and reject echo/mock/stub completions.", {
blocker: "observability_blocker",
evidence: requiredCodeAgentEvidenceTerms
});
addCheck(checks, blockers, "code-agent-conversation-ux-states", hasCodeAgentConversationUxStates(files), "Code Agent thread renders trusted completed, SOURCE fixture, failed, and blocked provider states as distinct conversation replies with bounded evidence.", {
blocker: "observability_blocker",
evidence: [
"DEV-LIVE 回复",
"SOURCE 回复",
"发送失败",
"服务受阻 or legacy BLOCKED 凭证缺口",
"message-evidence/details/chips"
]
});
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 renders tabbed Key/Value groups",
"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: "source",
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 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: []
};
addCheck(checks, blockers, "live-web-assets-current-main", webAssetIdentity.status, webAssetIdentity.summary, {
blocker: "observability_blocker",
evidence: webAssetIdentity.assets.map((asset) => `${asset.path}: ${asset.status}`),
observations: webAssetIdentity
});
const identityBlocked = deploymentIdentity.status !== "pass" || webAssetIdentity.status !== "pass";
if (identityBlocked) {
addCheck(
checks,
blockers,
"live-code-agent-browser-journey",
"blocked",
"Browser Code Agent journey was not posted because deployed runtime/assets do not match current source identity.",
{
blocker: "observability_blocker",
evidence: [
"POST /v1/agent/chat not sent",
`runtimeIdentity=${deploymentIdentity.status}`,
`webAssetIdentity=${webAssetIdentity.status}`
],
observations: {
request: {
method: "POST",
status: "not_sent",
urlPath: "/v1/agent/chat"
},
response: blockedAgentChatResponse("deployment_identity_preflight"),
classification: {
status: "blocked",
reason: "deployment_identity_preflight",
codeAgentBrowserJourneySkipped: true
}
}
}
);
return baseReport({
mode: "live",
url: args.url,
status: "blocked",
checks,
blockers,
evidenceLevel: "BLOCKED",
devLive: false,
runtimeIdentity,
sourceIdentity,
deploymentIdentity,
expectedRuntimeIdentity,
webAssetIdentity,
safety: {
prodTouched: false,
servicesRestarted: false,
heavyE2E: false,
hardwareWriteApis: false,
sourceIsDevLive: false,
liveMode: "deployment-identity-preflight",
liveConfirmed: args.confirmDevLive === true,
codeAgentBrowserJourneySkipped: true,
prompts: codeAgentE2ePrompts.map((prompt) => ({ id: prompt.id, label: prompt.label })),
legacyFailureWindowMs,
codeAgentTimeoutMs: codeAgentLongTimeoutMs,
retainedApiFields: ["runtime.commitId", "runtime.imageTag", "web asset hash/status"],
statement: "Live smoke blocked before the browser chat journey because deployed runtime/assets do not match the current source identity; it did not post Code Agent chat, call hardware write APIs, restart services, or read secret values."
}
});
}
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 help = http.ok ? await inspectLiveHelpRoute(args.url) : { 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
});
}
const journey = http.ok ? await inspectLiveCodeAgentE2e(args.url) : { status: "skip", summary: "浏览器 Code Agent E2E 因 HTTP fetch 失败而跳过。", evidence: [] };
addCheck(checks, blockers, "live-code-agent-browser-journey", journey.status, journey.summary, {
blocker: ["pass", "degraded"].includes(journey.status) ? 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 hasDegradedCheck = checks.some((check) => check.status === "degraded");
const status = blockers.length === 0
? apiRuntimeReadiness.status === "degraded" || hasDegradedCheck ? "degraded" : "pass"
: "blocked";
return baseReport({
mode: "live",
url: args.url,
status,
checks,
blockers,
evidenceLevel: status === "pass"
? "DEV-LIVE-BROWSER"
: status === "degraded" ? "DEV-LIVE-BROWSER-DEGRADED" : "BLOCKED",
devLive: status === "pass",
runtimeIdentity,
sourceIdentity,
deploymentIdentity,
expectedRuntimeIdentity,
webAssetIdentity,
safety: {
prodTouched: false,
servicesRestarted: false,
heavyE2E: false,
hardwareWriteApis: false,
sourceIsDevLive: false,
liveMode: "browser-user-journey-with-code-agent-post",
liveConfirmed: args.confirmDevLive === true,
prompts: codeAgentE2ePrompts.map((prompt) => ({ id: prompt.id, label: prompt.label })),
legacyFailureWindowMs,
codeAgentTimeoutMs: codeAgentLongTimeoutMs,
retainedApiFields: ["status", "provider", "model", "backend", "traceId", "hasReply", "error.code", "error.missingEnv", "error.providerStatus"],
statement: status === "degraded"
? "Live smoke records deployed UI usable in degraded/read-only mode only; API health/runtime durability is degraded, so this is not full DEV-LIVE acceptance and must not be used to infer M3/M4/M5 acceptance."
: "Live smoke opens the deployed workbench and sends two controlled Code Agent messages; it does not call hardware write APIs and must not be used to infer M3 DEV-LIVE hardware-loop acceptance."
}
});
}
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."
}
});
}
export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
const useLiveUrl = args.live === true || args.urlExplicit === true;
const layoutSourceMode = useLiveUrl ? "dev-live" : args.build === true ? "local-build" : "source-static";
const artifactRoot = path.resolve(
repoRoot,
args.artifactDir ?? path.join("tmp", "dev-cloud-workbench-layout-smoke", layoutArtifactTimestamp())
);
let chromium;
try {
({ chromium } = await importPlaywright());
} catch (error) {
const summary = `Workbench layout smoke skipped because Playwright is unavailable: ${error.message}`;
return {
status: "skip",
task: "DC-DCSN-P0-2026-003",
mode: "layout-browser",
url: useLiveUrl ? args.url : null,
evidenceLevel: useLiveUrl ? "BLOCKED" : "SOURCE",
devLive: false,
summary,
checks: [
{
id: "layout-browser-dependency",
status: "skip",
summary: playwrightDependencySummary,
evidence: [playwrightDependencyCommand, "package.json dependencies.playwright"]
}
],
blockers: [
{
type: "environment_blocker",
scope: "layout-browser-dependency",
status: "open",
summary
}
],
safety: staticSafety(),
dependency: {
package: "playwright",
declaredIn: "package.json",
installCommand: playwrightDependencyCommand
},
artifacts: {
screenshotDir: artifactRoot,
reportPath: args.reportPath ?? null
}
};
}
if (!useLiveUrl && args.build === true) {
execFileSync("node", ["web/hwlab-cloud-web/scripts/build.mjs"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 30000
});
}
await fs.promises.mkdir(artifactRoot, { recursive: true });
const server = useLiveUrl
? null
: await startStaticWebServer({ rootDir: args.build === true ? path.join(webRoot, "dist") : webRoot });
const url = useLiveUrl ? args.url : server.url;
let browser;
try {
browser = await chromium.launch({ headless: true });
const layoutViewports = [
{ id: "desktop", width: 1366, height: 768, isMobile: false },
{ id: "narrow-desktop", width: 1024, height: 768, isMobile: false },
{ id: "mobile", width: 390, height: 844, isMobile: true }
];
const viewportResults = {};
for (const viewport of layoutViewports) {
viewportResults[viewport.id] = await inspectWorkbenchLayoutViewport(browser, url, viewport, { artifactRoot });
}
const desktop = viewportResults.desktop;
const narrowDesktop = viewportResults["narrow-desktop"];
const mobile = viewportResults.mobile;
const checks = [
{
id: "layout-desktop-expanded",
status: desktop.expanded.pass ? "pass" : "blocked",
viewport: desktop.expanded.viewport,
summary: "Desktop 1366x768 expanded layout keeps M3 controls, Code Agent input, hardware panels, hit targets, overflow, and outer scroll in contract.",
observations: desktop.expanded
},
{
id: "layout-desktop-collapsed",
status: desktop.collapsed.pass ? "pass" : "blocked",
viewport: desktop.collapsed.viewport,
summary: "Desktop 1366x768 collapsed layout hides the resource tree, increases Agent/M3 usable width, and keeps key controls reachable.",
observations: desktop.collapsed
},
{
id: "layout-desktop-restored",
status: desktop.restored.pass ? "pass" : "blocked",
viewport: desktop.restored.viewport,
summary: "Desktop expand action restores the left navigation/resource tree without layout overlap.",
observations: desktop.restored
},
{
id: "layout-narrow-desktop-expanded",
status: narrowDesktop.expanded.pass ? "pass" : "blocked",
viewport: narrowDesktop.expanded.viewport,
summary: "Narrow desktop 1024x768 layout keeps the right hardware area below the workbench without covered controls or outer scroll.",
observations: narrowDesktop.expanded
},
{
id: "layout-narrow-desktop-collapsed",
status: narrowDesktop.collapsed.pass ? "pass" : "blocked",
viewport: narrowDesktop.collapsed.viewport,
summary: "Narrow desktop 1024x768 collapsed layout releases left-sidebar width without hiding M3 or Code Agent controls.",
observations: narrowDesktop.collapsed
},
{
id: "layout-narrow-desktop-restored",
status: narrowDesktop.restored.pass ? "pass" : "blocked",
viewport: narrowDesktop.restored.viewport,
summary: "Narrow desktop 1024x768 restored layout returns the resource tree without incoherent overlap.",
observations: narrowDesktop.restored
},
{
id: "layout-mobile-collapsed",
status: mobile.collapsed.pass ? "pass" : "blocked",
viewport: mobile.collapsed.viewport,
summary: "Mobile default layout keeps the resource tree collapsed and preserves Agent input plus M3 tabs/controls.",
observations: mobile.collapsed
},
{
id: "layout-mobile-drawer",
status: mobile.drawer.pass ? "pass" : "blocked",
viewport: mobile.drawer.viewport,
summary: "Mobile drawer expansion makes resource controls reachable and can collapse back through the same accessible button.",
observations: mobile.drawer
},
{
id: "layout-gate-desktop",
status: desktop.gate.currentRoute.pass ? "pass" : "blocked",
viewport: desktop.gate.currentRoute.viewport,
summary: "Desktop /gate current internal page keeps root scroll locked and avoids incoherent visible overlap while #288 remains a future UI simplification.",
observations: desktop.gate.currentRoute
},
{
id: "layout-gate-narrow-desktop",
status: narrowDesktop.gate.currentRoute.pass ? "pass" : "blocked",
viewport: narrowDesktop.gate.currentRoute.viewport,
summary: "Narrow desktop /gate current internal page keeps root scroll locked and avoids incoherent visible overlap while #288 remains a future UI simplification.",
observations: narrowDesktop.gate.currentRoute
},
{
id: "layout-gate-mobile",
status: mobile.gate.currentRoute.pass ? "pass" : "blocked",
viewport: mobile.gate.currentRoute.viewport,
summary: "Mobile /gate current internal page keeps root scroll locked and avoids incoherent visible overlap while #288 remains a future UI simplification.",
observations: mobile.gate.currentRoute
},
{
id: "layout-issue-287-future-hardware-status-tabs",
status: allViewportCoverage(viewportResults, "issue287HardwareTabsReady") ? "pass" : "skip",
summary: "#287 hardware status tab set is not required for this #273 smoke until the wider live hardware workspace is implemented; current visible right-side containers are still checked for overflow.",
observations: summarizeCoverageGap(viewportResults, "issue287")
},
{
id: "layout-issue-288-future-single-table-gate",
status: allViewportCoverage(viewportResults, "issue288SingleTableReady") ? "pass" : "skip",
summary: "#288 single-table internal review page is not required for this #273 smoke until that UI lands; current /gate route is checked only for outer-scroll and visible-layout regressions.",
observations: summarizeCoverageGap(viewportResults, "issue288")
}
];
const blockers = checks
.filter((check) => check.status !== "pass")
.filter((check) => check.status !== "skip")
.map((check) => ({
type: "runtime_blocker",
scope: check.id,
status: "open",
summary: check.summary,
viewport: check.viewport ?? null,
selector: firstFailure(check)?.selector ?? null,
failureType: firstFailure(check)?.failureType ?? "blocked"
}));
const failures = checks.flatMap((check) =>
(check.observations?.failures ?? []).map((failure) => ({
checkId: check.id,
viewport: check.viewport ?? failure.viewport ?? null,
selector: failure.selector ?? null,
failureType: failure.failureType ?? "blocked",
summary: failure.summary ?? check.summary,
...failure
}))
);
const skipped = checks
.filter((check) => check.status === "skip")
.map((check) => ({
checkId: check.id,
status: "skip",
failureType: "skip",
summary: check.summary,
observations: check.observations
}));
return {
status: blockers.length === 0 ? "pass" : "blocked",
task: "DC-DCSN-P0-2026-003",
mode: "layout-browser",
sourceMode: layoutSourceMode,
url,
generatedAt: new Date().toISOString(),
evidenceLevel: useLiveUrl ? blockers.length === 0 ? "DEV-LIVE-LAYOUT" : "BLOCKED" : "SOURCE",
devLive: false,
summary: useLiveUrl
? "Live browser layout smoke verifies workbench layout, hit targets, overflow, /gate usability, and the two-column wiring panel only; it is not M3 hardware acceptance."
: "Static local browser layout smoke verifies workbench layout, hit targets, overflow, /gate usability, and the two-column wiring panel only; deployment still requires DEV live verification.",
refs: ["pikasTech/HWLAB#273", "pikasTech/HWLAB#276", "pikasTech/HWLAB#278", "pikasTech/HWLAB#287", "pikasTech/HWLAB#288", "pikasTech/HWLAB#99", "pikasTech/HWLAB#227"],
viewports: layoutViewports.map(({ id, width, height }) => ({ id, width, height })),
checks,
blockers,
failures,
skipped,
artifacts: {
screenshotDir: artifactRoot,
screenshots: Object.values(viewportResults).flatMap((result) => [
...(result.artifacts?.screenshots ?? []),
...(result.gate?.currentRoute?.artifacts?.screenshots ?? [])
]),
reportPath: args.reportPath ?? null
},
safety: {
...staticSafety(),
sourceIsDevLive: false,
layoutOnly: true,
codeAgentPostSent: false,
hardwareWriteApis: false,
hitTestMethod: "document.elementsFromPoint plus normal Playwright clicks without forced clicks",
statement: "Layout smoke opens the workbench, uses real Playwright clicks for sidebar controls, and samples hit targets with elementsFromPoint. It does not send Code Agent chat, call M3 IO, mutate DEV, or claim M3 DEV-LIVE hardware-loop acceptance."
}
};
} catch (error) {
return {
status: "blocked",
task: "DC-DCSN-P0-2026-003",
mode: "layout-browser",
sourceMode: layoutSourceMode,
url,
generatedAt: new Date().toISOString(),
evidenceLevel: useLiveUrl ? "BLOCKED" : "SOURCE",
devLive: false,
summary: `Workbench layout smoke failed: ${error.message}`,
checks: [],
blockers: [
{
type: "runtime_blocker",
scope: "layout-browser-smoke",
status: "open",
summary: error.message,
selector: null,
failureType: "blocked",
viewport: null
}
],
failures: [
{
selector: null,
failureType: "blocked",
viewport: null,
summary: error.message
}
],
artifacts: {
screenshotDir: artifactRoot,
screenshots: [],
reportPath: args.reportPath ?? null
},
safety: staticSafety()
};
} finally {
if (browser) await browser.close();
if (server) await server.close();
}
}
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,
checks,
blockers,
evidenceLevel,
devLive,
help = undefined,
safety,
url = null,
runtimeIdentity = null,
sourceIdentity = observeSourceIdentity(),
deploymentIdentity = null,
expectedRuntimeIdentity = null,
webAssetIdentity = null
}) {
const liveJourneyPassed = mode === "live" && status === "pass";
const liveJourneyDegraded = mode === "live" && status === "degraded";
const sourceOnlyMode = mode === "source";
return {
$schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json",
$id: reportModeId(mode),
reportVersion: "v1",
status,
issue: "pikasTech/HWLAB#7",
taskId: "dev-cloud-workbench-live",
commitId: sourceIdentity.reportCommitId,
acceptanceLevel: sourceOnlyMode ? "dev_cloud_workbench_source" : "dev_cloud_workbench_live",
devOnly: true,
prodDisabled: true,
reportLifecycle: {
version: "v1",
state: "active",
activeEndpoint: runtime.endpoints.api,
activeBrowserEndpoint: runtime.endpoints.frontend,
deprecatedEndpoint: null,
summary: sourceOnlyMode
? "SOURCE-only Workbench contract report; it does not call the live provider and cannot be used as DEV-LIVE evidence."
: liveJourneyDegraded
? "Current deployed browser user journey report for the 16666 Cloud Workbench; deployed UI usable in degraded/read-only mode and does not substitute for M3/M4/M5 DEV-LIVE acceptance."
: "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#143",
"pikasTech/HWLAB#164",
"pikasTech/HWLAB#224",
"pikasTech/HWLAB#276"
],
mode,
url,
generatedAt: new Date().toISOString(),
evidenceLevel,
devLive,
sourceIdentity,
runtimeIdentity: runtimeIdentity ?? notObservedRuntimeIdentity("live runtime identity was not observed in this smoke mode"),
...(expectedRuntimeIdentity ? { expectedRuntimeIdentity } : {}),
...(deploymentIdentity ? { deploymentIdentity } : {}),
...(webAssetIdentity ? { webAssetIdentity } : {}),
sourceContract: {
status: "pass",
documents: [
"docs/cloud-web-workbench.md",
"docs/reference/cloud-workbench.md",
"docs/reference/code-agent-chat-readiness.md"
],
summary: sourceOnlyMode
? "SOURCE mode verifies repository Workbench and Code Agent readiness contracts without calling the live provider."
: "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 --source",
"node scripts/dev-cloud-workbench-smoke.mjs --layout",
"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 --layout --url http://74.48.78.17:16666/",
"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",
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: {
status: mode === "source" ? status : "not_run",
commands: [
"node scripts/dev-cloud-workbench-smoke.mjs --source",
"node scripts/dev-cloud-workbench-smoke.mjs --layout",
"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"
],
evidence: ["Source mode verifies the source contract and same-origin Code Agent wiring only.", "Layout browser mode verifies desktop/mobile sidebar collapse, hit targets, non-overlap, and outer-scroll lock at SOURCE level.", "Mobile browser mode verifies the 390x844 outer-scroll lock and help route at SOURCE level.", "Local fixture browser mode verifies both Code Agent prompts past the old 4500ms window at SOURCE level.", "Local timeout fixture verifies bounded timeout UI classification without live acceptance."],
summary: "Source, layout, mobile, and local fixture workbench smokes are SOURCE-level evidence and do not prove the deployed browser journey."
},
dryRun: {
status: "not_applicable",
commands: ["node scripts/dev-cloud-workbench-smoke.mjs --source"],
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: liveJourneyPassed ? "pass" : liveJourneyDegraded ? "degraded" : "blocked",
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."
},
endpoints: {
frontend: runtime.endpoints.frontend,
api: runtime.endpoints.api,
edge: runtime.endpoints.edge
},
checks,
blockers,
help,
safety
};
}
function reportModeId(mode) {
return mode === "layout"
? "https://hwlab.pikastech.local/reports/dev-gate/dev-cloud-workbench-layout.json"
: mode === "dom-only"
? "https://hwlab.pikastech.local/reports/dev-gate/dev-cloud-workbench-dom-only.json"
: "https://hwlab.pikastech.local/reports/dev-gate/dev-cloud-workbench-live.json";
}
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 observeSourceIdentity() {
const head = runGit(["rev-parse", "--verify", "HEAD"]);
const shortHead = head ? runGit(["rev-parse", "--short=12", "HEAD"]) : null;
const ref = runGit(["rev-parse", "--abbrev-ref", "HEAD"]) ?? process.env.GITHUB_HEAD_REF ?? process.env.GITHUB_REF_NAME ?? "unknown";
const statusOutput = runGit(["status", "--porcelain"]);
const envIdentity = head ? null : environmentCommitIdentity();
const commitId = head ?? envIdentity?.commitId ?? "unknown";
const shortCommitId = shortHead ?? envIdentity?.commitId.slice(0, 12) ?? "unknown";
const dirty = statusOutput === null ? null : statusOutput.length > 0;
const worktreeState = dirty === null ? "unknown" : dirty ? "dirty" : "clean";
const source = head ? "git-head" : envIdentity ? "environment" : "unknown";
const reportCommitId = commitId !== "unknown" && worktreeState === "clean" ? shortCommitId : "unknown";
return {
status: commitId === "unknown" ? "unknown" : "observed",
source,
...(envIdentity ? { environmentKey: envIdentity.key } : {}),
commitId,
shortCommitId,
ref,
worktreeState,
dirty,
reportCommitId,
summary: worktreeState !== "clean"
? "Source HEAD was observed, but the worktree was not cleanly attributable when the report was generated; top-level commitId is unknown rather than pretending to identify uncommitted source."
: "Source identity was derived from the current worktree or source environment and is separate from the live runtime identity."
};
}
function environmentCommitIdentity() {
for (const key of [
"GITHUB_SHA",
"CI_COMMIT_SHA",
"BUILDKITE_COMMIT",
"CODEBUILD_RESOLVED_SOURCE_VERSION",
"SOURCE_COMMIT",
"HWLAB_SOURCE_COMMIT_ID"
]) {
const value = process.env[key];
if (typeof value === "string" && /^[a-f0-9]{7,40}$/iu.test(value.trim())) {
return { key, commitId: value.trim().toLowerCase() };
}
}
return null;
}
function runGit(args) {
try {
return execFileSync("git", args, {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5000
}).trim();
} catch {
return null;
}
}
export function classifyLiveDeploymentIdentity(sourceIdentity, runtimeIdentity, expectedRuntimeIdentity = null) {
const expectedCommit = sanitizeRevision(expectedRuntimeIdentity?.commitId ?? sourceIdentity?.commitId);
const expectedShort = expectedCommit === "unknown" ? "unknown" : expectedCommit.slice(0, 7);
const expectedReport = sourceIdentity?.reportCommitId ?? "unknown";
const expectedImageTag = sanitizeRuntimeString(expectedRuntimeIdentity?.imageTag ?? expectedShort).toLowerCase();
const observedRuntimeCommit = sanitizeRevision(runtimeIdentity?.commitId);
const observedImageTag = sanitizeRuntimeString(runtimeIdentity?.imageTag).toLowerCase();
const runtimeObserved = runtimeIdentity?.status === "observed";
const expectedObserved = expectedRuntimeIdentity === null || expectedRuntimeIdentity?.status === "observed";
const sourceClean =
sourceIdentity?.status === "observed" &&
sourceIdentity?.worktreeState === "clean" &&
expectedReport !== "unknown";
const accepted = new Set([...commitIdentityVariants(expectedCommit), ...commitIdentityVariants(expectedImageTag)]);
const runtimeMatches =
runtimeObserved &&
sourceClean &&
expectedObserved &&
expectedCommit !== "unknown" &&
(identityVariantMatches(accepted, observedRuntimeCommit) || identityVariantMatches(accepted, observedImageTag));
if (runtimeMatches) {
return {
status: "pass",
expectedCommit,
expectedReportCommitId: expectedReport,
expectedImageTag,
expectedSource: expectedRuntimeIdentity?.source ?? "source-git-head",
observedRuntimeCommit,
observedImageTag,
evidence: [
`expected=${expectedShort}`,
`expectedImageTag=${expectedImageTag}`,
`runtimeCommit=${observedRuntimeCommit}`,
`imageTag=${observedImageTag}`
],
summary: "Live API runtime identity matches the current source desired runtime identity."
};
}
const reason = !sourceClean
? "current source identity is not cleanly attributable"
: !expectedObserved || expectedCommit === "unknown"
? "source desired runtime identity was not observed"
: !runtimeObserved
? "live API runtime identity was not observed"
: "live API runtime commit/image tag does not match current source desired runtime identity";
return {
status: "blocked",
expectedCommit,
expectedReportCommitId: expectedReport,
expectedImageTag,
expectedSource: expectedRuntimeIdentity?.source ?? "source-git-head",
observedRuntimeCommit,
observedImageTag,
reason,
evidence: [
`expected=${expectedShort}`,
`expectedImageTag=${expectedImageTag}`,
`runtimeCommit=${observedRuntimeCommit}`,
`imageTag=${observedImageTag}`
],
summary: `Deployment drift: ${reason}.`
};
}
function observeExpectedRuntimeIdentity(serviceId) {
const deploy = readJsonOrNull("deploy/deploy.json");
const catalog = readJsonOrNull("deploy/artifact-catalog.dev.json");
const deployService = Array.isArray(deploy?.services)
? deploy.services.find((service) => service?.serviceId === serviceId)
: null;
const catalogService = Array.isArray(catalog?.services)
? catalog.services.find((service) => service?.serviceId === serviceId)
: null;
const image = sanitizeRuntimeString(catalogService?.image ?? deployService?.image);
const imageTag = sanitizeRuntimeString(catalogService?.imageTag ?? deployService?.env?.HWLAB_IMAGE_TAG ?? imageTagFromImage(image));
const commitId = sanitizeRevision(catalogService?.commitId ?? deployService?.env?.HWLAB_COMMIT_ID ?? deploy?.commitId ?? catalog?.commitId ?? imageTag);
const sources = [];
if (catalogService) sources.push("deploy/artifact-catalog.dev.json");
if (deployService || deploy?.commitId) sources.push("deploy/deploy.json");
const observed = commitId !== "unknown" || imageTag !== "unknown";
return {
status: observed ? "observed" : "not_observed",
serviceId,
source: sources.length > 0 ? sources.join("+") : "not_observed",
commitId,
imageTag,
image,
summary: observed
? "Expected DEV runtime identity was derived from current source deploy desired state, not from live health."
: "Expected DEV runtime identity was not found in current source deploy desired state."
};
}
function readJsonOrNull(relativePath) {
try {
return JSON.parse(readText(relativePath));
} catch {
return null;
}
}
function imageTagFromImage(image) {
if (typeof image !== "string") return "unknown";
const match = image.match(/:([^:/]+)$/u);
return match?.[1] ?? "unknown";
}
function commitIdentityVariants(commitId) {
if (typeof commitId !== "string" || !/^[a-f0-9]{7,40}$/u.test(commitId)) return new Set();
return new Set([commitId, commitId.slice(0, 12), commitId.slice(0, 7)]);
}
function identityVariantMatches(accepted, observed) {
if (typeof observed !== "string" || observed === "unknown" || observed === "not_observed") return false;
if (accepted.has(observed)) return true;
return [...accepted].some((expected) => expected.length >= 7 && observed.startsWith(expected));
}
async function inspectLiveWebAssetIdentity(baseUrl, fetchedIndexHtml) {
const assets = [];
for (const assetPath of liveIdentityWebAssets) {
const sourceText = readText(`web/hwlab-cloud-web/${assetPath}`);
const live = assetPath === "index.html"
? { ok: true, status: 200, contentType: "text/html; charset=utf-8", body: fetchedIndexHtml }
: await fetchText(new URL(assetPath, baseUrl).toString());
assets.push(compareLiveAsset(assetPath, live, sourceText));
}
return classifyLiveWebAssetIdentity(assets);
}
export function classifyLiveWebAssetIdentity(assets) {
const mismatches = assets.filter((asset) => asset.status !== "match");
return {
status: mismatches.length === 0 ? "pass" : "blocked",
assets,
mismatches: mismatches.map((asset) => asset.path),
summary: mismatches.length === 0
? "Live 16666 primary web assets match the current clean source files."
: `Deployment drift: live 16666 primary web assets differ from current source (${mismatches.map((asset) => asset.path).join(", ")}).`
};
}
function compareLiveAsset(assetPath, live, sourceText) {
const liveText = typeof live?.body === "string" ? live.body : "";
const sourceHash = hashText(sourceText);
const liveHash = hashText(liveText);
const fetched = live?.ok === true && live?.status === 200;
const matches = fetched && liveText === sourceText;
return {
path: assetPath,
status: matches ? "match" : fetched ? "mismatch" : "fetch_failed",
httpStatus: live?.status ?? null,
contentType: live?.contentType ?? "",
sourceHash,
liveHash,
sourceLength: sourceText.length,
liveLength: liveText.length
};
}
function hashText(value) {
return createHash("sha256").update(value).digest("hex").slice(0, 16);
}
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) &&
/<title>\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 firstViewportText = visibleTextFromHtml(defaultFirstViewportHtml(html));
return defaultHomepageForbiddenTerms.every((term) => !textHasForbiddenTerm(firstViewportText, term));
}
function defaultWorkspaceIsSanitized({ html, app }) {
const defaultShellText = visibleTextFromHtml(defaultFirstViewportHtml(html));
const userFacingBodies = [
functionBody(app, "codeAgentStatusMessage"),
functionBody(app, "codeAgentPromptText"),
functionBody(app, "codeAgentControlSummary"),
functionBody(app, "codeAgentBlockedSummary"),
functionBody(app, "codeAgentAvailabilitySummary"),
functionBody(app, "failureMessage")
].join("\n");
return (
defaultHomepageForbiddenTerms.every((term) => !textHasForbiddenTerm(defaultShellText, term)) &&
!/OPENAI_API_KEY|hwlab-code-agent-provider|Secret|secretRef|m3-route-required|waiting-for-dev-live|BLOCKED 凭证缺口|SOURCE fixture|DEV-LIVE/u.test(userFacingBodies) &&
/服务受阻/u.test(functionBody(app, "renderAgentChatStatus")) &&
/Code Agent 服务暂不可用/u.test(functionBody(app, "codeAgentBlockedSummary")) &&
/等待可信记录/u.test(functionBody(app, "renderWiringList"))
);
}
function defaultFirstViewportHtml(html) {
return `${beforeSection(html, "help")}\n${rightSidebarDefaultHtml(html)}`;
}
function rightSidebarDefaultHtml(html) {
const start = html.search(/<aside\b[^>]*\bclass=["'][^"']*\bright-sidebar\b[^"']*["'][^>]*>/u);
if (start === -1) return "";
const wiringStart = html.slice(start).search(/<section\b[^>]*\bid=["']panel-wiring["'][^>]*>/u);
if (wiringStart !== -1) return html.slice(start, start + wiringStart);
const end = html.slice(start).search(/<\/aside>/u);
return end === -1 ? html.slice(start) : html.slice(start, start + end);
}
function visibleTextFromHtml(fragment) {
return fragment
.replace(/<script\b[\s\S]*?<\/script>/giu, " ")
.replace(/<style\b[\s\S]*?<\/style>/giu, " ")
.replace(/<[^>]+>/gu, " ")
.replace(/&nbsp;/gu, " ")
.replace(/&gt;/gu, ">")
.replace(/&lt;/gu, "<")
.replace(/&amp;/gu, "&")
.replace(/\s+/gu, " ")
.trim();
}
function textHasForbiddenTerm(text, term) {
if (/^[A-Za-z][A-Za-z0-9-]*$/u.test(term)) {
return new RegExp(`\\b${escapeRegExp(term)}\\b`, "u").test(text);
}
return text.includes(term);
}
function hasCompletePrimaryNavigation(html) {
const navLabelsPresent =
["工作台", "内部复核", "使用说明"].every((label) => html.includes(`>${label}<`)) &&
/>(?:资源树|收起资源树|展开资源树)<\/button>/u.test(html);
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 helpRouteAliasesAreServed(app, artifactPublisher, buildScript, distContractScript) {
const routeBody = functionBody(app, "routeFromLocation");
return (
/helpPathnames\(\)\.has/u.test(routeBody) &&
helpRouteAliases.every((route) => app.includes(`"${route}"`)) &&
helpRouteAliases.every((route) => artifactPublisher.includes(`routePath === "${route}"`)) &&
/buildCloudWebDist/u.test(buildScript) &&
/help\/index\.html/u.test(distContractScript) &&
/return "help"/u.test(routeBody) &&
finalRouteFallback(app) === "workspace"
);
}
function wiringTableContract(html, styles = "", app = "") {
const wiringPanel = html.match(/<section\b[^>]*\bdata-side-panel=["']wiring["'][\s\S]*?<\/section>/u)?.[0] ?? "";
const headerCount = (wiringPanel.match(/<th\b/gu) ?? []).length;
const bodyRow = wiringPanel.match(/<tr\b[^>]*\bdata-wiring-row=["']io["'][\s\S]*?<\/tr>/u)?.[0] ?? "";
const bodyCellCount = (bodyRow.match(/<td\b/gu) ?? []).length;
const renderWiringBody = functionBody(app, "renderWiringList");
return (
/data-wiring-layout=["']two-column-long-table["']/u.test(wiringPanel) &&
/class=["'][^"']*\bwiring-long-table\b/u.test(wiringPanel) &&
headerCount === 2 &&
bodyCellCount === 2 &&
requiredWiringLongTableHeaders.every((header) => wiringPanel.includes(header)) &&
["DO1", "DI1"].every((port) => wiringPanel.includes(port)) &&
requiredWiringSummaryTerms.every((term) => wiringPanel.includes(term)) &&
forbiddenLegacyWiringColumns.every((column) => !wiringPanel.includes(`<th>${column}</th>`)) &&
/id=["']wiring-summary["']/u.test(wiringPanel) &&
/overflow-x:\s*hidden/u.test(styles.match(/\.wiring-table-wrap\s*\{[\s\S]*?\}/u)?.[0] ?? "") &&
!/\.wiring-table\s*\{[\s\S]*?min-width:\s*9\d\dpx/u.test(styles) &&
!/min-width:\s*980px/u.test(styles) &&
/table-layout:\s*fixed/u.test(styles.match(/\.wiring-long-table\s*\{[\s\S]*?\}/u)?.[0] ?? "") &&
/M3_TRUSTED_ROUTE\.fromResourceId/u.test(renderWiringBody) &&
/M3_TRUSTED_ROUTE\.toResourceId/u.test(renderWiringBody) &&
/M3_TRUSTED_ROUTE\.patchPanelServiceId/u.test(renderWiringBody) &&
!/tr\.append\(cell\(M3_TRUSTED_ROUTE\.fromResourceId/u.test(renderWiringBody)
);
}
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) &&
/data-hardware-tab=["']overview["'][\s\S]*?>总览<\/button>/u.test(html) &&
/data-hardware-tab=["']gateways["'][\s\S]*?>Gateway-SIMU<\/button>/u.test(html) &&
/data-hardware-tab=["']box1["'][\s\S]*?>BOX-SIMU-1<\/button>/u.test(html) &&
/data-hardware-tab=["']box2["'][\s\S]*?>BOX-SIMU-2<\/button>/u.test(html) &&
/data-hardware-tab=["']patch["'][\s\S]*?>Patch Panel<\/button>/u.test(html) &&
/function\s+renderHardwareStatus\s*\(/u.test(app) &&
/function\s+initHardwareTabs\s*\(/u.test(app) &&
/function\s+hardwareKvGroup\s*\(/u.test(app) &&
/function\s+sourceFallbackM3Status\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) &&
/页面不直连模拟器,也不伪造硬件状态。/u.test(app) &&
/fetchJson\("\/v1\/m3\/status"\)/u.test(app) &&
/sourceFallbackM3Status\(\)/u.test(functionBody(app, "renderHardwareStatus")) &&
/\.hardware-section\s*\{/u.test(styles) &&
/\.hardware-status-tabs\s*\{/u.test(styles) &&
/\.hardware-tab\s*\{/u.test(styles) &&
/\.kv-list\s*\{/u.test(styles) &&
/\.kv-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+safeFailureReason\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) &&
/safeFailureReason\(message\.error\.message\)/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) &&
/aria-pressed=["']false["']/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")) &&
/aria-label/u.test(functionBody(app, "setExplorerCollapsed")) &&
/展开资源树/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 hasSidebarCollapseContract({ html, app, styles }) {
const setCollapsedBody = functionBody(app, "setExplorerCollapsed");
return (
/id=["']explorer-toggle["']/u.test(html) &&
/aria-label=["']收起左侧资源树["']/u.test(html) &&
/title=["']收起左侧资源树,给工作区更多宽度["']/u.test(html) &&
/>收起资源树<\/button>/u.test(html) &&
/setAttribute\("aria-label", collapsed \? "展开左侧资源树" : "收起左侧资源树"\)/u.test(setCollapsedBody) &&
/textContent = collapsed \? "展开资源树" : "收起资源树"/u.test(setCollapsedBody) &&
/title = collapsed \? "展开左侧资源树,恢复导航和硬件资源" : "收起左侧资源树,给工作区更多宽度"/u.test(setCollapsedBody) &&
/--explorer-width:\s*292px/u.test(styles) &&
/--right-width:\s*clamp\(560px,\s*38vw,\s*740px\)/u.test(styles) &&
/--right-width-expanded:\s*clamp\(560px,\s*45vw,\s*740px\)/u.test(styles) &&
/\.explorer-collapsed\s*\{[^}]*--explorer-width:\s*0px;[^}]*grid-template-columns:\s*var\(--rail-width\)\s+0\s+minmax\(420px,\s*1fr\)\s+var\(--right-width\);/su.test(styles) &&
/\.side-panel\s*\{[^}]*overflow:\s*auto;[^}]*overscroll-behavior:\s*contain;/su.test(styles)
);
}
function hasStableRouteControls({ html, styles }) {
return (
["工作台", "内部复核", "使用说明", "控制", "接线", "可信记录"].every((label) => html.includes(`>${label}<`)) &&
/>(?:资源树|收起资源树|展开资源树)<\/button>/u.test(html) &&
/\.rail-button\s*\{[^}]*min-width:\s*0;[^}]*min-height:\s*36px;[^}]*text-align:\s*center;[^}]*word-break:\s*keep-all;/su.test(styles) &&
/@media\s*\(max-width:\s*860px\)[\s\S]*?\.rail-button\s*\{[\s\S]*?min-height:\s*42px;/u.test(styles) &&
/\.side-tab\s*\{[^}]*min-width:\s*0;[^}]*min-height:\s*34px;[^}]*overflow-wrap:\s*anywhere;[^}]*word-break:\s*keep-all;/su.test(styles) &&
/\.(?:status-dot|state-tag|badge)[^{]*\{[^}]*max-width:\s*100%;[^}]*line-height:\s*1\.25;[^}]*white-space:\s*normal;[^}]*overflow-wrap:\s*anywhere;/su.test(styles) &&
/\.probe-card\s*\{[^}]*min-width:\s*0;/su.test(styles) &&
/\.probe-card strong\s*\{[^}]*line-height:\s*1\.2;[^}]*overflow-wrap:\s*anywhere;/su.test(styles) &&
/@media\s*\(max-width:\s*860px\)[\s\S]*?\.message-evidence\s*\{[\s\S]*?grid-column:\s*1;/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\/m3\/status"\)/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 hasApiDegradedDefaultStatus({ app }) {
const statusBody = functionBody(app, "workbenchApiSurfaceStatus");
const readinessBody = functionBody(app, "workbenchSurfaceReadiness");
const durableBlockBody = functionBody(app, "hasDurableRuntimeQueryBlock");
return (
/function\s+workbenchApiSurfaceStatus\s*\(/u.test(app) &&
/function\s+workbenchSurfaceReadiness\s*\(/u.test(app) &&
/function\s+hasDurableRuntimeQueryBlock\s*\(/u.test(app) &&
/surface\.durableQueryBlocked/u.test(statusBody) &&
/surface\.degraded/u.test(statusBody) &&
/function\s+readinessBooleanValues\s*\(/u.test(app) &&
/payload\.ready/u.test(functionBody(app, "readinessBooleanValues")) &&
/payload\.readiness\?\.ready/u.test(functionBody(app, "readinessBooleanValues")) &&
/readyValues\.includes\(false\)/u.test(readinessBody) &&
/runtime_durable_adapter_query_blocked/u.test(durableBlockBody) &&
/runtime_durable_adapter_/u.test(durableBlockBody) &&
/payload\.runtime\?\.blocker/u.test(durableBlockBody) &&
/payload\.readiness\?\.durability\?\.blocker/u.test(durableBlockBody) &&
/blockedLayer === "durability_query"/u.test(durableBlockBody) &&
/可信记录受阻 \/ 只读模式/u.test(statusBody) &&
/API 错误 \/ 只读模式/u.test(statusBody) &&
/apiDurableBlockerDetail\(coreProbes\)/u.test(statusBody) &&
/apiProbeErrorDetail\(coreProbes\)/u.test(statusBody) &&
/\/health\/live 可响应,但可信记录持久化尚未就绪:/u.test(app) &&
!/API 降级 \/ 只读模式/u.test(app) &&
!/runtime_durable_adapter_query_blocked|DB live readiness|Gate|诊断|验收|M0-M5|执行轨迹/u.test(statusBody)
);
}
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}`;
const userFacingBodies = [
functionBody(app, "codeAgentStatusMessage"),
functionBody(app, "codeAgentPromptText"),
functionBody(app, "codeAgentControlSummary"),
functionBody(app, "codeAgentBlockedSummary"),
functionBody(app, "codeAgentAvailabilitySummary"),
functionBody(app, "failureMessage")
].join("\n");
return (
/codeAgentAvailability/u.test(app) &&
/codeAgentAvailabilityFrom/u.test(app) &&
/sanitizeCodeAgentAvailability/u.test(app) &&
/latestCompletedAgentMessage/u.test(app) &&
blockedCodeAgentUiLabels.some((label) => app.includes(label)) &&
/provider_unavailable/u.test(app) &&
/OPENAI_API_KEY/u.test(app) &&
/只有真实完成回复才显示为完成/u.test(app) &&
/不能因为只有会话编号就当成实况完成/u.test(app) &&
/isTrustedCodeAgentProvider/u.test(app) &&
/Boolean\(value\?\.model\)/u.test(app) &&
/Boolean\(value\?\.backend\)/u.test(app) &&
/Boolean\(value\?\.traceId\)/u.test(app) &&
/Boolean\(value\?\.conversationId \|\| value\?\.sessionId\)/u.test(app) &&
/isRealCompletedChatMessage/u.test(app) &&
/isRealCompletedChatResult/u.test(app) &&
/hasRealCodeAgentEvidence/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) &&
!/OPENAI_API_KEY|hwlab-code-agent-provider|Secret|secretRef/u.test(userFacingBodies) &&
!/sk-[A-Za-z0-9._-]{8,}/u.test(source)
);
}
function hasCodeAgentCompletedEvidenceVisibility({ app }) {
const messageEvidenceBody = functionBody(app, "messageEvidenceFields");
const realEvidenceBody = functionBody(app, "hasRealCodeAgentEvidence");
return (
requiredCodeAgentEvidenceTerms.every((term) => app.includes(term)) &&
/conversationId:\s*result\.conversationId \|\| result\.sessionId \|\| state\.conversationId/u.test(app) &&
/providerTrace:\s*result\.providerTrace/u.test(app) &&
/function\s+providerTraceSummary\s*\(/u.test(app) &&
/recordField\("provider",\s*message\.provider\)/u.test(messageEvidenceBody) &&
/recordField\("model",\s*message\.model\)/u.test(messageEvidenceBody) &&
/recordField\("backend",\s*message\.backend\)/u.test(messageEvidenceBody) &&
/recordField\("runner",\s*message\.runner\?\.kind\)/u.test(messageEvidenceBody) &&
/recordField\("workspace",\s*message\.workspace\)/u.test(messageEvidenceBody) &&
/recordField\("sandbox",\s*message\.sandbox\)/u.test(messageEvidenceBody) &&
/recordField\("capability",\s*message\.capabilityLevel\)/u.test(messageEvidenceBody) &&
/recordField\("toolCalls",\s*toolCallsSummary\(message\.toolCalls\)\)/u.test(messageEvidenceBody) &&
/recordField\("skills",\s*skillsSummary\(message\.skills\)\)/u.test(messageEvidenceBody) &&
/recordField\("runnerTrace",\s*runnerTraceSummary\(message\.runnerTrace\)\)/u.test(messageEvidenceBody) &&
/recordField\("conversation",\s*message\.conversationId\)/u.test(messageEvidenceBody) &&
/recordField\("trace",\s*message\.traceId\)/u.test(messageEvidenceBody) &&
/recordField\("message",\s*message\.messageId\)/u.test(messageEvidenceBody) &&
/providerTraceSummary\(message\.providerTrace\)/u.test(messageEvidenceBody) &&
/isTrustedCodeAgentProvider\(value\?\.provider\)/u.test(realEvidenceBody) &&
/isCodexRunnerCapableEvidence\(value\)/u.test(realEvidenceBody) &&
/CODEX_RUNNER_CAPABLE_PROVIDERS/u.test(app) &&
/TRUSTED_CODE_AGENT_PROVIDERS/u.test(app) &&
/UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN/u.test(realEvidenceBody) &&
/value\?\.conversationId \|\| value\?\.sessionId/u.test(realEvidenceBody) &&
/isRealCompletedChatResult\(result\)/u.test(app) &&
/classifyCodeAgentCompletion\(result\)/u.test(app) &&
/status:\s*"completed"/u.test(functionBody(app, "classifyCodeAgentCompletion")) &&
/sourceKind:\s*"DEV-LIVE"/u.test(functionBody(app, "classifyCodeAgentCompletion")) &&
/Code Agent 完成证据不足/u.test(app) &&
/本次不会标记为真实完成/u.test(app)
);
}
function hasCodeAgentLongTimeoutContract(app) {
return (
/DEFAULT_API_TIMEOUT_MS\s*=\s*4500/u.test(app) &&
/DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*150000/u.test(app) &&
/async function sendAgentMessage[\s\S]*?fetchJson\("\/v1\/agent\/chat"[\s\S]*?timeoutMs:\s*CODE_AGENT_TIMEOUT_MS/u.test(app) &&
/timeoutMs\s*=\s*API_TIMEOUT_MS/u.test(functionBody(app, "fetchJson")) &&
/timeoutName\s*=\s*path/u.test(functionBody(app, "fetchJson")) &&
/HWLAB_CLOUD_WEB_CONFIG/u.test(app) &&
/codeAgentTimeoutMs/u.test(app)
);
}
function hasCodeAgentConversationUxStates({ app, styles }) {
const submitBody = functionBody(app, "initCommandBar");
return (
/function\s+classifyCodeAgentCompletion\s*\(/u.test(app) &&
/function\s+isSourceFixtureChatResult\s*\(/u.test(app) &&
/function\s+isSourceFixtureCompletion\s*\(/u.test(app) &&
/function\s+isSourceFixtureCompletedChatMessage\s*\(/u.test(app) &&
/function\s+messageEvidencePanel\s*\(/u.test(app) &&
/function\s+messageEvidenceSummary\s*\(/u.test(app) &&
/function\s+boundedEvidenceField\s*\(/u.test(app) &&
/DEFAULT_API_TIMEOUT_MS\s*=\s*4500/u.test(app) &&
/DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*150000/u.test(app) &&
/timeoutMs:\s*CODE_AGENT_TIMEOUT_MS/u.test(app) &&
/HWLAB_CLOUD_WEB_CONFIG/u.test(app) &&
/失败时会保留输入供重试/u.test(app) &&
/Code Agent 请求超过/u.test(app) &&
/Code Agent SOURCE 回复/u.test(app) &&
/SOURCE fixture 只可显示为 SOURCE 回复,不能冒充 DEV-LIVE/u.test(app) &&
/sourceKind:\s*completion\.sourceKind/u.test(submitBody) &&
/completion\.replied/u.test(submitBody) &&
/status-source/u.test(styles) &&
/message-user/u.test(styles) &&
/message-agent/u.test(styles) &&
/message-evidence\s*\{/u.test(styles) &&
/message-evidence-chip/u.test(styles) &&
/width:\s*min\(100%,\s*780px\)/u.test(styles) &&
/status === "completed" \? "dev-live" : status === "failed" \|\| status === "blocked" \? "blocked" : "source"/u.test(app) &&
!/sourceKind:\s*"SOURCE"[\s\S]{0,160}status:\s*"completed"/u.test(app)
);
}
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 fallbackBody = functionBody(app, "sourceFallbackM3Status");
const liveEvidenceBody = functionBody(app, "hasTrustedM3LiveEvidence");
const patchPanelReportBody = functionBody(app, "hasTrustedPatchPanelLiveReport");
const sourceFallbackStaysUnverified =
/sourceKind:\s*"SOURCE"/u.test(fallbackBody) &&
/unverified_cloud_api_m3_status/u.test(fallbackBody) &&
/SOURCE 拓扑不能冒充实况硬件状态/u.test(fallbackBody) &&
!/sourceKind:\s*"DEV-LIVE"/u.test(fallbackBody);
return (
sourceFallbackStaysUnverified &&
/sourceFallbackM3Status\(\)/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)
);
}
function m3RenderedWorkbenchNotM5Fixture(app) {
const hardwareBody = functionBody(app, "renderHardwareStatus");
const overviewBody = functionBody(app, "hardwareOverviewGroups");
const wiringBody = functionBody(app, "renderWiringList");
return (
/Key\/Value groups/u.test(hardwareBody) &&
/M3_TRUSTED_ROUTE\.fromResourceId/u.test(overviewBody) &&
/M3_TRUSTED_ROUTE\.toResourceId/u.test(overviewBody) &&
!/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,220}\/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."
};
}
export function classifyLiveApiRuntimeReadiness(runtimeIdentity) {
const healthStatus = sanitizeRuntimeString(runtimeIdentity?.healthStatus);
const runtime = runtimeIdentity?.runtime ?? {};
const readiness = runtimeIdentity?.readiness ?? {};
const durability = readiness?.durability ?? {};
const blockerCodes = Array.isArray(runtimeIdentity?.blockerCodes)
? runtimeIdentity.blockerCodes.map((item) => sanitizeRuntimeString(item))
: [];
const ready = runtimeIdentity?.ready === true || readiness?.ready === true;
const runtimeReady = runtime?.ready === true;
const durabilityReady = durability?.ready === true;
const runtimeDurable = runtime?.durable === true;
const durableBlocked = runtime?.blocker === "runtime_durable_adapter_query_blocked" ||
durability?.blocker === "runtime_durable_adapter_query_blocked" ||
blockerCodes.includes("runtime_durable_adapter_query_blocked") ||
durability?.blockedLayer === "durability_query" ||
runtime?.connection?.queryResult === "query_blocked" ||
durability?.queryResult === "query_blocked";
const degraded = healthStatus === "degraded" ||
ready === false ||
runtimeReady === false ||
durabilityReady === false ||
runtimeDurable === false ||
durableBlocked;
if (!degraded) {
return {
status: "pass",
healthStatus,
ready,
runtimeDurable,
runtimeReady,
durabilityReady,
durableBlocked,
blockerCodes,
evidence: [
`api.status=${healthStatus}`,
`ready=${ready}`,
`runtime.durable=${runtimeDurable}`,
`runtime.ready=${runtimeReady}`,
`durability.ready=${durabilityReady}`
],
summary: "Live API health and runtime durability are ready for browser journey evidence."
};
}
const blockedEvidence = [
`api.status=${healthStatus}`,
`ready=${ready}`,
`runtime.durable=${runtimeDurable}`,
`runtime.ready=${runtimeReady}`,
`durability.ready=${durabilityReady}`,
durableBlocked ? "runtime_durable_adapter_query_blocked" : null,
...blockerCodes.map((code) => `blocker=${code}`)
].filter(Boolean);
return {
status: "degraded",
healthStatus,
ready,
runtimeDurable,
runtimeReady,
durabilityReady,
durableBlocked,
blockerCodes,
evidence: blockedEvidence,
summary: "Live API is reachable but degraded/read-only; runtime durability is not ready, so this cannot be full DEV-LIVE acceptance."
};
}
async function observeLiveRuntimeIdentity(apiEndpoint) {
const endpoint = new URL("/health/live", apiEndpoint).toString();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 6000);
try {
const response = await fetch(endpoint, {
signal: controller.signal,
headers: { Accept: "application/json" }
});
let body = null;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok || !body || typeof body !== "object") {
return notObservedRuntimeIdentity(`health-live returned HTTP ${response.status}`, endpoint);
}
return {
status: "observed",
source: "health-live",
endpoint,
serviceId: sanitizeRuntimeString(body.serviceId),
environment: sanitizeRuntimeString(body.environment),
healthStatus: sanitizeRuntimeString(body.status),
ready: body.ready === true,
commitId: sanitizeRevision(body.commit?.id ?? body.commitId),
commitSource: sanitizeRuntimeString(body.commit?.source),
imageTag: sanitizeRuntimeString(body.image?.tag),
observedAt: sanitizeTimestamp(body.observedAt),
runtime: summarizeRuntimeHealth(body.runtime),
readiness: summarizeReadinessHealth(body.readiness),
blockerCodes: sanitizeStringList(body.blockerCodes),
summary: "Live runtime identity was observed through the existing read-only health endpoint and is not inferred from source git HEAD."
};
} catch (error) {
return notObservedRuntimeIdentity(
error.name === "AbortError" ? "health-live request timed out" : `health-live request failed: ${oneLine(error.message)}`,
endpoint
);
} finally {
clearTimeout(timer);
}
}
function notObservedRuntimeIdentity(reason, endpoint = new URL("/health/live", runtime.endpoints.api).toString()) {
return {
status: "not_observed",
source: "health-live",
endpoint,
serviceId: "not_observed",
environment: "not_observed",
healthStatus: "not_observed",
ready: false,
commitId: "not_observed",
commitSource: "not_observed",
imageTag: "not_observed",
observedAt: new Date().toISOString(),
runtime: {
durable: false,
ready: false,
status: "not_observed",
blocker: "not_observed"
},
readiness: {
ready: false,
status: "not_observed",
durability: {
ready: false,
status: "not_observed",
blocker: "not_observed",
blockedLayer: "not_observed",
queryResult: "not_observed"
}
},
blockerCodes: [],
reason: oneLine(reason),
summary: "Live runtime identity is recorded as not_observed; source identity must not be treated as the deployed runtime revision."
};
}
function summarizeRuntimeHealth(runtimeHealth) {
return {
durable: runtimeHealth?.durable === true,
ready: runtimeHealth?.ready === true,
status: sanitizeRuntimeString(runtimeHealth?.status),
blocker: sanitizeRuntimeString(runtimeHealth?.blocker),
connection: {
queryAttempted: runtimeHealth?.connection?.queryAttempted === true,
queryResult: sanitizeRuntimeString(runtimeHealth?.connection?.queryResult)
}
};
}
function summarizeReadinessHealth(readiness) {
return {
ready: readiness?.ready === true,
status: sanitizeRuntimeString(readiness?.status),
durability: {
ready: readiness?.durability?.ready === true,
status: sanitizeRuntimeString(readiness?.durability?.status),
blocker: sanitizeRuntimeString(readiness?.durability?.blocker),
blockedLayer: sanitizeRuntimeString(readiness?.durability?.blockedLayer),
queryResult: sanitizeRuntimeString(readiness?.durability?.queryResult)
}
};
}
function sanitizeStringList(value) {
return Array.isArray(value) ? value.map((item) => sanitizeRuntimeString(item)) : [];
}
function sanitizeRevision(value) {
if (typeof value !== "string") return "unknown";
const trimmed = value.trim().toLowerCase();
return /^[a-f0-9]{7,40}$/u.test(trimmed) ? trimmed : "unknown";
}
function sanitizeTimestamp(value) {
return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : new Date().toISOString();
}
function sanitizeRuntimeString(value) {
return typeof value === "string" && value.trim().length > 0 ? oneLine(value) : "unknown";
}
function oneLine(value) {
return String(value ?? "unknown").replace(/\s+/gu, " ").trim().slice(0, 240) || "unknown";
}
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) {
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, options = {}) {
let chromium;
try {
({ chromium } = await importPlaywright());
} 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 } });
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"]');
const gate = document.querySelector('[data-view="gate"]');
const help = document.querySelector('[data-view="help"]');
const diagnostics = document.querySelector('[data-side-panel="diagnostics"]');
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;
};
const visibleTextInFirstViewport = () => {
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
const chunks = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const textContent = node.textContent?.replace(/\s+/gu, " ").trim();
if (!textContent) continue;
const parent = node.parentElement;
if (!parent) continue;
const style = getComputedStyle(parent);
if (style.visibility === "hidden" || style.display === "none") continue;
const box = parent.getBoundingClientRect();
const inViewport = box.width > 0 && box.height > 0 && box.bottom > 0 && box.right > 0 && box.top < window.innerHeight && box.left < window.innerWidth;
if (inViewport) chunks.push(textContent);
}
return chunks.join(" ").replace(/\s+/gu, " ").trim();
};
const textHasForbiddenTerm = (candidateText, term) => {
if (/^[A-Za-z][A-Za-z0-9-]*$/u.test(term)) {
const escaped = term.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
return new RegExp(`\\b${escaped}\\b`, "u").test(candidateText);
}
return candidateText.includes(term);
};
window.scrollTo(0, 240);
document.documentElement.scrollTop = 240;
document.body.scrollTop = 240;
await new Promise((resolve) => requestAnimationFrame(resolve));
const firstViewportText = visibleTextInFirstViewport();
const rootAfterScrollAttempt = {
windowScrollY: window.scrollY,
htmlScrollTop: document.documentElement.scrollTop,
bodyScrollTop: document.body.scrollTop
};
return {
title: document.title,
bodyOverflow: bodyStyle.overflow,
htmlOverflow: htmlStyle.overflow,
workspaceHidden: workspace ? workspace.hidden : null,
gateHidden: gate ? gate.hidden : null,
helpHidden: help ? help.hidden : null,
diagnosticsHidden: diagnostics ? diagnostics.hidden : null,
outerScrollLocked: document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2,
rootAfterScrollAttempt,
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() === "内部复核"),
wiringLayout: document.querySelector(".wiring-table-wrap")?.getAttribute("data-wiring-layout") ?? "",
wiringHeaderDevices: [
document.querySelector("#wiring-source-device")?.textContent?.trim() ?? "",
document.querySelector("#wiring-target-device")?.textContent?.trim() ?? ""
],
wiringHeaderCount: document.querySelectorAll(".wiring-table thead th").length,
legacyWiringHeaders: [...document.querySelectorAll(".wiring-table thead th")].map((cell) => cell.textContent?.trim() ?? "").filter((text) =>
["源设备", "源端口", "接线盘", "目标设备", "目标端口", "状态", "证据来源", "轨迹/证据"].includes(text)
),
m3WiringRows: [...document.querySelectorAll("#wiring-body tr")].map((row) =>
[...row.querySelectorAll("td")].map((cell) => cell.textContent?.replace(/\s+/gu, " ").trim() ?? "")
),
wiringBodyCellCounts: [...document.querySelectorAll("#wiring-body tr")].map((row) => row.querySelectorAll("td").length),
wiringSummaryText: document.querySelector("#wiring-summary")?.textContent?.replace(/\s+/gu, " ").trim() ?? "",
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")
}
};
}, [...defaultHomepageForbiddenTerms]);
const pass =
dom.title === "HWLAB 云工作台" &&
dom.bodyOverflow === "hidden" &&
dom.htmlOverflow === "hidden" &&
dom.workspaceHidden === false &&
dom.gateHidden === true &&
(dom.helpHidden === true || dom.helpHidden === null) &&
(dom.diagnosticsHidden === true || dom.diagnosticsHidden === null) &&
dom.outerScrollLocked &&
dom.rootAfterScrollAttempt.windowScrollY === 0 &&
dom.rootAfterScrollAttempt.htmlScrollTop === 0 &&
dom.rootAfterScrollAttempt.bodyScrollTop === 0 &&
dom.firstViewportForbiddenPresent.length === 0 &&
dom.labelsPresent &&
dom.navLabelsPresent &&
dom.defaultBackstageHidden &&
dom.gateRouteAvailable &&
dom.wiringLayout === "two-column-long-table" &&
dom.wiringHeaderCount === 2 &&
dom.legacyWiringHeaders.length === 0 &&
requiredWiringLongTableHeaders.every((header) => dom.wiringHeaderDevices.includes(header)) &&
dom.wiringBodyCellCounts.every((count) => count === 2) &&
dom.m3WiringRows.some((row) =>
row.length === 2 &&
row[0].includes("DO1") &&
row[0].includes("hwlab-patch-panel") &&
row[1].includes("DI1") &&
row[1].includes("证据来源") &&
row[1].includes("轨迹/证据")
) &&
requiredWiringSummaryTerms.every((term) => dom.wiringSummaryText.includes(term)) &&
(postGuard ? postGuard.attempts.length === 0 : true) &&
Object.values(dom.coreControlsVisible).every(Boolean);
return {
status: pass ? "pass" : "blocked",
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}`,
`firstViewportForbidden=${dom.firstViewportForbiddenPresent.join(",") || "none"}`,
`gateRouteAvailable=${dom.gateRouteAvailable}`,
`wiringLayout=${dom.wiringLayout}`,
`wiringHeaderDevices=${dom.wiringHeaderDevices.join("|")}`,
`wiringBodyCellCounts=${dom.wiringBodyCellCounts.join("|")}`,
`m3WiringRows=${dom.m3WiringRows.length}`,
...(postGuard ? [`codeAgentPostAttempts=${postGuard.attempts.length}`] : [])
],
observations: {
...dom,
...(postGuard ? { codeAgentPostGuard: postGuard.observation() } : {})
}
};
} 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();
}
}
async function inspectLiveHelpRoute(url, options = {}) {
let chromium;
try {
({ chromium } = await importPlaywright());
} catch (error) {
return {
status: "skip",
summary: `Browser Markdown help 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(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 && (postGuard ? postGuard.attempts.length === 0 : true);
return {
status: pass ? "pass" : "blocked",
summary: pass
? "Desktop browser help route remains non-default, markdown-rendered, and scrollable."
: "Desktop browser help route did not satisfy the markdown workbench contract.",
evidence: [
`hash=${help.hash ?? "unknown"}`,
`heading=${help.heading ?? "unknown"}`,
`rootStillLocked=${help.rootStillLocked}`,
`helpPanelScrolls=${help.helpPanelScrolls}`,
...(postGuard ? [`codeAgentPostAttempts=${postGuard.attempts.length}`] : [])
],
observations: {
...help,
...(postGuard ? { codeAgentPostGuard: postGuard.observation() } : {})
}
};
} catch (error) {
return {
status: "blocked",
summary: `Desktop browser help route failed: ${error.message}`,
evidence: ["browser navigation or markdown render failed"]
};
} finally {
if (browser) await browser.close();
}
}
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 {
({ chromium } = await importPlaywright());
} catch (error) {
return {
status: "skip",
summary: `浏览器 Code Agent E2E 跳过:Playwright 不可用:${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: 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: "浏览器故障:无法可靠定位 Workbench Code Agent 控件。",
evidence: ["selectors=#command-input,#command-send,#agent-chat-status,#conversation-list,#hardware-list"],
observations: { controls }
};
}
const promptResults = [];
for (const prompt of codeAgentE2ePrompts) {
promptResults.push(await runCodeAgentPromptJourney(page, prompt));
}
const firstBlocked = promptResults.find((result) => result.status === "blocked");
const degraded = promptResults.some((result) => result.status === "degraded");
const status = firstBlocked ? "blocked" : degraded ? "degraded" : "pass";
return {
status,
summary: status === "pass"
? "DEV-LIVE 浏览器 E2E 已发送两个 Code Agent 提示,并确认旧 4500ms 窗口未形成永久失败。"
: status === "degraded"
? "DEV-LIVE 浏览器 E2E 未命中旧 4500ms 永久失败路径;至少一个提示在配置的长时间边界后返回清晰后端超时。"
: firstBlocked.classification?.chineseSummary ?? firstBlocked.summary,
evidence: [
`prompts=${promptResults.length}`,
`legacyFailureWindowMs=${legacyFailureWindowMs}`,
`codeAgentTimeoutMs=${codeAgentLongTimeoutMs}`,
...promptResults.map((result) => `${result.promptId}: status=${result.status}; blocker=${result.classification?.blocker ?? "none"}; traceId=${result.response?.traceId ?? result.traceId ?? "unknown"}`)
],
observations: {
controls,
request: promptResults.length === 1 ? promptResults[0].request : {
method: "POST",
status: promptResults.every((result) => result.request?.status === 200) ? 200 : "mixed",
urlPath: "/v1/agent/chat",
count: promptResults.length
},
response: promptResults[0]?.response ?? blockedAgentChatResponse("not_observed"),
classification: firstBlocked?.classification ?? promptResults.find((result) => result.status === "degraded")?.classification ?? {
status: "pass",
blocker: null,
reason: "both configured Code Agent browser prompts completed without a permanent 4500ms UI failure"
},
prompts: promptResults,
ui: promptResults.at(-1)?.ui ?? null,
networkEvents: promptResults.flatMap((result) => result.networkEvents ?? []),
legacyFailureWindowMs,
codeAgentTimeoutMs: codeAgentLongTimeoutMs
}
};
} catch (error) {
const classification = classifyCodeAgentBrowserFailure(error);
return {
status: "blocked",
summary: classification.chineseSummary,
evidence: ["browser navigation, selector, or POST failed", `category=${classification.category}`],
observations: { classification }
};
} finally {
if (browser) await browser.close();
}
}
async function runCodeAgentPromptJourney(page, prompt, options = {}) {
const agentRequests = [];
const agentResponses = [];
const requestListener = (request) => {
if (!request.url().includes("/v1/agent/chat")) return;
const body = parseJsonOrNull(request.postData() ?? "");
agentRequests.push({
method: request.method(),
urlPath: new URL(request.url()).pathname,
traceId: stringOrFallback(body?.traceId, request.headers()["x-trace-id"] ?? "unknown"),
messageLength: typeof body?.message === "string" ? body.message.length : 0
});
};
const responseListener = 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, { httpStatus: response.status() })
});
};
page.on("request", requestListener);
page.on("response", responseListener);
try {
const beforeMessageCount = await page.locator(".message-card").count();
await page.locator("#command-input").fill(prompt.text);
let responseSeenBeforeLegacyWindow = false;
const responsePromise = page.waitForResponse(
(response) => response.url().includes("/v1/agent/chat") && response.request().method() === "POST",
{ timeout: codeAgentLongTimeoutMs + 5000 }
).then((response) => {
responseSeenBeforeLegacyWindow = true;
return response;
});
await page.locator("#command-send").click();
await page.waitForTimeout(legacyFailureWindowMs + 350);
const legacyWindow = await inspectLegacyFailureWindow(page, { beforeMessageCount });
legacyWindow.responseSeenBeforeLegacyWindow = responseSeenBeforeLegacyWindow;
if (legacyWindow.permanentFailureAround4500ms && !responseSeenBeforeLegacyWindow) {
responsePromise.catch(() => null);
const ui = await inspectJourneyUi(page).catch(() => null);
const traceId = agentResponses.at(-1)?.body?.traceId ?? agentRequests.at(-1)?.traceId ?? "unknown";
return {
promptId: prompt.id,
promptLabel: prompt.label,
status: "blocked",
summary: `浏览器故障:前端在 ${legacyFailureWindowMs}ms 附近把 Code Agent 请求标为永久失败;traceId=${traceId}。`,
traceId,
request: {
method: "POST",
status: "not_observed",
urlPath: "/v1/agent/chat"
},
response: blockedAgentChatResponse("frontend-legacy-timeout"),
classification: {
status: "blocked",
blocker: "frontend-legacy-timeout",
category: "browser",
chineseSummary: `浏览器故障:前端在 ${legacyFailureWindowMs}ms 附近把 Code Agent 请求标为永久失败;traceId=${traceId}。`,
reason: "frontend marked Code Agent request as permanent failure around the old 4500ms timeout path"
},
ui,
legacyWindow,
requestEvents: agentRequests,
networkEvents: agentResponses
};
}
const response = await responsePromise;
let responseBody = null;
try {
responseBody = await response.json();
} catch {
responseBody = null;
}
if (options.sourceFixture === true) {
await page.waitForFunction(
() => document.querySelector("#agent-chat-status")?.textContent?.trim() === "SOURCE 回复",
null,
{ timeout: 8000 }
).catch(() => null);
} else {
await page.waitForTimeout(500);
}
const ui = await inspectJourneyUi(page);
const responseSummary = sanitizeAgentChatBody(responseBody, { httpStatus: response.status() });
let classification = classifyCodeAgentBrowserJourney({
responseSummary,
httpOk: response.ok(),
httpStatus: response.status(),
ui
});
if (
options.sourceFixture === true &&
response.ok() &&
responseSummary?.status === "completed" &&
responseSummary?.sourceKind === "SOURCE" &&
responseSummary?.hasReply === true &&
ui.agentChatStatus === "SOURCE 回复"
) {
classification = {
status: "pass",
blocker: null,
sourceOnly: true,
reason: "SOURCE fixture completed through the browser path; this is not DEV-LIVE evidence",
chineseSummary: "SOURCE fixture 完成:浏览器链路通过,但该证据不能标为 DEV-LIVE。"
};
}
const legacyWindowPass = !legacyWindow.permanentFailureAround4500ms;
const traceId = responseSummary?.traceId ?? agentResponses.at(-1)?.body?.traceId ?? agentRequests.at(-1)?.traceId ?? "unknown";
const backendTimeoutMs = backendTimeoutMsFromPayload(responseBody);
if (
legacyWindowPass &&
response.ok() &&
responseSummary?.status === "failed" &&
responseSummary?.error?.code === "provider_unavailable" &&
backendTimeoutMs !== null &&
backendTimeoutMs >= legacyFailureWindowMs
) {
classification = {
status: "degraded",
blocker: null,
terminalState: "backend-timeout",
backendTimeoutMs,
traceId,
chineseSummary: `后端超时:Code Agent 在配置的长时间边界后返回清晰超时,不是旧 ${legacyFailureWindowMs}ms 前端永久失败;traceId=${traceId}。`,
reason: "clear backend timeout after the configured longer bound"
};
}
const accepted = ["pass", "degraded"].includes(classification.status) && legacyWindowPass;
const returnedClassification = accepted ? classification : legacyWindowPass ? classification : {
status: "blocked",
blocker: "frontend-legacy-timeout",
category: "browser",
chineseSummary: `浏览器故障:前端在 ${legacyFailureWindowMs}ms 附近把 Code Agent 请求标为永久失败;traceId=${traceId}。`,
reason: "frontend marked heavy Code Agent request as permanent failure around the old 4500ms timeout path"
};
return {
promptId: prompt.id,
promptLabel: prompt.label,
status: accepted ? classification.status : "blocked",
summary: accepted
? classification.terminalState === "backend-timeout"
? `${prompt.label}返回清晰后端超时,且 ${legacyFailureWindowMs}ms 窗口没有永久失败。`
: `${prompt.label}完成,且 ${legacyFailureWindowMs}ms 窗口没有永久失败。`
: returnedClassification.chineseSummary ?? returnedClassification.reason,
traceId,
request: {
method: response.request().method(),
status: response.status(),
urlPath: new URL(response.url()).pathname
},
response: responseSummary,
classification: returnedClassification,
ui,
legacyWindow,
requestEvents: agentRequests,
networkEvents: agentResponses
};
} catch (error) {
const traceId = agentResponses.at(-1)?.body?.traceId ?? agentRequests.at(-1)?.traceId ?? null;
const classification = classifyCodeAgentBrowserFailure(error, { traceId });
return {
promptId: prompt.id,
promptLabel: prompt.label,
status: "blocked",
summary: classification.chineseSummary,
traceId: traceId ?? "unknown",
request: {
method: "POST",
status: "not_observed",
urlPath: "/v1/agent/chat"
},
response: blockedAgentChatResponse(classification.blocker),
classification,
ui: await inspectJourneyUi(page).catch(() => null),
legacyWindow: null,
requestEvents: agentRequests,
networkEvents: agentResponses
};
} finally {
page.off("request", requestListener);
page.off("response", responseListener);
}
}
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(() => {
const failedText = [...document.querySelectorAll(".message-card.status-failed")]
.map((element) => element.textContent ?? "")
.join("\n");
const commandValue = document.querySelector("#command-input")?.value ?? "";
return {
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: commandValue === "",
retryInputPreserved: commandValue === "请用一句话说明当前 HWLAB 工作台可以做什么。",
completedMessageVisible: Boolean(document.querySelector(".message-card.status-completed")),
sourceMessageVisible: Boolean(document.querySelector(".message-card.status-source")),
failedMessageVisible: Boolean(document.querySelector(".message-card.status-failed")),
failedMessageHasTrace: /trace=trc_/u.test(failedText),
failedMessageHasChineseTimeout: /请求超过|请求超时|保留输入|重试/u.test(failedText),
messageCount: document.querySelectorAll(".message-card").length,
completedMessageHasNonSensitiveMeta: Boolean(
[...document.querySelectorAll(".message-card.status-completed .message-meta")]
.some((element) => /openai-responses|codex-readonly-runner|gpt-5\.5|runner=|workspace=|toolCalls=|trc_/u.test(element.textContent ?? ""))
),
sourceMessageHasNonSensitiveMeta: Boolean(
[...document.querySelectorAll(".message-card.status-source .message-meta")]
.some((element) => /SOURCE|source-fixture|trc_/u.test(element.textContent ?? ""))
)
};
});
}
async function inspectLegacyFailureWindow(page, { beforeMessageCount }) {
return page.evaluate(({ beforeMessageCount, legacyFailureWindowMs }) => {
const messages = [...document.querySelectorAll(".message-card")];
const newMessages = messages.slice(beforeMessageCount);
const failed = newMessages.filter((element) => element.classList.contains("status-failed"));
const latestAgent = [...newMessages].reverse().find((element) => element.classList.contains("message-agent"));
const agentChatStatus = document.querySelector("#agent-chat-status")?.textContent?.trim() ?? null;
const pendingStillRunning =
latestAgent?.classList.contains("status-running") === true ||
agentChatStatus === "处理中" ||
agentChatStatus === "运行中";
return {
checkedAtMs: legacyFailureWindowMs,
beforeMessageCount,
messageCount: messages.length,
newMessageCount: newMessages.length,
agentChatStatus,
failedMessageVisible: failed.length > 0,
runningMessageVisible: Boolean(latestAgent?.classList.contains("status-running")),
permanentFailureAround4500ms: failed.length > 0 && !pendingStillRunning
};
}, { beforeMessageCount, legacyFailureWindowMs });
}
function backendTimeoutMsFromPayload(payload) {
const message = String(payload?.error?.message ?? "");
const match = message.match(/(?:timed out after|timeout after|请求超时|请求超过)\s*(\d+)\s*ms/iu);
if (!match) return null;
const value = Number.parseInt(match[1], 10);
return Number.isInteger(value) && value > 0 ? value : null;
}
async function inspectDefaultApiStatus(page) {
return page.evaluate(() => {
const liveStatus = document.querySelector("#live-status")?.textContent?.trim() ?? "";
const liveDetail = document.querySelector("#live-detail")?.textContent?.trim() ?? "";
const methodText = [...document.querySelectorAll("#method-list .badge")].map((element) => element.textContent?.trim() ?? "");
const firstScreenText = document.querySelector(".topbar")?.textContent?.replace(/\s+/gu, " ").trim() ?? "";
const forbidden = /Gate|诊断|验收|BLOCKED|M0-M5|执行轨迹|runtime_durable_adapter_query_blocked|DB live readiness/u;
return {
liveStatus,
liveDetail,
methodText,
firstScreenText,
degradedStatusVisible:
(liveStatus === "可信记录受阻 / 只读模式" || liveStatus === "API 错误 / 只读模式") &&
(
liveDetail.startsWith("/health/live 可响应,但可信记录持久化尚未就绪:") ||
liveDetail === "同源 API 返回 blocked/degraded/failed 或 ready=false;当前保持只读查看和本地任务整理。"
) &&
methodText.includes("system.health"),
defaultCopyIsWorkbenchSafe: !forbidden.test(`${liveStatus} ${liveDetail} ${firstScreenText}`)
};
});
}
export function sanitizeAgentChatBody(body, options = {}) {
return summarizeCodeAgentPayload(body, options);
}
function blockedAgentChatResponse(code) {
return {
status: "failed",
provider: "not_observed",
model: "not_observed",
backend: "not_observed",
traceId: "not_observed",
hasReply: false,
error: {
code,
missingEnv: []
}
};
}
export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() {
return runLocalAgentFixtureSmoke({
mode: "local-agent-fixture-browser",
responseDelayMs: localAgentFixtureDelayMs,
expectTimeout: false
});
}
async function runLocalAgentFixtureSmoke({
mode,
responseDelayMs = 0,
timeoutConfigMs = null,
expectTimeout = false
} = {}) {
let chromium;
try {
({ chromium } = await importPlaywright());
} catch (error) {
const summary = `Local Code Agent browser fixture smoke skipped because Playwright is unavailable: ${error.message}`;
return {
status: "skip",
task: "DC-DCSN-P0-2026-003",
mode,
evidenceLevel: "SOURCE",
devLive: false,
summary,
checks: [
{
id: "local-agent-fixture-browser-dependency",
status: "skip",
summary: playwrightDependencySummary,
evidence: [playwrightDependencyCommand, "package.json dependencies.playwright"]
}
],
blockers: [
{
type: "environment_blocker",
scope: "local-agent-fixture-browser-dependency",
status: "open",
summary
}
],
safety: staticSafety(),
dependency: {
package: "playwright",
declaredIn: "package.json",
installCommand: playwrightDependencyCommand
}
};
}
const server = await startStaticWebServer({ agentFixture: true, agentDelayMs: responseDelayMs });
let browser;
try {
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1366, height: 768 } });
if (timeoutConfigMs !== null) {
await page.addInitScript((timeoutMs) => {
globalThis.HWLAB_CLOUD_WEB_CONFIG = {
...(globalThis.HWLAB_CLOUD_WEB_CONFIG ?? {}),
timeouts: {
...(globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? {}),
codeAgentTimeoutMs: timeoutMs
}
};
}, timeoutConfigMs);
}
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, { httpStatus: response.status() })
});
});
await page.goto(server.url, { waitUntil: "networkidle", timeout: 15000 });
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
await page.waitForFunction(
() => document.querySelector("#live-status")?.textContent?.trim() === "可信记录受阻 / 只读模式",
null,
{ timeout: 8000 }
);
const controls = await inspectJourneyControls(page);
const apiStatus = await inspectDefaultApiStatus(page);
let response = null;
let responseBody = null;
let responseSummary = null;
const promptResults = [];
if (expectTimeout) {
await page.locator("#command-input").fill(codeAgentE2ePrompts[0].text);
await page.locator("#command-send").click();
await page.waitForFunction(
() => document.querySelector("#agent-chat-status")?.textContent?.trim() === "发送失败",
null,
{ timeout: 8000 }
);
} else {
for (const prompt of codeAgentE2ePrompts) {
promptResults.push(await runCodeAgentPromptJourney(page, prompt, { sourceFixture: true }));
await page.waitForFunction(
() => document.querySelector("#agent-chat-status")?.textContent?.trim() === "SOURCE 回复",
null,
{ timeout: 8000 }
);
}
}
const ui = await inspectJourneyUi(page);
responseSummary = response ? sanitizeAgentChatBody(responseBody, { httpStatus: response.status() }) : null;
const pass = expectTimeout
? (
ui.agentChatStatus === "发送失败" &&
ui.failedMessageVisible &&
ui.retryInputPreserved &&
ui.failedMessageHasTrace &&
ui.failedMessageHasChineseTimeout &&
!ui.completedMessageVisible
)
: (
promptResults.every((result) =>
result.status === "pass" &&
result.response?.status === "completed" &&
result.response?.sourceKind === "SOURCE" &&
result.response?.hasReply === true &&
result.classification?.sourceOnly === true &&
result.legacyWindow?.permanentFailureAround4500ms === false
) &&
ui.agentChatStatus === "SOURCE 回复" &&
ui.sourceMessageVisible &&
ui.sourceMessageHasNonSensitiveMeta &&
!ui.completedMessageVisible &&
!ui.failedMessageVisible
);
const checks = [
{
id: "local-agent-fixture-controls",
status: Object.values(controls).every(Boolean) ? "pass" : "blocked",
summary: "Local SOURCE fixture browser smoke can locate the Code Agent input, send button, thread, and right-side evidence panels.",
observations: controls
},
{
id: "local-api-degraded-default-status",
status: apiStatus.degradedStatusVisible && apiStatus.defaultCopyIsWorkbenchSafe ? "pass" : "blocked",
summary: "Default workbench status renders reachable blocked /health/live as concrete read-only status without first-screen backend route details.",
observations: apiStatus
},
{
id: expectTimeout ? "local-agent-timeout-fixture-failed-state" : "local-agent-fixture-replied-state",
status: pass ? "pass" : "blocked",
summary: expectTimeout
? "Local SOURCE fixture proves a bounded Code Agent timeout stays failed/BLOCKED in the UI, preserves trace evidence, and keeps the user's input for retry."
: "Local SOURCE fixture sends input and reaches a user-visible replied state without marking the fixture as DEV-LIVE.",
observations: expectTimeout ? {
response: responseSummary,
ui,
networkEvents: agentResponses
} : {
prompts: promptResults,
ui,
networkEvents: agentResponses
}
}
];
if (!expectTimeout) {
checks.push({
id: "local-agent-fixture-no-4500ms-permanent-failure",
status: promptResults.every((result) => result.legacyWindow?.permanentFailureAround4500ms === false) ? "pass" : "blocked",
summary: "Local SOURCE fixture delays each Code Agent response beyond 4500ms and the frontend keeps the request pending instead of marking permanent failure.",
observations: {
legacyFailureWindowMs,
fixtureDelayMs: responseDelayMs,
prompts: promptResults.map((result) => ({
promptId: result.promptId,
status: result.status,
legacyWindow: result.legacyWindow,
traceId: result.traceId
}))
}
});
}
const blockers = checks
.filter((check) => check.status !== "pass")
.map((check) => ({
type: "observability_blocker",
scope: check.id,
status: "open",
summary: check.summary
}));
return {
status: blockers.length === 0 ? "pass" : "blocked",
task: "DC-DCSN-P0-2026-003",
mode,
url: server.url,
generatedAt: new Date().toISOString(),
evidenceLevel: "SOURCE",
devLive: false,
summary: expectTimeout
? "Local browser smoke uses a sanitized slow SOURCE fixture for /v1/agent/chat; it proves timeout UI classification only and does not claim DEV-LIVE."
: "Local browser smoke uses a sanitized SOURCE fixture for /v1/agent/chat; it proves the UI conversation path only and does not claim DEV-LIVE.",
checks,
blockers,
safety: {
...staticSafety(),
localFixtureOnly: true,
fixtureTrustBoundary: "SOURCE fixture replies render as SOURCE 回复 and never as DEV-LIVE 回复.",
legacyFailureWindowMs,
fixtureDelayMs: responseDelayMs,
prompts: codeAgentE2ePrompts.map((prompt) => ({ id: prompt.id, label: prompt.label })),
preservedLiveFields: ["provider", "model", "backend", "conversationId", "traceId", "messageId", "providerTrace"]
}
};
} catch (error) {
return {
status: "blocked",
task: "DC-DCSN-P0-2026-003",
mode,
url: server.url,
generatedAt: new Date().toISOString(),
evidenceLevel: "SOURCE",
devLive: false,
summary: `Local Code Agent browser fixture smoke failed: ${error.message}`,
checks: [],
blockers: [
{
type: "observability_blocker",
scope: mode,
status: "open",
summary: error.message
}
],
safety: staticSafety()
};
} finally {
if (browser) await browser.close();
await server.close();
}
}
export async function runDevCloudWorkbenchMobileSmoke() {
let chromium;
try {
({ chromium } = await importPlaywright());
} catch (error) {
const summary = `Mobile browser smoke skipped because Playwright is unavailable: ${error.message}`;
return {
status: "skip",
task: "DC-DCSN-P0-2026-003",
mode: "static-mobile-browser",
viewport: { width: 390, height: 844 },
evidenceLevel: "SOURCE",
devLive: false,
summary,
checks: [
{
id: "mobile-browser-dependency",
status: "skip",
summary: playwrightDependencySummary,
evidence: [playwrightDependencyCommand, "package.json dependencies.playwright"]
}
],
blockers: [
{
type: "environment_blocker",
scope: "mobile-browser-dependency",
status: "open",
summary
}
],
safety: staticSafety(),
dependency: {
package: "playwright",
declaredIn: "package.json",
installCommand: playwrightDependencyCommand
}
};
}
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.goto(new URL("/#/help", server.url).toString(), { waitUntil: "networkidle", timeout: 15000 });
await page.waitForFunction(() => document.querySelector("#help-content")?.dataset.helpState === "ready", null, { timeout: 8000 });
const slashHashHelp = await inspectHelpMarkdownRoute(page);
await page.goto(new URL("/help", server.url).toString(), { waitUntil: "networkidle", timeout: 15000 });
await page.waitForFunction(() => document.querySelector("#help-content")?.dataset.helpState === "ready", null, { timeout: 8000 });
const directHelp = 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.routeIsHashHelp && help.termsPresent ? "pass" : "blocked",
summary: "Help opens from the rail as a non-default hash route rendered from help.md by the vendored Markdown renderer.",
observations: help
},
{
id: "mobile-slash-hash-help-route",
status: slashHashHelp.nonDefaultMarkdownHelp && slashHashHelp.routeIsSlashHashHelp && slashHashHelp.termsPresent ? "pass" : "blocked",
summary: "Direct #/help opens the user-facing help view.",
observations: slashHashHelp
},
{
id: "mobile-direct-help-route",
status: directHelp.nonDefaultMarkdownHelp && directHelp.routeIsDirectHelp && directHelp.termsPresent ? "pass" : "blocked",
summary: "Direct /help opens the user-facing help view instead of raw API 404 JSON.",
observations: directHelp
},
{
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();
}
}
export async function runM3StatusFixtureSmoke() {
let chromium;
try {
({ chromium } = await importPlaywright());
} catch (error) {
const summary = `M3 status fixture smoke skipped because Playwright is unavailable: ${error.message}`;
return {
status: "skip",
task: "DC-DCSN-P0-2026-003",
mode: "m3-status-fixture",
evidenceLevel: "SOURCE",
devLive: false,
summary,
checks: [
{
id: "m3-status-browser-dependency",
status: "skip",
summary: playwrightDependencySummary,
evidence: [playwrightDependencyCommand, "package.json dependencies.playwright"]
}
],
blockers: [
{
type: "environment_blocker",
scope: "m3-status-browser-dependency",
status: "open",
summary
}
],
safety: staticSafety()
};
}
const m3State = {
doValue: false,
diValue: false,
observedAt: "2026-05-23T00:00:00.000Z",
sequence: 1
};
const server = await startStaticWebServer({ m3StatusFixture: true, m3State });
let browser;
try {
browser = await chromium.launch({ headless: true });
const desktop = await inspectM3StatusFixtureViewport(browser, server.url, {
width: 1366,
height: 768,
expectedRightWidthMin: 560
});
const mobile = await inspectM3StatusFixtureViewport(browser, server.url, {
width: 390,
height: 844,
expectedRightWidthMin: 300
});
const refresh = await inspectM3StatusFixtureRefresh(browser, server.url);
const checks = [
{
id: "m3-status-fixture-desktop",
status: desktop.pass ? "pass" : "blocked",
summary: "Desktop hardware workspace reads /v1/m3/status, exposes tabs/Key-Value state, and has no outer or horizontal scroll.",
observations: desktop
},
{
id: "m3-status-fixture-mobile",
status: mobile.pass ? "pass" : "blocked",
summary: "390x844 hardware workspace wraps tabs, preserves Key-Value content, and avoids horizontal overflow.",
observations: mobile
},
{
id: "m3-status-fixture-refresh",
status: refresh.pass ? "pass" : "blocked",
summary: "DO1 false/true writes refresh through same-origin cloud-api status and DI1 follows through patch-panel.",
observations: refresh
}
];
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: "m3-status-fixture",
url: server.url,
generatedAt: new Date().toISOString(),
evidenceLevel: "SOURCE",
devLive: false,
summary: "Local SOURCE fixture browser smoke validates the M3 status UI contract; it is not DEV-LIVE hardware evidence.",
refs: ["pikasTech/HWLAB#287", "pikasTech/HWLAB#273", "pikasTech/HWLAB#278"],
checks,
blockers,
safety: {
...staticSafety(),
devLive: false,
sourceIsDevLive: false,
hardwareWriteApis: false,
statement: "Fixture smoke intercepts only same-origin cloud-api routes on a local static server; it never contacts gateway-simu, box-simu, patch-panel, DEV, or PROD."
}
};
} catch (error) {
return {
status: "blocked",
task: "DC-DCSN-P0-2026-003",
mode: "m3-status-fixture",
url: server.url,
generatedAt: new Date().toISOString(),
evidenceLevel: "SOURCE",
devLive: false,
summary: `M3 status fixture smoke failed: ${error.message}`,
checks: [],
blockers: [
{
type: "runtime_blocker",
scope: "m3-status-fixture",
status: "open",
summary: error.message
}
],
safety: staticSafety()
};
} finally {
if (browser) await browser.close();
await server.close();
}
}
async function startStaticWebServer(options = {}) {
const rootDir = path.resolve(options.rootDir ?? webRoot);
const server = http.createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
if (options.m3StatusFixture && await handleM3StatusFixtureApi({ request, response, url, options })) {
return;
}
if (options.agentFixture && await handleLocalAgentFixtureApi({ request, response, url, options })) {
return;
}
let pathname = decodeURIComponent(url.pathname);
if (pathname === "/" || pathname === "/workbench" || pathname === "/gate" || pathname === "/diagnostics/gate" || pathname === "/help") {
pathname = "/index.html";
}
const filePath = path.resolve(rootDir, pathname.replace(/^\/+/, ""));
if (!filePath.startsWith(rootDir)) {
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()))
};
}
async function handleM3StatusFixtureApi({ request, response, url, options = {} }) {
if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1")) {
jsonResponse(response, 200, m3StatusFixtureHealth(options.m3State));
return true;
}
if (request.method === "GET" && url.pathname === "/v1/m3/io") {
jsonResponse(response, 200, {
status: "available",
route: "/v1/m3/io",
readiness: {
status: "ready",
controlReady: true,
sourceKind: "DEV-LIVE",
evidenceLevel: "DEV-LIVE"
}
});
return true;
}
if (request.method === "GET" && url.pathname === "/v1/m3/status") {
jsonResponse(response, 200, m3StatusFixturePayload(options.m3State));
return true;
}
if (request.method === "POST" && url.pathname === "/v1/m3/io") {
const body = await readJsonBody(request);
if (body?.action === "do.write") {
options.m3State.doValue = body.value === true;
options.m3State.diValue = body.value === true;
options.m3State.sequence += 1;
options.m3State.observedAt = `2026-05-23T00:00:0${Math.min(options.m3State.sequence, 9)}.000Z`;
}
jsonResponse(response, 200, {
status: "succeeded",
accepted: true,
action: body?.action ?? "di.read",
value: body?.action === "di.read" ? options.m3State.diValue : options.m3State.doValue,
result: {
value: body?.action === "di.read" ? options.m3State.diValue : options.m3State.doValue
},
operationId: `op_fixture_${options.m3State.sequence}`,
traceId: `trc_fixture_${options.m3State.sequence}`,
auditId: null,
evidenceId: null,
evidenceState: {
status: "blocked",
reason: "runtime_durable_adapter_auth_blocked"
},
controlPath: {
frontendBypass: false
}
});
return true;
}
if (request.method === "POST" && url.pathname === "/json-rpc") {
const body = await readJsonBody(request);
jsonResponse(response, 200, {
jsonrpc: "2.0",
id: body?.id ?? "req_m3_status_fixture",
result: localRpcFixtureResult(body?.method)
});
return true;
}
return false;
}
function m3StatusFixtureHealth(m3State = { sequence: 1 }) {
return {
serviceId: "hwlab-cloud-api",
status: "degraded",
ready: false,
methods: [...readOnlyRpcMethods],
runtime: {
durable: true,
ready: false,
liveRuntimeEvidence: false,
status: "blocked",
blocker: "runtime_durable_adapter_auth_blocked",
connection: {
queryAttempted: true,
queryResult: "auth_blocked"
}
},
readiness: {
status: "degraded",
ready: false,
durability: {
status: "blocked",
ready: false,
blocker: "runtime_durable_adapter_auth_blocked",
blockedLayer: "durability_auth",
queryResult: "auth_blocked"
}
},
blockerCodes: ["runtime_durable_adapter_auth_blocked"],
m3FixtureSequence: m3State.sequence
};
}
function m3StatusFixturePayload(m3State = { doValue: false, diValue: false, observedAt: "2026-05-23T00:00:00.000Z", sequence: 1 }) {
const observedAt = m3State.observedAt;
return {
serviceId: "hwlab-cloud-api",
contractVersion: "m3-status-v1",
route: "/v1/m3/status",
status: "blocked",
sourceKind: "DEV-LIVE",
observedAt,
chain: {
projectId: gateSummary.topology.projectId,
sourceGatewayId: "gwsimu_1",
sourceGatewaySessionId: "gws_gwsimu_1",
sourceResourceId: "res_boxsimu_1",
sourceBoxId: "boxsimu_1",
sourcePort: "DO1",
targetGatewayId: "gwsimu_2",
targetGatewaySessionId: "gws_gwsimu_2",
targetResourceId: "res_boxsimu_2",
targetBoxId: "boxsimu_2",
targetPort: "DI1",
patchPanelServiceId: "hwlab-patch-panel"
},
boundaries: {
frontendCallsOnly: "/v1/m3/status",
directFrontendGatewayOrBoxAccess: false,
sourceTopologyFallbackMustStayUnverified: true
},
gateways: [
{
id: "gwsimu_1",
role: "source",
online: true,
observable: true,
sessionId: "gws_gwsimu_1",
lastSeenAt: observedAt,
sourceKind: "DEV-LIVE",
lastError: null
},
{
id: "gwsimu_2",
role: "target",
online: true,
observable: true,
sessionId: "gws_gwsimu_2",
lastSeenAt: observedAt,
sourceKind: "DEV-LIVE",
lastError: null
}
],
boxes: [
{
id: "boxsimu_1",
resourceId: "res_boxsimu_1",
gatewayId: "gwsimu_1",
gatewaySessionId: "gws_gwsimu_1",
online: true,
observable: true,
sourceKind: "DEV-LIVE",
observedAt,
ports: {
DO1: {
value: m3State.doValue,
direction: "output",
source: "gateway-simu",
sourceKind: "DEV-LIVE",
observedAt
}
}
},
{
id: "boxsimu_2",
resourceId: "res_boxsimu_2",
gatewayId: "gwsimu_2",
gatewaySessionId: "gws_gwsimu_2",
online: true,
observable: true,
sourceKind: "DEV-LIVE",
observedAt,
ports: {
DI1: {
value: m3State.diValue,
direction: "input",
source: "gateway-simu/patch-panel",
sourceKind: "DEV-LIVE",
observedAt
}
}
}
],
patchPanel: {
serviceId: "hwlab-patch-panel",
observable: true,
connectionActive: true,
connectionVersion: m3State.sequence,
wiringConfigId: "wir_m3_do1_di1",
lastSyncAt: observedAt,
lastError: null,
sourceKind: "DEV-LIVE"
},
trust: {
operationId: `op_fixture_${m3State.sequence}`,
traceId: `trc_fixture_${m3State.sequence}`,
auditId: null,
evidenceId: null,
durableStatus: "blocked",
blocker: "runtime_durable_adapter_auth_blocked"
},
blocker: {
code: "runtime_durable_adapter_auth_blocked",
layer: "runtime-durable",
zh: "runtime durable 仍 blockedruntime_durable_adapter_auth_blocked"
}
};
}
async function handleLocalAgentFixtureApi({ request, response, url, options = {} }) {
if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1")) {
jsonResponse(response, 200, {
serviceId: "hwlab-cloud-api",
status: "degraded",
ready: false,
sourceKind: "SOURCE",
codeAgent: localSourceFixtureAvailability(),
methods: [...readOnlyRpcMethods],
runtime: {
durable: false,
ready: false,
status: "degraded",
blocker: "runtime_durable_adapter_query_blocked",
connection: {
queryAttempted: true,
queryResult: "query_blocked"
},
counts: {
gatewaySessions: gateSummary.gatewaySessionCount,
boxResources: gateSummary.boxResourceCount
}
},
readiness: {
status: "degraded",
ready: false,
durability: {
status: "blocked",
ready: false,
blocker: "runtime_durable_adapter_query_blocked",
blockedLayer: "durability_query",
queryResult: "query_blocked"
}
},
blockerCodes: ["runtime_durable_adapter_query_blocked"]
});
return true;
}
if (request.method === "POST" && url.pathname === "/json-rpc") {
const body = await readJsonBody(request);
jsonResponse(response, 200, {
jsonrpc: "2.0",
id: body?.id ?? "req_source_fixture",
result: localRpcFixtureResult(body?.method)
});
return true;
}
if (request.method === "POST" && url.pathname === "/v1/agent/chat") {
const body = await readJsonBody(request);
await delay(options.agentDelayMs ?? 0);
const timestamp = "2026-05-22T00:00:00.000Z";
const conversationId = stringOrFallback(body?.conversationId, "cnv_source_fixture_browser");
const normalizedMessage = String(body?.message ?? "");
const isSkillListPrompt = /列出你能使用的所有skill/u.test(normalizedMessage);
const messageId = isSkillListPrompt ? "msg_source_fixture_browser_skills" : "msg_source_fixture_browser_simple";
jsonResponse(response, 200, {
conversationId,
sessionId: conversationId,
messageId,
status: "completed",
sourceKind: "SOURCE",
evidenceLevel: "SOURCE",
createdAt: timestamp,
updatedAt: timestamp,
traceId: stringOrFallback(body?.traceId, "trc_source_fixture_browser"),
provider: "source-fixture",
model: "gpt-source-fixture",
backend: "local-source-fixture",
projectId: stringOrFallback(body?.projectId, gateSummary.topology.projectId),
reply: {
messageId,
role: "assistant",
content: isSkillListPrompt
? "SOURCE fixture 回复:可用 skill 列表需要由真实运行时返回;本地只验证浏览器长请求链路。"
: "SOURCE fixture 回复:HWLAB 工作台可以整理任务、查看资源,并保持硬件变更受控。",
createdAt: timestamp
},
usage: null,
providerTrace: {
source: "SOURCE-local-browser-fixture",
sourceKind: "SOURCE",
responseId: "rsp_source_fixture_sanitized",
redacted: true
}
});
return true;
}
return false;
}
async function inspectM3StatusFixtureViewport(browser, url, viewport) {
const page = await browser.newPage({ viewport: { width: viewport.width, height: viewport.height } });
const requests = [];
try {
page.on("request", (request) => {
const requestUrl = new URL(request.url());
requests.push({
method: request.method(),
pathname: requestUrl.pathname,
href: request.url()
});
});
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
await page.waitForFunction(() => document.querySelector("#hardware-source-status")?.textContent?.trim() === "实况受阻", null, { timeout: 8000 });
for (const tab of ["gateways", "box1", "box2", "patch", "overview"]) {
await page.locator(`[data-hardware-tab="${tab}"]`).click();
}
const dom = await page.evaluate(() => {
const text = document.body.textContent ?? "";
const hardwareText = document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, " ").trim() ?? "";
const right = document.querySelector(".right-sidebar");
const hardwareList = document.querySelector("#hardware-list");
const tabs = [...document.querySelectorAll("[data-hardware-tab]")];
const tabRows = new Set(tabs.map((tab) => Math.round(tab.getBoundingClientRect().top))).size;
const tabLabels = tabs.map((tab) => tab.textContent?.trim() ?? "");
const visible = (selector) => {
const element = document.querySelector(selector);
if (!element) return false;
const box = element.getBoundingClientRect();
const style = getComputedStyle(element);
return box.width > 0 && box.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
return {
hardwareStatus: document.querySelector("#hardware-source-status")?.textContent?.trim() ?? "",
liveDetail: document.querySelector("#live-detail")?.textContent?.trim() ?? "",
hardwareText,
tabLabels,
tabRows,
rightWidth: right?.getBoundingClientRect().width ?? 0,
rightScrollWidth: right?.scrollWidth ?? 0,
rightClientWidth: right?.clientWidth ?? 0,
hardwareScrollWidth: hardwareList?.scrollWidth ?? 0,
hardwareClientWidth: hardwareList?.clientWidth ?? 0,
htmlScrollWidth: document.documentElement.scrollWidth,
htmlClientWidth: document.documentElement.clientWidth,
bodyScrollWidth: document.body.scrollWidth,
bodyClientWidth: document.body.clientWidth,
outerScrollLocked:
getComputedStyle(document.documentElement).overflow === "hidden" &&
getComputedStyle(document.body).overflow === "hidden" &&
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
document.body.scrollHeight <= document.body.clientHeight + 2,
keysVisible: ["状态", "Gateway 在线", "BOX 在线", "DO1", "DI1", "runtime durable", "blocker"].every((term) => hardwareText.includes(term)),
tabsVisible: ["总览", "Gateway-SIMU", "BOX-SIMU-1", "BOX-SIMU-2", "Patch Panel"].every((term) => tabLabels.includes(term)),
blockedVisible: text.includes("runtime_durable_adapter_auth_blocked") && !text.includes("实况可信"),
noHorizontalOverflow:
document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 &&
document.body.scrollWidth <= document.body.clientWidth + 2 &&
(!right || right.scrollWidth <= right.clientWidth + 2) &&
(!hardwareList || hardwareList.scrollWidth <= hardwareList.clientWidth + 2),
boxTabClickable: visible('[data-hardware-tab="box1"]') && visible('[data-hardware-tab="box2"]')
};
});
const directRuntimeRequests = requests.filter((request) => /gateway-simu|box-simu|patch-panel|:7101|:7201|:7301/iu.test(request.href));
const pass =
dom.hardwareStatus === "实况受阻" &&
dom.tabsVisible &&
dom.keysVisible &&
dom.blockedVisible &&
dom.outerScrollLocked &&
dom.noHorizontalOverflow &&
dom.boxTabClickable &&
dom.rightWidth >= viewport.expectedRightWidthMin &&
requests.some((request) => request.method === "GET" && request.pathname === "/v1/m3/status") &&
directRuntimeRequests.length === 0;
return {
viewport: { width: viewport.width, height: viewport.height },
pass,
...dom,
statusRequests: requests.filter((request) => request.pathname === "/v1/m3/status").length,
directRuntimeRequests
};
} finally {
await page.close();
}
}
async function inspectM3StatusFixtureRefresh(browser, url) {
const page = await browser.newPage({ viewport: { width: 1366, height: 768 } });
const requests = [];
try {
page.on("request", (request) => {
const requestUrl = new URL(request.url());
requests.push({
method: request.method(),
pathname: requestUrl.pathname,
href: request.url()
});
});
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
await page.waitForFunction(() => document.querySelector("#hardware-source-status")?.textContent?.trim() === "实况受阻", null, { timeout: 8000 });
await page.locator('[data-hardware-tab="box1"]').click();
const initialBox1 = await hardwarePanelText(page);
await page.locator("#m3-value-select").selectOption("true");
await page.locator("#m3-write-do").click();
await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DO1true"), null, { timeout: 8000 });
const trueBox1 = await hardwarePanelText(page);
await page.locator('[data-hardware-tab="box2"]').click();
await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DI1true"), null, { timeout: 8000 });
const trueBox2 = await hardwarePanelText(page);
await page.locator("#m3-value-select").selectOption("false");
await page.locator("#m3-write-do").click();
await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DI1false"), null, { timeout: 8000 });
await page.locator('[data-hardware-tab="box1"]').click();
await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DO1false"), null, { timeout: 8000 });
const falseBox1 = await hardwarePanelText(page);
await page.locator('[data-hardware-tab="box2"]').click();
await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DI1false"), null, { timeout: 8000 });
const falseBox2 = await hardwarePanelText(page);
const statusRequests = requests.filter((request) => request.method === "GET" && request.pathname === "/v1/m3/status").length;
const ioPosts = requests.filter((request) => request.method === "POST" && request.pathname === "/v1/m3/io").length;
const directRuntimeRequests = requests.filter((request) => /gateway-simu|box-simu|patch-panel|:7101|:7201|:7301/iu.test(request.href));
const blockedStillVisible = await page.evaluate(() => document.body.textContent?.includes("runtime_durable_adapter_auth_blocked") === true && !document.body.textContent?.includes("实况可信"));
const pass =
initialBox1.includes("DO1false") &&
trueBox1.includes("DO1true") &&
trueBox2.includes("DI1true") &&
falseBox1.includes("DO1false") &&
falseBox2.includes("DI1false") &&
statusRequests >= 3 &&
ioPosts === 2 &&
directRuntimeRequests.length === 0 &&
blockedStillVisible;
return {
pass,
initialBox1,
trueBox1,
trueBox2,
falseBox1,
falseBox2,
statusRequests,
ioPosts,
directRuntimeRequests,
blockedStillVisible
};
} finally {
await page.close();
}
}
async function hardwarePanelText(page) {
return page.evaluate(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, " ").trim() ?? "");
}
function delay(ms) {
const normalized = Number.isFinite(Number(ms)) && Number(ms) > 0 ? Number(ms) : 0;
return new Promise((resolve) => setTimeout(resolve, normalized));
}
function localSourceFixtureAvailability() {
return {
endpoint: "POST /v1/agent/chat",
provider: "source-fixture",
model: "gpt-source-fixture",
backend: "local-source-fixture",
mode: "local-source-fixture",
status: "available",
sourceKind: "SOURCE",
evidenceLevel: "SOURCE",
ready: true,
summary: "Local browser smoke SOURCE fixture; it is not DEV-LIVE provider evidence.",
missingEnv: [],
secretRefs: [],
safety: {
localFixtureOnly: true,
secretsRead: false,
valuesRedacted: true
}
};
}
function localRpcFixtureResult(method) {
if (method === "system.health" || method === "cloud.adapter.describe") {
return {
status: "degraded",
sourceKind: "SOURCE",
methods: [...readOnlyRpcMethods],
runtime: {
durable: false,
counts: {
gatewaySessions: gateSummary.gatewaySessionCount,
boxResources: gateSummary.boxResourceCount
}
}
};
}
if (method === "audit.event.query") {
return { count: 0, events: [] };
}
if (method === "evidence.record.query") {
return { count: 0, records: [] };
}
return { status: "not_implemented", sourceKind: "SOURCE" };
}
async function readJsonBody(request) {
const chunks = [];
for await (const chunk of request) {
chunks.push(chunk);
}
const text = Buffer.concat(chunks).toString("utf8");
if (!text.trim()) return null;
try {
return JSON.parse(text);
} catch {
return null;
}
}
function parseJsonOrNull(text) {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function jsonResponse(response, status, body) {
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify(body));
}
function stringOrFallback(value, fallback) {
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
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 inspectWorkbenchLayoutViewport(browser, url, viewport, options = {}) {
const page = await browser.newPage({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: 1,
isMobile: viewport.isMobile === true
});
const artifacts = { screenshots: [] };
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });
await page.locator("#explorer-toggle").waitFor({ state: "visible", timeout: 12000 });
await page.waitForTimeout(150);
if (viewport.isMobile) {
const collapsed = await inspectLayoutState(page, {
mode: "mobile-collapsed",
viewport,
compareTo: null
});
artifacts.screenshots.push(...await captureLayoutScreenshots(page, options.artifactRoot, viewport, "mobile-collapsed"));
await page.locator("#explorer-toggle").click();
await page.waitForFunction(() => !document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 });
const drawer = await inspectLayoutState(page, {
mode: "mobile-drawer",
viewport,
compareTo: collapsed
});
artifacts.screenshots.push(...await captureLayoutScreenshots(page, options.artifactRoot, viewport, "mobile-drawer"));
await page.locator("#explorer-toggle").click();
await page.waitForFunction(() => document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 });
const recollapsed = await inspectLayoutState(page, {
mode: "mobile-recollapsed",
viewport,
compareTo: collapsed
});
drawer.recollapsed = {
explorerCollapsed: recollapsed.explorerCollapsed,
toggleText: recollapsed.toggle.text,
toggleAriaLabel: recollapsed.toggle.ariaLabel,
keyTargetsReachable: recollapsed.keyTargetsReachable
};
drawer.pass = drawer.pass && recollapsed.explorerCollapsed === true && recollapsed.keyTargetsReachable;
const gate = await inspectWorkbenchGateLayout(browser, url, viewport, options);
return { collapsed, drawer, gate, artifacts };
}
const expanded = await inspectLayoutState(page, {
mode: "desktop-expanded",
viewport,
compareTo: null
});
artifacts.screenshots.push(...await captureLayoutScreenshots(page, options.artifactRoot, viewport, "desktop-expanded"));
await page.locator("#explorer-toggle").click();
await page.waitForFunction(() => document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 });
const collapsed = await inspectLayoutState(page, {
mode: "desktop-collapsed",
viewport,
compareTo: expanded
});
artifacts.screenshots.push(...await captureLayoutScreenshots(page, options.artifactRoot, viewport, "desktop-collapsed"));
await page.locator("#explorer-toggle").click();
await page.waitForFunction(() => !document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 });
const restored = await inspectLayoutState(page, {
mode: "desktop-restored",
viewport,
compareTo: expanded
});
const gate = await inspectWorkbenchGateLayout(browser, url, viewport, options);
return { expanded, collapsed, restored, gate, artifacts };
} finally {
await page.close();
}
}
async function inspectWorkbenchGateLayout(browser, url, viewport, options = {}) {
const page = await browser.newPage({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: 1,
isMobile: viewport.isMobile === true
});
try {
await page.goto(new URL("/gate", url).toString(), { waitUntil: "domcontentloaded", timeout: 15000 });
await page.locator('[data-view="gate"]').waitFor({ state: "visible", timeout: 12000 });
await page.waitForTimeout(150);
const currentRoute = await inspectGateLayoutState(page, { viewport, mode: "gate-current" });
currentRoute.artifacts = {
screenshots: await captureGateScreenshots(page, options.artifactRoot, viewport)
};
return { currentRoute };
} finally {
await page.close();
}
}
async function inspectLayoutState(page, { mode, viewport, compareTo }) {
return page.evaluate(async ({ mode, viewport, compareTo }) => {
const failures = [];
const skip = [];
const selectorLabels = [
["#explorer-toggle", "资源树折叠按钮"],
["#command-input", "Code Agent 输入"],
["#command-send", "发送"],
["#m3-gateway-select", "M3 Gateway select"],
["#m3-box-select", "M3 BOX select"],
["#m3-port-select", "M3 port select"],
["#m3-value-select", "M3 value select"],
["#tab-control", "M3 控制标签"],
["#tab-wiring", "M3 接线标签"],
["#tab-records", "可信记录标签"],
["#m3-write-do", "写入 DO1"],
["#m3-read-di", "读取 DI1"],
["[data-hardware-tab='overview']", "硬件总览标签"],
["[data-hardware-tab='gateways']", "Gateway-SIMU 标签"],
["[data-hardware-tab='box1']", "BOX-SIMU-1 标签"],
["[data-hardware-tab='box2']", "BOX-SIMU-2 标签"],
["[data-hardware-tab='patch']", "Patch Panel 标签"]
];
const overflowSelectors = [
"body",
"[data-app-shell]",
".workbench-shell",
".center-workspace",
"#command-form",
".input-shell",
".right-sidebar",
".hardware-status",
"#hardware-list",
"#panel-control",
"#m3-control-form",
".m3-control-actions"
];
const overlapPairs = [
[".right-sidebar article.info-card", "#m3-control-form", "M3 control info-card vs DO/DI form"],
[".right-sidebar article.info-card", ".m3-control-actions", "M3 control info-card vs actions"],
["#m3-control-form", "#control-list", "M3 form vs control info list"],
[".m3-control-actions", "#control-list", "M3 actions vs control info list"],
[".hardware-status", ".side-workspace", "hardware status vs side workspace"],
["#command-form", ".right-sidebar", "Code Agent command form vs right sidebar"]
];
const wiringLegacyHeaders = ["源设备", "源端口", "接线盘", "目标设备", "目标端口", "状态", "证据来源", "轨迹/证据"];
const drawerSelectorLabels = [
[".quick-actions button", "资源树快捷操作"],
["#resource-tree summary", "资源树分组"],
["#resource-tree .tree-row", "资源树资源行"]
];
const boxForElement = (element) => {
if (!element) return null;
const box = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
left: box.left,
top: box.top,
right: box.right,
bottom: box.bottom,
width: box.width,
height: box.height,
display: style.display,
visibility: style.visibility,
overflow: style.overflow,
overflowY: style.overflowY,
overflowX: style.overflowX,
scrollHeight: element.scrollHeight,
clientWidth: element.clientWidth,
clientHeight: element.clientHeight,
scrollWidth: element.scrollWidth,
text: element.textContent?.replace(/\s+/gu, " ").trim().slice(0, 120) ?? ""
};
};
const boxFor = (selector) => boxForElement(document.querySelector(selector));
const boxesOverlap = (a, b, gap = 0) => {
if (!a || !b || a.width <= 0 || a.height <= 0 || b.width <= 0 || b.height <= 0) return false;
return !(a.right + gap <= b.left || b.right + gap <= a.left || a.bottom + gap <= b.top || b.bottom + gap <= a.top);
};
const ownsHit = (element, hit) => hit === element || element.contains(hit);
const hitLabel = (hit) => hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}${hit.className ? `.${String(hit.className).trim().replace(/\s+/gu, ".")}` : ""}` : "none";
const nearestScrollableAncestor = (element) => {
let current = element.parentElement;
while (current && current !== document.documentElement) {
const style = getComputedStyle(current);
const scrollable = current.scrollHeight > current.clientHeight + 2 && !["visible", "clip"].includes(style.overflowY);
if (scrollable) return current;
current = current.parentElement;
}
return null;
};
const scrollIntoNearestContainer = async (element) => {
const container = nearestScrollableAncestor(element);
if (!container) {
element.scrollIntoView({ block: "center", inline: "nearest" });
await new Promise((resolve) => requestAnimationFrame(resolve));
return null;
}
const elementBox = element.getBoundingClientRect();
const containerBox = container.getBoundingClientRect();
const inset = Math.max(6, Math.min(24, (container.clientHeight - Math.min(elementBox.height, container.clientHeight)) / 2));
container.scrollTop += elementBox.top - containerBox.top - inset;
await new Promise((resolve) => requestAnimationFrame(resolve));
return container;
};
const clipBoxForElement = (element) => {
let visible = {
left: 0,
top: 0,
right: window.innerWidth,
bottom: window.innerHeight
};
let current = element.parentElement;
while (current && current !== document.documentElement) {
const style = getComputedStyle(current);
if (!["visible", "clip"].includes(style.overflow) || !["visible", "clip"].includes(style.overflowY) || !["visible", "clip"].includes(style.overflowX)) {
const box = current.getBoundingClientRect();
visible = {
left: Math.max(visible.left, box.left),
top: Math.max(visible.top, box.top),
right: Math.min(visible.right, box.right),
bottom: Math.min(visible.bottom, box.bottom)
};
}
current = current.parentElement;
}
return visible;
};
const inspectHitTarget = async (selector, label) => {
const element = document.querySelector(selector);
if (!element) return { selector, label, ok: false, missing: true };
const scrollContainer = await scrollIntoNearestContainer(element);
const box = element.getBoundingClientRect();
const clipBox = clipBoxForElement(element);
const visibleBox = {
left: Math.max(box.left, clipBox.left),
top: Math.max(box.top, clipBox.top),
right: Math.min(box.right, clipBox.right),
bottom: Math.min(box.bottom, clipBox.bottom)
};
const visibleWidth = Math.max(0, visibleBox.right - visibleBox.left);
const visibleHeight = Math.max(0, visibleBox.bottom - visibleBox.top);
const cx = visibleBox.left + visibleWidth / 2;
const cy = visibleBox.top + visibleHeight / 2;
const stack = document.elementsFromPoint(cx, cy);
const hit = stack[0] ?? null;
const style = getComputedStyle(element);
const ok =
style.display !== "none" &&
style.visibility !== "hidden" &&
box.width > 0 &&
box.height > 0 &&
visibleWidth > 0 &&
visibleHeight > 0 &&
cx >= 0 &&
cx <= window.innerWidth &&
cy >= 0 &&
cy <= window.innerHeight &&
stack.some((candidate) => ownsHit(element, candidate));
return {
selector,
label,
ok,
center: { x: cx, y: cy },
box: { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height },
visibleBox,
clipping: clipBox,
scrollContainer: scrollContainer ? scrollContainer.id ? `#${scrollContainer.id}` : scrollContainer.className ? `.${String(scrollContainer.className).trim().replace(/\s+/gu, ".")}` : scrollContainer.tagName.toLowerCase() : null,
hit: hitLabel(hit),
hitStack: stack.slice(0, 5).map(hitLabel)
};
};
const inspectFirstHit = async (selector, label) => {
const element = document.querySelector(selector);
if (!element) return { selector, label, ok: false, missing: true };
return inspectHitTarget(selector, label);
};
const scrollableProbe = async (selector) => {
const element = document.querySelector(selector);
if (!element) return { selector, exists: false, scrolls: false };
element.scrollTop = 0;
await new Promise((resolve) => requestAnimationFrame(resolve));
const before = element.scrollTop;
element.scrollTop = element.scrollHeight;
await new Promise((resolve) => requestAnimationFrame(resolve));
return {
selector,
exists: true,
before,
after: element.scrollTop,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
overflowY: getComputedStyle(element).overflowY,
scrolls: element.scrollHeight > element.clientHeight ? element.scrollTop > before : true
};
};
window.scrollTo(0, 240);
document.documentElement.scrollTop = 240;
document.body.scrollTop = 240;
await new Promise((resolve) => requestAnimationFrame(resolve));
const shell = document.querySelector("[data-app-shell]");
const toggleElement = document.querySelector("#explorer-toggle");
const isDesktop = viewport.width >= 861;
const isMobile = !isDesktop;
const boxes = {
shell: boxFor("[data-app-shell]"),
rail: boxFor(".activity-rail"),
explorer: boxFor("#resource-explorer"),
center: boxFor(".center-workspace"),
right: boxFor(".right-sidebar"),
commandBar: boxFor("#command-form"),
commandInput: boxFor("#command-input"),
commandSend: boxFor("#command-send"),
hardwareStatus: boxFor(".hardware-status"),
hardwareList: boxFor("#hardware-list"),
controlPanel: boxFor("#panel-control"),
m3Form: boxFor("#m3-control-form"),
m3Actions: boxFor(".m3-control-actions"),
controlList: boxFor("#control-list"),
topbar: boxFor(".topbar")
};
const noHorizontalOverflow = {
html: document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2,
body: document.body.scrollWidth <= document.body.clientWidth + 2,
shell: boxes.shell ? boxes.shell.scrollWidth <= boxes.shell.clientWidth + 2 : false,
right: boxes.right ? boxes.right.scrollWidth <= boxes.right.clientWidth + 2 : false,
hardwareList: (() => {
const element = document.querySelector("#hardware-list");
return element ? element.scrollWidth <= element.clientWidth + 2 : false;
})()
};
const hardwareTabRows = (() => {
const tabs = [...document.querySelectorAll("[data-hardware-tab]")];
return new Set(tabs.map((tab) => Math.round(tab.getBoundingClientRect().top))).size;
})();
const rightPanelStacked = Boolean(boxes.right && boxes.center && boxes.right.top >= boxes.center.bottom - 2);
const rightWidthTarget = isDesktop
? rightPanelStacked
? boxes.right?.width >= 560 && boxes.right?.width <= viewport.width
: boxes.right?.width >= (viewport.width >= 1500 ? 700 : 560) && boxes.right?.width <= 760
: boxes.right?.width <= viewport.width && boxes.right?.width >= Math.max(0, viewport.width - 80);
const rootAfterScrollAttempt = {
windowScrollY: window.scrollY,
htmlScrollTop: document.documentElement.scrollTop,
bodyScrollTop: document.body.scrollTop
};
const rootScrollLocked =
getComputedStyle(document.documentElement).overflow === "hidden" &&
getComputedStyle(document.body).overflow === "hidden" &&
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
document.body.scrollHeight <= document.body.clientHeight + 2 &&
rootAfterScrollAttempt.windowScrollY === 0 &&
rootAfterScrollAttempt.htmlScrollTop === 0 &&
rootAfterScrollAttempt.bodyScrollTop === 0;
const keyTargets = [];
for (const [selector, label] of selectorLabels) {
keyTargets.push(await inspectHitTarget(selector, label));
}
const drawerTargets = [];
for (const [selector, label] of drawerSelectorLabels) {
drawerTargets.push(await inspectFirstHit(selector, label));
}
const blockedKeyTargets = keyTargets.filter((target) => !target.ok);
const blockedDrawerTargets = drawerTargets.filter((target) => !target.ok);
const explorerCollapsed = shell?.classList.contains("explorer-collapsed") === true;
const explorerVisible = Boolean(
boxes.explorer &&
boxes.explorer.display !== "none" &&
boxes.explorer.visibility !== "hidden" &&
boxes.explorer.width > 1 &&
boxes.explorer.height > 1
);
const requireKeyTargets = !(isMobile && mode === "mobile-drawer");
if (requireKeyTargets) {
for (const target of blockedKeyTargets) {
failures.push({
failureType: target.missing ? "blocked/skip" : "covered-hit-target",
selector: target.selector,
viewport,
summary: `${target.label} is not center-click reachable; hit=${target.hit ?? "none"}`,
target
});
}
}
const centerWidthDelta = compareTo?.boxes?.center?.width && boxes.center
? boxes.center.width - compareTo.boxes.center.width
: null;
const rightWidthDelta = compareTo?.boxes?.right?.width && boxes.right
? boxes.right.width - compareTo.boxes.right.width
: null;
const overlapChecks = {
railCenter: boxesOverlap(boxes.rail, boxes.center, -1),
explorerCenter: explorerVisible && isDesktop && boxesOverlap(boxes.explorer, boxes.center, -1),
centerRight: isDesktop && boxesOverlap(boxes.center, boxes.right, -1),
m3ActionsControlList: boxesOverlap(boxes.m3Actions, boxes.controlList, -1),
topbarCommandBar: boxesOverlap(boxes.topbar, boxes.commandBar, -1)
};
const semanticOverlapChecks = overlapPairs.map(([aSelector, bSelector, label]) => {
const a = boxFor(aSelector);
const b = boxFor(bSelector);
const overlaps = boxesOverlap(a, b, -1);
if (overlaps) {
failures.push({
failureType: "overlap",
selector: `${aSelector} <-> ${bSelector}`,
viewport,
summary: `${label} overlap`,
boxes: { [aSelector]: a, [bSelector]: b }
});
}
return {
label,
selectors: [aSelector, bSelector],
overlaps,
boxes: { [aSelector]: a, [bSelector]: b }
};
});
const noIncoherentOverlap = Object.values(overlapChecks).every((value) => value === false);
if (!noIncoherentOverlap) {
for (const [key, value] of Object.entries(overlapChecks)) {
if (value) {
failures.push({
failureType: "overlap",
selector: key,
viewport,
summary: `layout region overlap detected: ${key}`
});
}
}
}
const overflowChecks = overflowSelectors.map((selector) => {
const element = selector === "body" ? document.body : document.querySelector(selector);
if (!element) {
return { selector, exists: false, ok: true, skipped: true };
}
const style = getComputedStyle(element);
const allowHorizontalScroll = selector === ".activity-rail" || selector === ".table-wrap";
const horizontalOverflow = element.scrollWidth > element.clientWidth + 2;
const verticalOverflow = element.scrollHeight > element.clientHeight + 2;
const isRootSelector = selector === "body" || selector === "[data-app-shell]" || selector === ".workbench-shell";
const ok = isRootSelector
? !horizontalOverflow && !verticalOverflow
: !horizontalOverflow || allowHorizontalScroll || ["auto", "scroll"].includes(style.overflowX);
const result = {
selector,
exists: true,
ok,
horizontalOverflow,
verticalOverflow,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
overflowX: style.overflowX,
overflowY: style.overflowY
};
if (!ok) {
failures.push({
failureType: "overflow",
selector,
viewport,
summary: `${selector} overflow regression: scrollWidth=${element.scrollWidth}, clientWidth=${element.clientWidth}, scrollHeight=${element.scrollHeight}, clientHeight=${element.clientHeight}`,
overflow: result
});
}
return result;
});
const overflowOk = overflowChecks.every((check) => check.ok);
const sidePanelScroll = await scrollableProbe("#panel-control");
const sidePanelOverflowUsable = sidePanelScroll.exists && (sidePanelScroll.scrolls || sidePanelScroll.overflowY !== "hidden");
const wiringTab = document.querySelector("#tab-wiring");
if (wiringTab?.getAttribute("aria-selected") !== "true") {
wiringTab?.click();
await new Promise((resolve) => requestAnimationFrame(resolve));
}
const wiringPanel = document.querySelector("#panel-wiring");
const wiringWrap = document.querySelector(".wiring-table-wrap");
const wiringTable = document.querySelector(".wiring-long-table");
const wiringHeaders = [...document.querySelectorAll(".wiring-table thead th")].map((cell) => cell.textContent?.replace(/\s+/gu, " ").trim() ?? "");
const wiringRows = [...document.querySelectorAll("#wiring-body tr")].map((row) =>
[...row.querySelectorAll("td")].map((cell) => cell.textContent?.replace(/\s+/gu, " ").trim() ?? "")
);
const wiringLegacyHeaderHits = wiringHeaders.filter((header) => wiringLegacyHeaders.includes(header));
const wiringHorizontalScroll = {
panelClientWidth: wiringPanel?.clientWidth ?? 0,
panelScrollWidth: wiringPanel?.scrollWidth ?? 0,
wrapClientWidth: wiringWrap?.clientWidth ?? 0,
wrapScrollWidth: wiringWrap?.scrollWidth ?? 0,
tableClientWidth: wiringTable?.clientWidth ?? 0,
tableScrollWidth: wiringTable?.scrollWidth ?? 0,
panelOverflowX: wiringPanel ? getComputedStyle(wiringPanel).overflowX : "missing",
wrapOverflowX: wiringWrap ? getComputedStyle(wiringWrap).overflowX : "missing"
};
const wiringNoHorizontalScroll =
Boolean(wiringPanel && wiringWrap && wiringTable) &&
wiringHorizontalScroll.panelScrollWidth <= wiringHorizontalScroll.panelClientWidth + 2 &&
wiringHorizontalScroll.wrapScrollWidth <= wiringHorizontalScroll.wrapClientWidth + 2;
const wiringLongTableOk =
wiringWrap?.getAttribute("data-wiring-layout") === "two-column-long-table" &&
wiringHeaders.length === 2 &&
wiringHeaders.includes("res_boxsimu_1 互连设备 A") &&
wiringHeaders.includes("res_boxsimu_2 互连设备 B") &&
wiringLegacyHeaderHits.length === 0 &&
wiringRows.some((row) =>
row.length === 2 &&
row[0].includes("DO1") &&
row[0].includes("hwlab-patch-panel") &&
row[1].includes("DI1") &&
row[1].includes("证据来源") &&
row[1].includes("轨迹/证据")
);
if (!wiringLongTableOk) {
failures.push({
failureType: "blocked/skip",
selector: "#panel-wiring .wiring-long-table",
viewport,
summary: "#276 wiring panel is not the required two-column long table with device headers and DO1/DI1 row details.",
wiring: {
headers: wiringHeaders,
legacyHeaderHits: wiringLegacyHeaderHits,
rows: wiringRows,
layout: wiringWrap?.getAttribute("data-wiring-layout") ?? null
}
});
}
if (!wiringNoHorizontalScroll) {
failures.push({
failureType: "overflow",
selector: "#panel-wiring",
viewport,
summary: "#276 wiring panel exposes internal horizontal scroll or clipped table width.",
wiringHorizontalScroll
});
}
const controlTab = document.querySelector("#tab-control");
if (controlTab?.getAttribute("aria-selected") !== "true") {
controlTab?.click();
await new Promise((resolve) => requestAnimationFrame(resolve));
}
const toggle = {
text: toggleElement?.textContent?.replace(/\s+/gu, " ").trim() ?? "",
ariaLabel: toggleElement?.getAttribute("aria-label") ?? "",
title: toggleElement?.getAttribute("title") ?? "",
ariaExpanded: toggleElement?.getAttribute("aria-expanded") ?? "",
ariaPressed: toggleElement?.getAttribute("aria-pressed") ?? ""
};
const collapsedWidthGain = isDesktop && mode === "desktop-collapsed"
? centerWidthDelta !== null && centerWidthDelta >= 180
: true;
const restoredWidth = isDesktop && mode === "desktop-restored"
? centerWidthDelta !== null && Math.abs(centerWidthDelta) <= 4
: true;
const drawerReachable = isMobile && mode === "mobile-drawer"
? drawerTargets.length >= 3 && blockedDrawerTargets.length === 0
: true;
for (const target of isMobile && mode === "mobile-drawer" ? blockedDrawerTargets : []) {
failures.push({
failureType: target.missing ? "blocked/skip" : "covered-hit-target",
selector: target.selector,
viewport,
summary: `${target.label} drawer target is not reachable; hit=${target.hit ?? "none"}`,
target
});
}
const collapsedExpected = mode === "desktop-collapsed" || mode === "mobile-collapsed" || mode === "mobile-recollapsed";
const expandedExpected = mode === "desktop-expanded" || mode === "desktop-restored" || mode === "mobile-drawer";
const collapsedStateMatches = collapsedExpected
? explorerCollapsed === true && toggle.ariaExpanded === "false" && toggle.ariaPressed === "true" && toggle.text === "展开资源树" && toggle.ariaLabel === "展开左侧资源树"
: expandedExpected
? explorerCollapsed === false && toggle.ariaExpanded === "true" && toggle.ariaPressed === "false" && toggle.text === "收起资源树" && toggle.ariaLabel === "收起左侧资源树"
: true;
const explorerStateMatches = collapsedExpected
? isDesktop
? boxes.explorer !== null && boxes.explorer.width <= 1
: boxes.explorer?.display === "none"
: explorerVisible;
const defaultCollapsedMobile = mode === "mobile-collapsed"
? explorerCollapsed === true
: true;
const keyTargetsReachable = blockedKeyTargets.length === 0;
const keyTargetsRequirement = requireKeyTargets ? keyTargetsReachable : true;
if (!collapsedStateMatches) {
failures.push({
failureType: "blocked/skip",
selector: "#explorer-toggle",
viewport,
summary: `Explorer toggle state/Chinese label mismatch in ${mode}: text=${toggle.text}, ariaLabel=${toggle.ariaLabel}, ariaExpanded=${toggle.ariaExpanded}, ariaPressed=${toggle.ariaPressed}.`,
toggle,
expected: collapsedExpected
? {
explorerCollapsed: true,
text: "展开资源树",
ariaLabel: "展开左侧资源树",
ariaExpanded: "false",
ariaPressed: "true"
}
: {
explorerCollapsed: false,
text: "收起资源树",
ariaLabel: "收起左侧资源树",
ariaExpanded: "true",
ariaPressed: "false"
}
});
}
if (!explorerStateMatches) {
failures.push({
failureType: "blocked/skip",
selector: "#resource-explorer",
viewport,
summary: `Explorer visibility did not match expected ${collapsedExpected ? "collapsed" : "expanded"} state in ${mode}.`,
explorerCollapsed,
explorerVisible,
explorerBox: boxes.explorer
});
}
if (!collapsedWidthGain) {
failures.push({
failureType: "overflow",
selector: ".center-workspace",
viewport,
summary: `Collapsed desktop layout did not release enough width to the center workspace; centerWidthDelta=${centerWidthDelta}.`,
centerWidthDelta,
centerBox: boxes.center
});
}
if (!restoredWidth) {
failures.push({
failureType: "overflow",
selector: ".center-workspace",
viewport,
summary: `Restored desktop layout did not return center width near the expanded baseline; centerWidthDelta=${centerWidthDelta}.`,
centerWidthDelta,
centerBox: boxes.center
});
}
if (!drawerReachable) {
failures.push({
failureType: "covered-hit-target",
selector: "#resource-explorer",
viewport,
summary: "Mobile resource drawer opened but its quick actions/resource rows were not reachable.",
blockedDrawerTargets
});
}
if (!defaultCollapsedMobile) {
failures.push({
failureType: "blocked/skip",
selector: "[data-app-shell]",
viewport,
summary: "Mobile default workbench did not start with the resource explorer collapsed.",
explorerCollapsed
});
}
if (!rootScrollLocked) {
failures.push({
failureType: "outer-scroll-regression",
selector: "html/body/.workbench-shell",
viewport,
summary: "Outer html/body/workbench shell scrolled or exceeded viewport after scroll attempt.",
rootAfterScrollAttempt,
html: boxForElement(document.documentElement),
body: boxForElement(document.body),
shell: boxes.shell
});
}
if (!sidePanelOverflowUsable) {
failures.push({
failureType: "overflow",
selector: "#panel-control",
viewport,
summary: "M3 control panel does not expose usable internal scrolling when needed.",
sidePanelScroll
});
}
if (!rightWidthTarget) {
failures.push({
failureType: "overflow",
selector: ".right-sidebar",
viewport,
summary: "M3 hardware status workspace width is outside the expected desktop, stacked desktop, or mobile boundary.",
rightPanelStacked,
rightBox: boxes.right
});
}
const issue287HardwareTabsReady = ["总览", "Gateway-SIMU", "BOX-SIMU-1", "BOX-SIMU-2", "Patch Panel"].every((label) =>
Boolean([...document.querySelectorAll(".hardware-status button, .hardware-status [role='tab'], .hardware-status .side-tab")].find((item) => item.textContent?.includes(label)))
);
if (!issue287HardwareTabsReady) {
skip.push({
failureType: "skip",
selector: ".hardware-status",
viewport,
summary: "#287 hardware status tabs are not visible in the current DOM; current container overflow is still covered."
});
}
const pass =
rootScrollLocked &&
noIncoherentOverlap &&
semanticOverlapChecks.every((check) => !check.overlaps) &&
overflowOk &&
Object.values(noHorizontalOverflow).every(Boolean) &&
rightWidthTarget &&
keyTargetsRequirement &&
collapsedStateMatches &&
explorerStateMatches &&
collapsedWidthGain &&
restoredWidth &&
drawerReachable &&
defaultCollapsedMobile &&
sidePanelOverflowUsable;
const passWithWiring =
pass &&
wiringLongTableOk &&
wiringNoHorizontalScroll;
return {
mode,
viewport,
pass: passWithWiring,
explorerCollapsed,
explorerVisible,
toggle,
rootScrollLocked,
rootAfterScrollAttempt,
boxes,
centerWidthDelta,
rightWidthDelta,
collapsedWidthGain,
restoredWidth,
rightPanelStacked,
overlapChecks,
noHorizontalOverflow,
horizontalOverflowFree: Object.values(noHorizontalOverflow).every(Boolean),
hardwareTabRows,
rightWidthTarget,
noIncoherentOverlap,
keyTargetsReachable,
keyTargetsRequirement,
blockedKeyTargets,
drawerReachable,
blockedDrawerTargets,
sidePanelScroll,
sidePanelOverflowUsable,
wiring: {
longTableOk: wiringLongTableOk,
noHorizontalScroll: wiringNoHorizontalScroll,
headers: wiringHeaders,
legacyHeaderHits: wiringLegacyHeaderHits,
rows: wiringRows,
horizontalScroll: wiringHorizontalScroll
},
semanticOverlapChecks,
overflowChecks,
defaultCollapsedMobile,
issue287HardwareTabsReady,
issue288SingleTableReady: false,
skip,
failures
};
}, { mode, viewport, compareTo });
}
async function inspectGateLayoutState(page, { mode, viewport }) {
return page.evaluate(async ({ mode, viewport }) => {
const failures = [];
const skip = [];
const boxForElement = (element) => {
if (!element) return null;
const box = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
left: box.left,
top: box.top,
right: box.right,
bottom: box.bottom,
width: box.width,
height: box.height,
display: style.display,
visibility: style.visibility,
overflowX: style.overflowX,
overflowY: style.overflowY,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
text: element.textContent?.replace(/\s+/gu, " ").trim().slice(0, 160) ?? ""
};
};
const boxFor = (selector) => boxForElement(document.querySelector(selector));
const boxesOverlap = (a, b, gap = 0) => {
if (!a || !b || a.width <= 0 || a.height <= 0 || b.width <= 0 || b.height <= 0) return false;
return !(a.right + gap <= b.left || b.right + gap <= a.left || a.bottom + gap <= b.top || b.bottom + gap <= a.top);
};
const ownsHit = (element, hit) => hit === element || element.contains(hit);
const hitLabel = (hit) => hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}${hit.className ? `.${String(hit.className).trim().replace(/\s+/gu, ".")}` : ""}` : "none";
const hitTarget = (selector, label) => {
const element = document.querySelector(selector);
if (!element) {
const missing = { selector, label, ok: false, missing: true };
failures.push({
failureType: "blocked/skip",
selector,
viewport,
summary: `${label} is missing on /gate`,
target: missing
});
return missing;
}
const box = element.getBoundingClientRect();
const cx = box.left + box.width / 2;
const cy = box.top + Math.min(box.height / 2, 24);
const stack = document.elementsFromPoint(cx, cy);
const ok =
box.width > 0 &&
box.height > 0 &&
cx >= 0 &&
cx <= window.innerWidth &&
cy >= 0 &&
cy <= window.innerHeight &&
stack.some((candidate) => ownsHit(element, candidate));
const target = {
selector,
label,
ok,
center: { x: cx, y: cy },
hit: hitLabel(stack[0] ?? null),
hitStack: stack.slice(0, 5).map(hitLabel),
box: { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height }
};
if (!ok) {
failures.push({
failureType: "covered-hit-target",
selector,
viewport,
summary: `${label} is not user-reachable on /gate; hit=${target.hit}`,
target
});
}
return target;
};
window.scrollTo(0, 240);
document.documentElement.scrollTop = 240;
document.body.scrollTop = 240;
await new Promise((resolve) => requestAnimationFrame(resolve));
const gate = document.querySelector('[data-view="gate"]');
const workspace = document.querySelector('[data-view="workspace"]');
const shell = document.querySelector("[data-app-shell]");
const html = boxForElement(document.documentElement);
const body = boxForElement(document.body);
const shellBox = boxFor("[data-app-shell]");
const gateBox = boxFor('[data-view="gate"]');
const rootAfterScrollAttempt = {
windowScrollY: window.scrollY,
htmlScrollTop: document.documentElement.scrollTop,
bodyScrollTop: document.body.scrollTop
};
const rootScrollLocked =
getComputedStyle(document.documentElement).overflow === "hidden" &&
getComputedStyle(document.body).overflow === "hidden" &&
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
document.body.scrollHeight <= document.body.clientHeight + 2 &&
rootAfterScrollAttempt.windowScrollY === 0 &&
rootAfterScrollAttempt.htmlScrollTop === 0 &&
rootAfterScrollAttempt.bodyScrollTop === 0;
if (!rootScrollLocked) {
failures.push({
failureType: "outer-scroll-regression",
selector: "html/body/.workbench-shell",
viewport,
summary: "/gate route allowed page-level scrolling.",
rootAfterScrollAttempt,
html,
body,
shell: shellBox
});
}
const hitTargets = [
hitTarget('[data-route="workspace"]', "工作台"),
hitTarget('[data-route="gate"]', "内部复核"),
hitTarget('[data-route="help"]', "使用说明")
];
const gateChildren = [...document.querySelectorAll('[data-view="gate"] > *')].map((element, index) => ({
selector: `[data-view="gate"] > :nth-child(${index + 1})`,
box: boxForElement(element)
}));
const visibleOverlapPairs = [];
for (let i = 0; i < gateChildren.length; i += 1) {
for (let j = i + 1; j < gateChildren.length; j += 1) {
const a = gateChildren[i];
const b = gateChildren[j];
if (boxesOverlap(a.box, b.box, -1)) {
visibleOverlapPairs.push([a.selector, b.selector]);
failures.push({
failureType: "overlap",
selector: `${a.selector} <-> ${b.selector}`,
viewport,
summary: "/gate visible sections overlap.",
boxes: { [a.selector]: a.box, [b.selector]: b.box }
});
}
}
}
const gateHorizontalOverflow = Boolean(gate && gate.scrollWidth > gate.clientWidth + 2);
if (gateHorizontalOverflow) {
failures.push({
failureType: "overflow",
selector: '[data-view="gate"]',
viewport,
summary: `/gate horizontal overflow: scrollWidth=${gate.scrollWidth}, clientWidth=${gate.clientWidth}`,
overflow: {
scrollWidth: gate.scrollWidth,
clientWidth: gate.clientWidth,
overflowX: getComputedStyle(gate).overflowX
}
});
}
const tableCount = document.querySelectorAll('[data-view="gate"] table').length;
const workspacePanels = document.querySelectorAll('[data-view="gate"] .workspace-panel').length;
const complexLayoutCount = document.querySelectorAll('[data-view="gate"] .gate-grid, [data-view="gate"] .gate-split, [data-view="gate"] .milestone-grid').length;
const issue288SingleTableReady = tableCount === 1 && workspacePanels <= 1 && complexLayoutCount === 0;
if (!issue288SingleTableReady) {
skip.push({
failureType: "skip",
selector: '[data-view="gate"]',
viewport,
summary: `#288 single-table gate page not yet implemented: tableCount=${tableCount}, workspacePanels=${workspacePanels}, complexLayoutCount=${complexLayoutCount}`
});
}
const pass =
window.location.pathname.replace(/\/+$/, "") === "/gate" &&
gate?.hidden === false &&
workspace?.hidden === true &&
shell !== null &&
rootScrollLocked &&
hitTargets.every((target) => target.ok) &&
!gateHorizontalOverflow &&
visibleOverlapPairs.length === 0;
return {
mode,
viewport,
pass,
pathname: window.location.pathname,
workspaceHidden: workspace ? workspace.hidden : null,
gateHidden: gate ? gate.hidden : null,
rootScrollLocked,
rootAfterScrollAttempt,
boxes: {
html,
body,
shell: shellBox,
gate: gateBox
},
hitTargets,
visibleOverlapPairs,
gateHorizontalOverflow,
tableCount,
workspacePanels,
complexLayoutCount,
issue288SingleTableReady,
issue287HardwareTabsReady: false,
failures,
skip
};
}, { mode, viewport });
}
async function captureLayoutScreenshots(page, artifactRoot, viewport, stateId) {
if (!artifactRoot) return [];
return [
await captureElementScreenshot(page, ".right-sidebar", artifactRoot, viewport, `${stateId}-right-sidebar`),
await captureElementScreenshot(page, "#m3-control-form", artifactRoot, viewport, `${stateId}-m3-control-form`),
await captureElementScreenshot(page, "#command-form", artifactRoot, viewport, `${stateId}-code-agent-input`)
].filter(Boolean);
}
async function captureGateScreenshots(page, artifactRoot, viewport) {
if (!artifactRoot) return [];
return [
await captureElementScreenshot(page, '[data-view="gate"]', artifactRoot, viewport, "gate-current")
].filter(Boolean);
}
async function captureElementScreenshot(page, selector, artifactRoot, viewport, label) {
const locator = page.locator(selector).first();
const count = await locator.count();
if (count === 0) return null;
const safeLabel = label.replace(/[^a-z0-9_-]+/giu, "-").replace(/^-|-$/gu, "").toLowerCase();
const filePath = path.join(artifactRoot, `${viewport.width}x${viewport.height}-${safeLabel}.png`);
try {
await locator.screenshot({ path: filePath, timeout: 5000 });
return {
selector,
viewport: { width: viewport.width, height: viewport.height },
label,
path: filePath
};
} catch (error) {
return {
selector,
viewport: { width: viewport.width, height: viewport.height },
label,
path: filePath,
status: "blocked",
failureType: "screenshot-diff",
summary: `screenshot capture failed: ${error.message}`
};
}
}
function firstFailure(check) {
return check?.observations?.failures?.[0] ?? null;
}
function allViewportCoverage(viewportResults, fieldName) {
return Object.values(viewportResults).every((result) =>
Boolean(result.expanded?.[fieldName] ?? result.collapsed?.[fieldName] ?? result.gate?.currentRoute?.[fieldName])
);
}
function summarizeCoverageGap(viewportResults, issueKey) {
const fieldName = issueKey === "issue288" ? "issue288SingleTableReady" : "issue287HardwareTabsReady";
return {
status: allViewportCoverage(viewportResults, fieldName) ? "pass" : "skip",
coverage: Object.fromEntries(
Object.entries(viewportResults).map(([id, result]) => [
id,
{
viewport: result.expanded?.viewport ?? result.collapsed?.viewport ?? result.gate?.currentRoute?.viewport ?? null,
covered: Boolean(result.expanded?.[fieldName] ?? result.collapsed?.[fieldName] ?? result.gate?.currentRoute?.[fieldName]),
currentVisibleContainersChecked: true,
skip: [
...(result.expanded?.skip ?? []),
...(result.collapsed?.skip ?? []),
...(result.gate?.currentRoute?.skip ?? [])
].filter((item) => issueKey === "issue288"
? item.summary?.includes("#288")
: item.summary?.includes("#287"))
}
])
)
};
}
function layoutArtifactTimestamp(date = new Date()) {
return date.toISOString().replace(/[-:]/gu, "").replace(/\.\d{3}Z$/u, "Z");
}
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,
pathname: window.location.pathname,
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,
routeIsHashHelp: window.location.hash === "#help",
routeIsSlashHashHelp: window.location.hash === "#/help",
routeIsDirectHelp: (window.location.pathname.replace(/\/+$/, "") || "/") === "/help",
nonDefaultMarkdownHelp:
(window.location.hash === "#help" || window.location.hash === "#/help" || (window.location.pathname.replace(/\/+$/, "") || "/") === "/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 --source | --layout [--url http://74.48.78.17:16666/] | --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.",
"--layout runs desktop and mobile browser geometry/hit-target checks for sidebar collapse/expand; with --url it remains layout-only live evidence and does not claim M3 acceptance.",
"--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."
]
};
}