Merge pull request #115 from pikasTech/test/issue108-static-smoke

test: add Cloud Workbench smoke contract
This commit is contained in:
Lyon
2026-05-22 19:30:15 +08:00
committed by GitHub
4 changed files with 582 additions and 0 deletions
+13
View File
@@ -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` |
+16
View File
@@ -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;
}
}
@@ -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) &&
/<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]));
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."
]
};
}
+9
View File
@@ -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 [