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(/<section\b[^>]*\bdata-view=["']([^"']+)["'][^>]*>/gu)] + .filter((match) => match[1] !== "workspace") + .filter((match) => !/\bhidden\b/u.test(match[0])); + const diagnosticsPanel = html.match(/<section\b[^>]*\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(`<section\\b[^>]*\\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) && !/<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(); + } +} + +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 [