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