diff --git a/docs/dev-acceptance-matrix.md b/docs/dev-acceptance-matrix.md
index c816cc3c..41481627 100644
--- a/docs/dev-acceptance-matrix.md
+++ b/docs/dev-acceptance-matrix.md
@@ -91,6 +91,19 @@ Network checks may be replaced by recorded observations when the runner cannot
reach the DEV host, but replacement evidence must include the artifact fields
listed above.
+Cloud Web workbench smoke is covered by:
+
+- `node scripts/dev-cloud-workbench-smoke.mjs --static`
+- `node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/`
+
+The static mode is SOURCE-level contract evidence only and must report
+`devLive=false`; it cannot promote SOURCE, LOCAL, DRY-RUN, or fixture evidence
+to DEV-LIVE. It observes the PR #114 Markdown help surface as ready when
+`web/hwlab-cloud-web/help.md`, the vendored `marked` renderer, and the
+non-default internal help route are present. The live mode is optional
+read-only HTTP plus browser DOM observation and must report `blocked` or `skip`
+structure instead of a false green when the browser check is unavailable.
+
| Step | Probe | Success Criteria | Failure Criteria | Blocker Class |
| --- | --- | --- | --- | --- |
| 1 | Confirm repository contract files and JSON checklist parse. | `docs/dev-acceptance-matrix.md` exists and `docs/dev-acceptance-checklist.json` parses. | Missing file or invalid JSON. | `contract_blocker` |
diff --git a/scripts/dev-cloud-workbench-smoke.mjs b/scripts/dev-cloud-workbench-smoke.mjs
new file mode 100644
index 00000000..2ba62c22
--- /dev/null
+++ b/scripts/dev-cloud-workbench-smoke.mjs
@@ -0,0 +1,16 @@
+#!/usr/bin/env node
+import { fileURLToPath } from "node:url";
+
+import { parseSmokeArgs, printSmokeHelp, runDevCloudWorkbenchSmoke } from "./src/dev-cloud-workbench-smoke-lib.mjs";
+
+if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
+ try {
+ const args = parseSmokeArgs(process.argv.slice(2));
+ const report = args.help ? printSmokeHelp() : await runDevCloudWorkbenchSmoke(process.argv.slice(2));
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
+ process.exitCode = report.status === "pass" || report.status === "usage" ? 0 : 2;
+ } catch (error) {
+ process.stderr.write(`[dev-cloud-workbench-smoke] ${error instanceof Error ? error.stack : String(error)}\n`);
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs
new file mode 100644
index 00000000..ce9e60b2
--- /dev/null
+++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs
@@ -0,0 +1,544 @@
+import fs from "node:fs";
+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 workbenchMarkers = Object.freeze([
+ "data-app-shell",
+ "workbench-shell",
+ "activity-rail",
+ "explorer",
+ "resource-tree",
+ "center-workspace",
+ "conversation-list",
+ "trace-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 === "--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");
+
+ 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, "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, "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", "/json-rpc", ...readOnlyRpcMethods]
+ });
+
+ 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"]
+ });
+
+ 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"],
+ mode,
+ url,
+ generatedAt: new Date().toISOString(),
+ evidenceLevel,
+ devLive,
+ endpoints: {
+ frontend: runtime.endpoints.frontend,
+ api: runtime.endpoints.api,
+ edge: runtime.endpoints.edge
+ },
+ checks,
+ blockers,
+ help,
+ safety
+ };
+}
+
+function addCheck(checks, blockers, id, result, summary, options = {}) {
+ const status = result === true ? "pass" : result === false ? "blocked" : result;
+ const check = {
+ id,
+ status,
+ summary,
+ ...(options.owner ? { owner: options.owner } : {}),
+ ...(options.evidence ? { evidence: options.evidence } : {}),
+ ...(options.observations ? { observations: options.observations } : {})
+ };
+ checks.push(check);
+ if (status === "blocked" && options.blocker) {
+ blockers.push({
+ type: options.blocker,
+ scope: id,
+ status: "open",
+ summary
+ });
+ }
+ return check;
+}
+
+function readStaticFiles() {
+ return {
+ html: readText("web/hwlab-cloud-web/index.html"),
+ styles: readText("web/hwlab-cloud-web/styles.css"),
+ app: readText("web/hwlab-cloud-web/app.mjs"),
+ runtime: readText("web/hwlab-cloud-web/runtime.mjs"),
+ panel: readText("web/hwlab-cloud-web/workbench-hardware-panel.mjs")
+ };
+}
+
+function readText(relativePath) {
+ return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
+}
+
+function assetsExist() {
+ return requiredWebAssets.every((file) => fs.existsSync(path.join(webRoot, file)));
+}
+
+function hasDefaultWorkbench({ html, app }) {
+ const workspaceTag = firstTagForDataView(html, "workspace");
+ const gateTag = firstTagForDataView(html, "gate");
+ return (
+ labelsPresent(html, workbenchMarkers) &&
+ /
\s*HWLAB 云工作台\s*<\/title>/u.test(html) &&
+ /data-route=["']workspace["']/u.test(html) &&
+ workspaceTag !== null &&
+ !/\bhidden\b/u.test(workspaceTag) &&
+ gateTag !== null &&
+ /\bhidden\b/u.test(gateTag) &&
+ finalRouteFallback(app) === "workspace"
+ );
+}
+
+function secondaryViewsAreNotDefault({ html, app }) {
+ const finalFallback = finalRouteFallback(app);
+ const unhiddenSecondaryViews = [...html.matchAll(/]*\bdata-view=["']([^"']+)["'][^>]*>/gu)]
+ .filter((match) => match[1] !== "workspace")
+ .filter((match) => !/\bhidden\b/u.test(match[0]));
+ const diagnosticsPanel = html.match(/]*\bdata-side-panel=["']diagnostics["'][^>]*>/u)?.[0] ?? "";
+ return finalFallback === "workspace" && unhiddenSecondaryViews.length === 0 && /\bhidden\b/u.test(diagnosticsPanel);
+}
+
+function firstTagForDataView(html, view) {
+ return html.match(new RegExp(`]*\\bdata-view=["']${escapeRegExp(view)}["'][^>]*>`, "u"))?.[0] ?? null;
+}
+
+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|hardware-list)[^\{]*\{[^\}]*min-height:\s*0;[^\}]*overflow:\s*auto;/su.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\("\/json-rpc"/u.test(app) &&
+ JSON.stringify(rpcCalls) === JSON.stringify([...readOnlyRpcMethods].sort()) &&
+ !/fetchJson\(\s*["']https?:\/\//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.evidenceRecords.every((record) => record.dryRun === true) &&
+ gateSummary.safety.allowNetwork === false &&
+ ["来源 SOURCE", "演练 DRY-RUN", "开发实况 DEV-LIVE", "阻塞 BLOCKED"].every((label) => source.includes(label))
+ );
+}
+
+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) && !/]*>\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();
+ }
+}
+
+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.",
+ "Live mode is read-only and reports blocked/skip when optional browser DOM evidence is unavailable."
+ ]
+ };
+}
diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs
index 6a462a81..bc6238e1 100644
--- a/web/hwlab-cloud-web/scripts/check.mjs
+++ b/web/hwlab-cloud-web/scripts/check.mjs
@@ -3,6 +3,7 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadMvpGateSummary } from "../../../internal/mvp-gate/summary.mjs";
+import { runDevCloudWorkbenchStaticSmoke } from "../../../scripts/src/dev-cloud-workbench-smoke-lib.mjs";
import { gateSummary } from "../gate-summary.mjs";
import { runCloudWebM3ReadonlyContract } from "./m3-readonly-contract.mjs";
import { runWorkbenchHardwarePanelContract } from "./workbench-hardware-panel-contract.mjs";
@@ -36,6 +37,14 @@ const buildScript = fs.readFileSync(path.resolve(rootDir, "scripts/build.mjs"),
const markedLicense = fs.readFileSync(path.resolve(rootDir, "third_party/marked/LICENSE"), "utf8");
const artifactPublisher = fs.readFileSync(path.resolve(repoRoot, "scripts/dev-artifact-publish.mjs"), "utf8");
const frontendSource = `${html}\n${app}\n${artifactPublisher}`;
+const workbenchSmoke = runDevCloudWorkbenchStaticSmoke();
+const helpSmokeCheck = workbenchSmoke.checks.find((check) => check.id === "help-md-contract");
+
+assert.equal(workbenchSmoke.status, "pass", JSON.stringify(workbenchSmoke.blockers, null, 2));
+assert.equal(workbenchSmoke.evidenceLevel, "SOURCE");
+assert.equal(workbenchSmoke.devLive, false);
+assert.equal(helpSmokeCheck?.status, "pass", "PR #114 help Markdown route must be present and ready");
+assert.equal(workbenchSmoke.help.status, "pass", "smoke report must not leave #114 help contract pending");
assert.match(html, /HWLAB 云工作台/);
for (const chineseWorkbenchText of [