fix(web): enforce React TypeScript single path

This commit is contained in:
Codex
2026-06-03 17:45:18 +08:00
parent 6ecfe015cf
commit 58b06fcd17
92 changed files with 2964 additions and 11294 deletions
+7 -1
View File
@@ -15,12 +15,17 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."
export function parseLayoutSmokeArgs(argv) {
if (argv.includes("--help")) return { help: true };
const normalized = ["--layout"];
let liveMode = false;
let buildMode = false;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--static" || arg === "--source") {
buildMode = true;
continue;
}
if (arg === "--build") buildMode = true;
if (arg === "--live") {
liveMode = true;
const next = argv[index + 1];
if (next && !next.startsWith("--")) {
normalized.push("--url");
@@ -39,6 +44,7 @@ export function parseLayoutSmokeArgs(argv) {
normalized.push(argv[index]);
}
}
if (!liveMode && !buildMode) normalized.push("--build");
return parseSmokeArgs(normalized);
}
@@ -101,7 +107,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
...printSmokeHelp(),
command: `node scripts/dev-cloud-workbench-layout-smoke.mjs [--static|--build|--live --url http://74.48.78.17:16666/] [--report ${tempReportPath("dev-cloud-workbench-layout.json")}]`,
layoutNotes: [
"默认 --static 使用 source/static 本地静态服务。",
"默认 --static 使用 Vite local-build;源码静态服务路径已废弃。",
"--build 会先运行 cd web/hwlab-cloud-web && bun run build,再检查本地 dist 构建产物。",
"--live --url http://74.48.78.17:16666/ 只做 DEV live UI 布局/命中/溢出检查,不发送 Code Agent 消息,不调用 Device Pod 或硬件写操作,不等同于硬件验收。",
"结构化失败会包含 status、viewport、selector、failureType、artifact screenshot/report path。"
+29 -31
View File
@@ -40,6 +40,7 @@ const expectedRuntimeIdentity = Object.freeze({
const rootPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
const cloudWebPackage = JSON.parse(readFileSync(new URL("../web/hwlab-cloud-web/package.json", import.meta.url), "utf8"));
const cloudWebCheckSource = readFileSync(new URL("../web/hwlab-cloud-web/scripts/check.ts", import.meta.url), "utf8");
const cloudWebTscCheckSource = readFileSync(new URL("../web/hwlab-cloud-web/scripts/tsc-check.ts", import.meta.url), "utf8");
test("workbench smoke defaults to SOURCE mode and requires live confirmation before DEV-LIVE provider calls", () => {
const defaultArgs = parseSmokeArgs([]);
@@ -79,10 +80,9 @@ test("source/default workbench report cannot claim DEV-LIVE and documents the co
report.validationCommands.includes("node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/ --report /tmp/hwlab-dev-gate/dev-cloud-workbench-live.json"),
false
);
assert.equal(report.checks.find((check) => check.id === "code-agent-long-timeout-contract")?.status, "pass");
const traceDisclosure = report.checks.find((check) => check.id === "code-agent-trace-replay-disclosure");
assert.equal(traceDisclosure?.status, "pass");
assert.deepEqual(traceDisclosure?.evidence, ["显示全部可读事件 / 完整 trace 回放中", "复制 JSON", "下载 trace", "traceDetailsOpen", "traceScrollPositions", "traceScrollPinnedToBottom", "internal scroll for full trace"]);
assert.equal(report.checks.find((check) => check.id === "react-strict-typescript-entry")?.status, "pass");
assert.equal(report.checks.find((check) => check.id === "vite-build-and-dist-contract")?.status, "pass");
assert.equal(report.checks.find((check) => check.id === "legacy-runtime-paths-removed")?.status, "pass");
});
test("Code Agent browser failure classifier emits Chinese timeout/provider/browser categories with traceId", () => {
@@ -180,32 +180,30 @@ test("smoke args include a DOM-only live read-only mode", () => {
assert.equal(args.urlExplicit, true);
});
test("source/default smoke covers #352 resource explorer removal contract", () => {
test("source/default smoke covers React shell-only and module boundary contracts", () => {
const report = runDevCloudWorkbenchStaticSmoke();
const check = report.checks.find((item) => item.id === "feedback-352-resource-explorer-removed");
assert.equal(check?.status, "pass");
assert.equal(check.evidence.includes("aside#resource-explorer absent"), true);
assert.equal(check.evidence.includes("#explorer-resize absent"), true);
assert.equal(report.checks.find((item) => item.id === "react-shell-only-index")?.status, "pass");
assert.equal(report.checks.find((item) => item.id === "react-module-boundaries")?.status, "pass");
assert.equal(report.checks.find((item) => item.id === "react-api-state-separation")?.status, "pass");
});
test("source/default smoke covers Device Pod right-sidebar contract", () => {
test("source/default smoke covers React workbench selectors and layout", () => {
const report = runDevCloudWorkbenchStaticSmoke();
const summary = report.checks.find((item) => item.id === "device-pod-summary-sidebar");
const events = report.checks.find((item) => item.id === "device-pod-event-stream");
const service = report.checks.find((item) => item.id === "device-pod-executor-authority");
assert.equal(summary?.status, "pass");
assert.equal(events?.status, "pass");
assert.equal(service?.status, "pass");
assert.equal(summary.evidence.includes("summary-tile"), true);
assert.equal(summary.evidence.includes("device-detail-dialog"), true);
assert.equal(events.evidence.includes("device-event-text"), true);
const selectors = report.checks.find((item) => item.id === "react-core-workbench-selectors");
const layout = report.checks.find((item) => item.id === "react-layout-contract");
assert.equal(selectors?.status, "pass");
assert.equal(layout?.status, "pass");
assert.equal(selectors.evidence.includes("#device-pod-sidebar"), true);
assert.equal(layout.evidence.includes("@media (max-width: 720px)"), true);
});
test("source/default smoke covers #288 gate single-table contract", () => {
test("source/default smoke removes legacy vanilla runtime paths", () => {
const report = runDevCloudWorkbenchStaticSmoke();
const check = report.checks.find((item) => item.id === "feedback-288-gate-single-table");
const check = report.checks.find((item) => item.id === "legacy-runtime-paths-removed");
assert.equal(check?.status, "pass");
assert.equal(report.checks.some((item) => item.id === "feedback-119-gate-retains-diagnostics"), false);
assert.equal(check.evidence.includes("removed:app.ts"), true);
assert.equal(check.evidence.includes("removed:styles.css"), true);
assert.equal(report.checks.some((item) => item.id === "feedback-288-gate-single-table"), false);
});
test("live workbench identity passes only when runtime commit or image tag matches current source", () => {
@@ -270,18 +268,18 @@ test("live workbench identity uses deploy desired runtime identity instead of gi
test("live web asset identity blocks stale deployed primary assets", () => {
const pass = classifyLiveWebAssetIdentity([
{ path: "index.html", status: "match" },
{ path: "styles.css", status: "match" },
{ path: "assets/index.css", status: "match" },
{ path: "app.js", status: "match" }
]);
assert.equal(pass.status, "pass");
const stale = classifyLiveWebAssetIdentity([
{ path: "index.html", status: "match" },
{ path: "styles.css", status: "mismatch" },
{ path: "assets/index.css", status: "mismatch" },
{ path: "app.js", status: "mismatch" }
]);
assert.equal(stale.status, "blocked");
assert.deepEqual(stale.mismatches, ["styles.css", "app.js"]);
assert.deepEqual(stale.mismatches, ["assets/index.css", "app.js"]);
assert.match(stale.summary, /Deployment drift/u);
});
@@ -498,13 +496,13 @@ test("Code Agent browser classifier blocks completed payloads without backend ev
test("repo-owned web checks keep cloud-web semantic checks out of old browser gates", () => {
const checkCommands = checkProfiles.check.map((task) => task.command.join(" "));
assert.match(rootPackage.scripts["web:check"], /cd web\/hwlab-cloud-web && bun run check/u);
assert.match(cloudWebPackage.scripts.check, /bun test && bun run scripts\/check\.ts/u);
assert.match(cloudWebPackage.scripts.check, /bun run scripts\/check\.ts && bun run scripts\/tsc-check\.ts && bun test/u);
assert.ok(checkProfiles.check.some((task) => task.cwd === "web/hwlab-cloud-web" && task.command.join(" ") === "bun run check"));
assert.doesNotMatch(checkCommands.join("\n"), /dev-cloud-workbench-layout-smoke|gate-summary\.mjs|export-web-gate-summary/u);
assert.match(cloudWebCheckSource, /assertTraceHelpersBound/u);
assert.match(cloudWebCheckSource, /Device Pod summary sidebar/u);
assert.match(cloudWebCheckSource, /device-detail-dialog/u);
assert.match(cloudWebCheckSource, /device-event-text/u);
assert.match(cloudWebCheckSource, /isCodeAgentTimeoutError/u);
assert.match(cloudWebCheckSource, /React module assets present/u);
assert.match(cloudWebTscCheckSource, /explicit any/u);
assert.match(cloudWebTscCheckSource, /--strict/u);
assert.match(cloudWebCheckSource, /legacy runtime path/u);
assert.match(cloudWebCheckSource, /dist bundle built by Vite/u);
assert.doesNotMatch(cloudWebCheckSource, /m3-readonly-contract|workbench-hardware-panel-contract|gate-summary\.mjs/u);
});
@@ -1,4 +1,5 @@
import { execFileSync } from "node:child_process";
import { readdirSync, statSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -9,13 +10,7 @@ import { buildDesiredStatePlan } from "./deploy-desired-state-plan.mjs";
import { ensureNotRepoReportsPath, tempReportPath } from "./report-paths.mjs";
const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const cloudWebAppSourceFiles = Object.freeze([
"web/hwlab-cloud-web/app.ts",
"web/hwlab-cloud-web/app-device-pod.ts",
"web/hwlab-cloud-web/app-conversation.ts",
"web/hwlab-cloud-web/app-trace.ts",
"web/hwlab-cloud-web/app-helpers.ts"
]);
const cloudWebSourceRoot = "web/hwlab-cloud-web/src";
function bunExecutable() {
return process.env.HWLAB_BUN_BINARY || process.env.BUN_BINARY || "bun";
@@ -42,10 +37,22 @@ function inspectCloudWebDistFreshness(webRoot) {
}
async function readCloudWebAppSource(repoRoot) {
const parts = await Promise.all(cloudWebAppSourceFiles.map((file) => readFile(path.join(repoRoot, file), "utf8")));
const files = collectCloudWebSourceFiles(path.join(repoRoot, cloudWebSourceRoot));
const parts = await Promise.all(files.map((file) => readFile(file, "utf8")));
return parts.join("\n");
}
function collectCloudWebSourceFiles(dir) {
const files = [];
for (const entry of readdirSync(dir)) {
const full = path.join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) files.push(...collectCloudWebSourceFiles(full));
else if (/\.(ts|tsx|css)$/u.test(full)) files.push(full);
}
return files.sort();
}
const defaultArtifactReport = tempReportPath("dev-artifacts.json");
const defaultArtifactCatalog = "deploy/artifact-catalog.dev.json";
const defaultRuntimeReport = tempReportPath("dev-cloud-workbench-live.json");
@@ -1005,7 +1012,7 @@ async function inspectM3IoSourceReadiness(repoRoot) {
}
const present = matches.length > 0;
const sourceReady = contractFiles.length > 0 &&
matches.some((file) => cloudWebAppSourceFiles.includes(file)) &&
matches.some((file) => file.startsWith(`${cloudWebSourceRoot}/`)) &&
matches.some((file) => file === "internal/cloud/m3-io-control.ts" || file === "scripts/src/m3-io-control-e2e.mjs");
return {
status: present ? (sourceReady ? "pass" : "blocked") : "absent",
+195 -209
View File
@@ -19,7 +19,13 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../
const domOnlyReportPath = tempReportPath("dev-cloud-workbench-dom-only.json");
const liveReportPath = tempReportPath("dev-cloud-workbench-live.json");
const webRoot = path.join(repoRoot, "web/hwlab-cloud-web");
const runtime = readCloudWebRuntime();
const runtime = Object.freeze({
endpoints: Object.freeze({
frontend: "http://74.48.78.17:16666",
api: "http://74.48.78.17:16667",
edge: "http://74.48.78.17:16667"
})
});
const defaultLiveUrl = "http://74.48.78.17:16666/";
const helpOwner = "codex_1779444232735_1";
const legacyFailureWindowMs = 4500;
@@ -253,35 +259,35 @@ const markdownRendererPackages = Object.freeze([
]);
const cloudWebAppSourceFiles = Object.freeze([
"app.ts",
"app-device-pod.ts",
"app-conversation.ts",
"app-trace.ts",
"app-helpers.ts"
]);
const cloudWebModuleSourceFiles = Object.freeze([
"src/services/auth.ts",
"src/logic/code-agent-facts.ts",
"src/logic/code-agent-status.ts",
"src/logic/message-markdown.ts",
"src/logic/live-status.ts",
"src/logic/runtime.ts"
"src/main.tsx",
"src/App.tsx"
]);
const requiredWebAssets = Object.freeze([
"index.html",
"styles.css",
"src/styles/workbench.css",
...cloudWebAppSourceFiles,
...cloudWebModuleSourceFiles,
"src/components/layout/ActivityRail.tsx",
"src/components/auth/LoginShell.tsx",
"src/components/sessions/SessionSidebar.tsx",
"src/components/conversation/ConversationPanel.tsx",
"src/components/command-bar/CommandBar.tsx",
"src/components/device-pod/DevicePodSidebar.tsx",
"src/components/settings/SettingsView.tsx",
"src/components/skills/SkillsView.tsx",
"src/hooks/useAuth.ts",
"src/services/api/client.ts",
"src/services/markdown/render.ts",
"src/state/workbench.ts",
"src/state/conversation.ts",
"src/types/domain.ts",
"favicon.svg",
"favicon.ico",
"help.md",
"third_party/marked/marked.esm.js",
"third_party/marked/LICENSE"
"vite.config.ts"
]);
const liveIdentityWebAssets = Object.freeze(["index.html", "styles.css", "app.js"]);
const liveIdentityWebAssets = Object.freeze(["index.html", "assets/index.css", "app.js"]);
const playwrightDependencyCommand = "npm install";
const playwrightDependencySummary =
@@ -429,175 +435,50 @@ function runStaticSmoke() {
const checks = [];
const blockers = [];
const files = readStaticFiles();
const source = Object.values(files).join("\n");
const buildScript = readText("web/hwlab-cloud-web/scripts/build.ts");
const distContractScript = readText("web/hwlab-cloud-web/scripts/dist-contract.ts");
const source = `${files.html}\n${files.app}\n${files.styles}\n${files.packageJson}\n${files.tsconfig}\n${files.checkScript}\n${files.tscCheck}`;
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), "Authenticated route is the VS Code-style Cloud Workbench.", {
addCheck(checks, blockers, "react-shell-only-index", reactShellOnlyIndex(files), "Cloud Web index.html is a shell-only React mount entry.", {
blocker: "runtime_blocker",
evidence: ["workspace view exists behind login", "Gate view is hidden by default after auth", ...workbenchMarkers]
evidence: ["<div id=\"root\"></div>", "/src/main.tsx", "no business DOM in index.html"]
});
addCheck(checks, blockers, "secondary-gate-status-help", secondaryViewsAreNotDefault(files), "Gate/status/diagnostics/help surfaces are not accepted as the default homepage.", {
addCheck(checks, blockers, "react-strict-typescript-entry", reactStrictTypeScriptEntry(files), "React runtime is mounted from TSX under strict TypeScript without explicit any in source.", {
blocker: "runtime_blocker",
evidence: ["routeFromLocation final fallback is workspace", "non-workspace data-view sections must be hidden"]
evidence: ["createRoot", "React.StrictMode", "strict=true", "noImplicitAny=true", "tsc-check explicit any scan"]
});
addCheck(checks, blockers, "chinese-workbench-labels", labelsPresent(source, chineseWorkbenchLabels), "Current Chinese workbench labels are present.", {
addCheck(checks, blockers, "react-module-boundaries", reactModuleBoundaries(files), "React modules are split by layout/auth/sessions/conversation/command-bar/device-pod/settings/shared/hooks/services/state/types/styles.", {
blocker: "contract_blocker",
evidence: chineseWorkbenchLabels
evidence: requiredWebAssets.filter((file) => file.startsWith("src/"))
});
addCheck(checks, blockers, "feedback-117-default-no-backstage", defaultHomepageHidesBackstage(files), "Default workbench hides Gate, diagnostics, acceptance, BLOCKED, and M0-M5 backstage information.", {
addCheck(checks, blockers, "legacy-runtime-paths-removed", legacyRuntimePathsRemoved(), "Legacy vanilla runtime files are removed and cannot remain as a second frontend path.", {
blocker: "runtime_blocker",
evidence: defaultHomepageForbiddenTerms
evidence: legacyRuntimeFiles.map((file) => `removed:${file}`)
});
addCheck(checks, blockers, "feedback-118-complete-nav", hasCompletePrimaryNavigation(files.html), "Primary navigation uses complete Chinese labels and removes the left-rail trusted-records shortcut.", {
addCheck(checks, blockers, "react-core-workbench-selectors", reactCoreWorkbenchSelectors(files), "React runtime renders the authenticated shell, command bar, session sidebar, conversation, Device Pod sidebar, settings, skills, gate, and help selectors.", {
blocker: "contract_blocker",
evidence: ["工作台", "内部复核", "使用说明"]
evidence: ["data-app-shell", "#command-input", "#conversation-list", "#device-pod-sidebar", "#logout-button"]
});
addCheck(checks, blockers, "feedback-288-gate-single-table", gateSingleTableContract(files), "Internal review /gate is a single read-only table backed by the live diagnostics aggregation route.", {
addCheck(checks, blockers, "react-api-state-separation", reactApiStateSeparation(files), "API, workspace/session state, conversation mapping, markdown rendering, and UI components are separated.", {
blocker: "contract_blocker",
evidence: [...gateReviewTableColumns, "/v1/diagnostics/gate", "LIVE-BACKEND", ...gateReviewStatusLabels]
evidence: ["services/api/client.ts", "state/workbench.ts", "state/conversation.ts", "services/markdown/render.ts"]
});
addCheck(checks, blockers, "internal-gate-route-aliases", gateRouteAliasesAreServed(files.app, files.artifactPublisher, buildScript, distContractScript), "/gate and /diagnostics/gate are direct internal diagnostic aliases, not default routes.", {
addCheck(checks, blockers, "vite-build-and-dist-contract", viteBuildAndDistContract(files), "Vite build, route aliases, dist freshness, and root app.js output are the only deployable Cloud Web asset path.", {
blocker: "runtime_blocker",
evidence: gateRouteAliases
evidence: ["vite.config.ts", "dist-contract.ts", "app.js", "assets/index.css", ...gateRouteAliases, ...helpRouteAliases]
});
addCheck(checks, blockers, "direct-help-route-alias", helpRouteAliasesAreServed(files.app, files.artifactPublisher, buildScript, distContractScript), "/help is a direct user-facing help alias and does not become the default route.", {
addCheck(checks, blockers, "react-layout-contract", reactLayoutContract(files), "React CSS owns desktop, narrow, and 390px mobile layout without page-level scrolling.", {
blocker: "runtime_blocker",
evidence: [...helpRouteAliases, "routeFromLocation final fallback is workspace"]
});
addCheck(checks, blockers, "device-pod-summary-sidebar", devicePodSummarySidebarContract(files), "Right sidebar is a Device Pod summary board: no internal expand/collapse, summary tiles only, and click/keyboard opens the detail dialog.", {
blocker: "contract_blocker",
evidence: ["device-pod-sidebar", "device-pod-summary", "summary-tile", "device-detail-dialog", "no <details> in right sidebar"]
});
addCheck(checks, blockers, "device-pod-event-stream", devicePodEventStreamContract(files), "Device Pod events render as a simple plain-text stream whose updates do not steal the user's scroll position.", {
blocker: "runtime_blocker",
evidence: ["device-event-scroll", "device-event-text", "setDeviceEventFollow", "deviceEventUserActive", "overscroll-behavior: contain"]
});
addCheck(checks, blockers, "device-pod-executor-authority", devicePodExecutorAuthorityContract(files), "The hwlab-device-pod executor and Cloud API compatibility payload expose the Device Pod REST surface without fake fallback.", {
blocker: "runtime_blocker",
evidence: ["hwlab-device-pod", "/v1/device-pods", "/status", "/events", "device-pod-executor-v1"]
});
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 without a resource explorer drawer.", {
blocker: "runtime_blocker",
evidence: [
"mobile rail spans full height",
"center workspace and right sidebar share the content column",
"resource explorer drawer selectors are absent"
]
});
addCheck(checks, blockers, "feedback-352-resource-explorer-removed", hasResourceExplorerRemovalContract(files), "Default workbench removes the left resource explorer, quick actions, resource tree, capability cards, and explorer resize handle.", {
blocker: "runtime_blocker",
evidence: [
"aside#resource-explorer absent",
"#explorer-resize absent",
"resource tree / quick actions / capability cards absent",
"right side keeps a 560-740px Device Pod summary boundary through --right-width"
]
});
addCheck(checks, blockers, "api-live-status-attribution", hasApiLiveStatusAttribution(files), "Default workbench status maps raw API health into pass, error, unverified, or read-only with concrete service/API attribution.", {
blocker: "observability_blocker",
evidence: ["API 正常", "API 错误", "等待验证", "只读模式", "hwlab-cloud-api /health/live", "/v1/agent/chat", "/v1/device-pods", "raw degraded kept as internalRawStatuses"]
});
addCheck(checks, blockers, "workbench-live-surface-timeout-contract", hasWorkbenchLiveSurfaceTimeoutContract(files), "Workbench live status uses a dedicated live-surface timeout instead of the 4500ms light API budget.", {
blocker: "runtime_blocker",
evidence: ["DEFAULT_LIVE_SURFACE_TIMEOUT_MS=12000", "liveSurfaceFetch", "liveSurfaceRpc", "/v1/device-pods", "system.health"]
});
addCheck(checks, blockers, "code-agent-chat-primary-flow", hasCodeAgentChatContract(files), "Center Agent conversation sends to the controlled Code Agent chat endpoint and no longer uses 添加草稿 as the primary flow.", {
blocker: "runtime_blocker",
evidence: ["command-send", "agent-chat-status", "/v1/agent/chat", "conversationId/messageId/status/timestamps/error.message"]
});
addCheck(checks, blockers, "code-agent-long-timeout-contract", hasCodeAgentLongTimeoutContract(files), "Code Agent chat uses a dedicated long timeout and does not reuse the old 4500ms API timeout.", {
blocker: "runtime_blocker",
evidence: ["DEFAULT_API_TIMEOUT_MS=4500 for light probes", `DEFAULT_CODE_AGENT_TIMEOUT_MS=${codeAgentLongTimeoutMs}`, "sendAgentMessage passes timeoutMs"]
});
addCheck(checks, blockers, "code-agent-trace-replay-disclosure", hasCodeAgentTraceReplayDisclosure(files), "Trace replay panels auto-replay full trace, avoid compressed-window UI, and preserve open/scroll state while live trace updates.", {
blocker: "observability_blocker",
evidence: ["显示全部可读事件 / 完整 trace 回放中", "复制 JSON", "下载 trace", "traceDetailsOpen", "traceScrollPositions", "traceScrollPinnedToBottom", "internal scroll for full trace"]
});
addCheck(checks, blockers, "code-agent-provider-readiness-visibility", hasCodeAgentReadinessVisibility(files), "Workbench shows provider/stdio blockers without exposing credential internals and only long-lived Codex stdio replies can become full Code Agent completion.", {
blocker: "observability_blocker",
evidence: ["服务受阻 or legacy BLOCKED 凭证缺口", "provider_unavailable classifier", "completed -> dev-live guard", "default workspace hides credential internals"]
});
addCheck(checks, blockers, "code-agent-status-summary", hasCodeAgentStatusSummaryContract(files), "Code Agent area has a compact collapsible status summary for Codex stdio, blockers, trace, and current deployed revision.", {
blocker: "observability_blocker",
evidence: [
"code-agent-summary",
"fallback-text-chat-only",
"stateless-one-shot",
"read-only-session-tools",
"当前部署 revision"
]
});
addCheck(checks, blockers, "default-workspace-sanitized", defaultWorkspaceIsSanitized(files), "Default user workspace hides raw blocker codes, internal route markers, and credential reference material.", {
blocker: "observability_blocker",
evidence: defaultHomepageForbiddenTerms
});
addCheck(checks, blockers, "route-control-stable-dimensions", hasStableRouteControls(files), "Route controls and status tags have stable dimensions, readable wrapping, and usable labels on small screens.", {
blocker: "runtime_blocker",
evidence: ["rail-button min-width/min-height", "state-tag wrapping", "summary-tile wrapping", "mobile trace column"]
});
addCheck(checks, blockers, "code-agent-completed-evidence-visible", hasCodeAgentCompletedEvidenceVisibility(files), "Completed full Code Agent replies expose Codex stdio runnerTrace and reject echo/mock/stub, OpenAI fallback, and local shortcut completions.", {
blocker: "observability_blocker",
evidence: requiredCodeAgentEvidenceTerms
});
addCheck(checks, blockers, "code-agent-conversation-ux-states", hasCodeAgentConversationUxStates(files), "Code Agent thread renders trusted completed, SOURCE fixture, failed, and blocked provider states as distinct conversation replies with bounded trace.", {
blocker: "observability_blocker",
evidence: [
"DEV-LIVE 回复",
"SOURCE 回复",
"等待超时/API 错误/后端失败",
"服务受阻 or legacy BLOCKED 凭证缺口",
"message-trace/details/events"
]
});
addCheck(checks, blockers, "no-hardware-write-api", noForbiddenWriteSurface(`${files.html}\n${files.app}\n${files.liveStatus}`), "Workbench frontend 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: ["SOURCE remains source-level", "Device Pod compatibility fallback is blocked", "no DEV-LIVE claim from fixture data"]
});
const help = inspectHelpContract(files);
addCheck(checks, blockers, "help-md-contract", help.status, help.summary, {
blocker: help.blocker,
owner: helpOwner,
observations: help.observations
evidence: ["html/body overflow hidden", "@media (max-width: 720px)", "390px mobile grid", "event stream overscroll contained"]
});
return baseReport({
@@ -607,10 +488,7 @@ function runStaticSmoke() {
blockers,
evidenceLevel: "SOURCE",
devLive: false,
help: {
status: help.status,
...help.observations
},
help: inspectReactHelpContract(files),
safety: staticSafety()
});
}
@@ -889,7 +767,7 @@ async function runLiveDomOnlySmoke(args) {
export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
const useLiveUrl = args.live === true || args.urlExplicit === true;
const layoutSourceMode = useLiveUrl ? "dev-live" : args.build === true ? "local-build" : "source-static";
const layoutSourceMode = useLiveUrl ? "dev-live" : "local-build";
const artifactRoot = path.resolve(
repoRoot,
args.artifactDir ?? path.join("tmp", "dev-cloud-workbench-layout-smoke", layoutArtifactTimestamp())
@@ -978,8 +856,11 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
try {
browser = await launchChromium(chromium);
const checks = [];
const screenshots = [];
for (const viewport of layoutViewports) {
checks.push(await inspectMinimalWorkbenchLayout(browser, url, viewport));
const check = await inspectMinimalWorkbenchLayout(browser, url, viewport, { artifactRoot });
checks.push(check);
screenshots.push(...(check.artifacts?.screenshots ?? []));
}
const blockers = checks
.filter((check) => check.status !== "pass")
@@ -1030,7 +911,7 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
devLive: false,
summary: useLiveUrl
? "Live browser layout smoke verifies that the workbench opens and core controls are visible."
: "Static local browser layout smoke verifies that the workbench opens and core controls are visible; detailed visual acceptance remains manual/live.",
: "Vite local-build browser layout smoke verifies that the workbench opens and core controls are visible; detailed visual acceptance remains manual/live.",
refs: ["pikasTech/HWLAB#273"],
viewports: layoutViewports.map(({ id, width, height }) => ({ id, width, height })),
checks,
@@ -1039,7 +920,7 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
skipped,
artifacts: {
screenshotDir: artifactRoot,
screenshots: [],
screenshots,
reportPath: args.reportPath ?? null
},
safety: layoutSafety()
@@ -1259,7 +1140,7 @@ function layoutReportLifecycle(useLiveUrl, status) {
deprecatedEndpoint: null,
summary: useLiveUrl
? `DEV live layout smoke ${status}; this is UI layout/clickability evidence only and not hardware acceptance.`
: `Repository source/static layout smoke ${status}; this runs without public DEV dependency and cannot be used as DEV-LIVE evidence.`
: `Repository Vite local-build layout smoke ${status}; this runs without public DEV dependency and cannot be used as DEV-LIVE evidence.`
};
}
@@ -1294,11 +1175,11 @@ function layoutLocalSmoke(status) {
"npm run web:layout:build"
],
evidence: [
"web:check runs the SOURCE/static layout smoke as a repo-owned frontend gate.",
"web:layout runs the source/static Playwright geometry, hit-target, overflow, and outer-scroll checks without public DEV dependency.",
"web:layout:build rebuilds local dist and checks the built Cloud Web assets."
"web:check runs strict React TypeScript, module-boundary, dist freshness, and unit checks as the repo-owned frontend gate.",
"web:layout runs the Vite local-build Playwright geometry and core-control visibility checks without public DEV dependency.",
"web:layout:build is the same Vite local-build browser smoke entry."
],
summary: "SOURCE/static and local-build layout checks classify UI overlap, covered hit targets, overflow, and outer-scroll regressions as layout blockers."
summary: "Vite local-build layout checks classify hidden core controls and browser startup regressions as layout blockers."
};
}
@@ -1511,14 +1392,14 @@ function addCheck(checks, blockers, id, result, summary, options = {}) {
function readStaticFiles() {
return {
html: readText("web/hwlab-cloud-web/index.html"),
styles: readText("web/hwlab-cloud-web/styles.css"),
auth: readText("web/hwlab-cloud-web/src/services/auth.ts"),
styles: readText("web/hwlab-cloud-web/src/styles/workbench.css"),
app: readCloudWebAppSource(),
codeAgentFacts: readText("web/hwlab-cloud-web/src/logic/code-agent-facts.ts"),
codeAgentStatus: readText("web/hwlab-cloud-web/src/logic/code-agent-status.ts"),
messageMarkdown: readText("web/hwlab-cloud-web/src/logic/message-markdown.ts"),
liveStatus: readText("web/hwlab-cloud-web/src/logic/live-status.ts"),
runtime: readText("web/hwlab-cloud-web/src/logic/runtime.ts"),
packageJson: readText("web/hwlab-cloud-web/package.json"),
tsconfig: readText("web/hwlab-cloud-web/tsconfig.json"),
checkScript: readText("web/hwlab-cloud-web/scripts/check.ts"),
tscCheck: readText("web/hwlab-cloud-web/scripts/tsc-check.ts"),
distContract: readText("web/hwlab-cloud-web/scripts/dist-contract.ts"),
viteConfig: readText("web/hwlab-cloud-web/vite.config.ts"),
help: readText("web/hwlab-cloud-web/help.md"),
artifactPublisher: readText("scripts/artifact-publish.mjs"),
cloudApiServer: readText("internal/cloud/server.ts"),
@@ -1531,19 +1412,123 @@ function readStaticFiles() {
};
}
const legacyRuntimeFiles = Object.freeze([
"app.ts",
"app-device-pod.ts",
"app-conversation.ts",
"app-helpers.ts",
"app-skills.ts",
"app-session-tabs.ts",
"app-trace.ts",
"auth.ts",
"code-agent-facts.ts",
"code-agent-status.ts",
"composer-policy.ts",
"live-status.ts",
"message-markdown.ts",
"runtime.ts",
"styles.css",
"web-types.d.ts"
]);
function reactShellOnlyIndex({ html }) {
return /<div id="root"><\/div>/u.test(html) &&
/src="\/src\/main\.tsx"/u.test(html) &&
!/workbench-shell|device-pod-sidebar|command-input|conversation-list|session-tabs/u.test(html);
}
function reactStrictTypeScriptEntry({ app, tsconfig, tscCheck }) {
return /createRoot/u.test(app) &&
/React\.StrictMode/u.test(app) &&
/"strict"\s*:\s*true/u.test(tsconfig) &&
/"noImplicitAny"\s*:\s*true/u.test(tsconfig) &&
/"noUncheckedIndexedAccess"\s*:\s*true/u.test(tsconfig) &&
/explicit any/u.test(tscCheck) &&
!/(^|[^A-Za-z0-9_])(?:as\s+any|:\s*any\b|Array\s*<\s*any\s*>|<\s*any\s*>)/u.test(app);
}
function reactModuleBoundaries() {
return requiredWebAssets
.filter((file) => file.startsWith("src/"))
.every((file) => fs.existsSync(path.join(webRoot, file)));
}
function legacyRuntimePathsRemoved() {
return legacyRuntimeFiles.every((file) => !fs.existsSync(path.join(webRoot, file)));
}
function reactCoreWorkbenchSelectors({ app }) {
return [
"data-app-shell",
"id=\"command-input\"",
"id=\"conversation-list\"",
"id=\"device-pod-sidebar\"",
"id=\"device-event-scroll\"",
"id=\"logout-button\"",
"data-view=\"gate\"",
"data-view=\"help\"",
"SkillsView",
"SettingsView"
].every((term) => app.includes(term));
}
function reactApiStateSeparation({ app }) {
return [
"services/api/client.ts",
"state/workbench.ts",
"state/conversation.ts",
"services/markdown/render.ts"
].every((file) => fs.existsSync(path.join(webRoot, "src", file))) &&
/fetchJson/u.test(app) &&
/useWorkbenchStore/u.test(app) &&
/renderMessageMarkdown/u.test(app) &&
/selectConversation/u.test(app);
}
function viteBuildAndDistContract({ packageJson, distContract, viteConfig }) {
return /"build"\s*:\s*"bun run scripts\/build\.ts"/u.test(packageJson) &&
/vite.*build/u.test(distContract) &&
/entryFileNames:\s*"app\.js"/u.test(viteConfig) &&
/assets\/index\.css/u.test(distContract) &&
gateRouteAliases.every((route) => distContract.includes(`${route.replace(/^\//u, "")}/index.html`)) &&
helpRouteAliases.every((route) => distContract.includes(`${route.replace(/^\//u, "")}/index.html`));
}
function reactLayoutContract({ styles }) {
return /html, body, #root \{[^}]*overflow:\s*hidden;/su.test(styles) &&
/\.workbench-shell \{[^}]*overflow:\s*hidden;/su.test(styles) &&
/@media \(max-width: 720px\)/u.test(styles) &&
/grid-template-columns:\s*56px\s+minmax\(0,\s*1fr\)/u.test(styles) &&
/\.device-event-scroll\s*\{[\s\S]*?overscroll-behavior:\s*contain;/u.test(styles);
}
function inspectReactHelpContract({ app, help }) {
return {
status: /data-view="help"/u.test(app) && /fetchText\("\/help\.md"/u.test(app) && help.trim().length > 0 ? "pass" : "blocked",
route: "/help",
source: "web/hwlab-cloud-web/help.md"
};
}
function readText(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
}
function readCloudWebAppSource() {
return cloudWebAppSourceFiles.map((file) => readText(`web/hwlab-cloud-web/${file}`)).join("\n");
return collectCloudWebSourceFiles(path.join(webRoot, "src"))
.map((file) => fs.readFileSync(file, "utf8"))
.join("\n");
}
function readCloudWebRuntime() {
const source = fs.readFileSync(path.join(webRoot, "src/logic/runtime.ts"), "utf8");
const match = source.match(/export const runtime\s*=\s*([\s\S]*?);\s*$/u);
if (!match) throw new Error("Unable to parse web/hwlab-cloud-web/src/logic/runtime.ts");
return Function(`return (${match[1]});`)();
function collectCloudWebSourceFiles(dir) {
const files = [];
for (const entry of fs.readdirSync(dir)) {
const full = path.join(dir, entry);
const stat = fs.statSync(full);
if (stat.isDirectory()) files.push(...collectCloudWebSourceFiles(full));
else if (/\.(ts|tsx|css)$/u.test(full)) files.push(full);
}
return files.sort();
}
function resolveBunCommand() {
@@ -1775,21 +1760,13 @@ function compareLiveAsset(assetPath, live, sourceText) {
}
async function buildCloudWebAppBundleText() {
const tmpDir = fs.mkdtempSync(path.join("/tmp", "hwlab-cloud-web-live-asset-"));
const entryPath = path.join(webRoot, ".app-entry.generated.ts");
try {
fs.writeFileSync(entryPath, `${readCloudWebAppSource()}\n`);
execFileSync(resolveBunCommand(), ["build", entryPath, "--target", "browser", "--format", "esm", "--outfile", path.join(tmpDir, "app.js")], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 30000
});
return fs.readFileSync(path.join(tmpDir, "app.js"), "utf8");
} finally {
fs.rmSync(entryPath, { force: true });
fs.rmSync(tmpDir, { recursive: true, force: true });
}
execFileSync(resolveBunCommand(), ["run", "build"], {
cwd: webRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 60000
});
return fs.readFileSync(path.join(webRoot, "dist/app.js"), "utf8");
}
function hashText(value) {
@@ -2563,6 +2540,7 @@ function findHelpMarkdownFiles() {
}
function findMarkdownRenderers() {
const renderService = readText("web/hwlab-cloud-web/src/services/markdown/render.ts");
const packages = ["package.json", "web/hwlab-cloud-web/package.json"]
.filter((file) => fs.existsSync(path.join(repoRoot, file)))
.flatMap((file) => {
@@ -2571,11 +2549,8 @@ function findMarkdownRenderers() {
});
const source = `${readCloudWebAppSource()}\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");
if (/export function renderMessageMarkdown/u.test(renderService) && /escapeHtml/u.test(renderService)) renderers.push("react-render-service");
return [...new Set(renderers)].sort();
}
@@ -3527,7 +3502,16 @@ async function inspectAuthFixtureViewport(browser, url, viewport) {
}
async function loginWithDefaultCredentials(page) {
const loginVisible = await page.locator("#login-shell").evaluate((element) => element.hidden === false).catch(() => false);
await page.waitForFunction(
() => document.querySelector("#command-input") !== null || document.querySelector("#login-username") !== null,
null,
{ timeout: 12000 }
).catch(() => {});
const loginVisible = await page.evaluate(() => {
const login = document.querySelector("#login-shell");
const username = document.querySelector("#login-username");
return Boolean(login && username && login.hidden === false);
});
if (!loginVisible) return;
await page.locator("#login-username").fill(defaultAuthCredentials.username);
await page.locator("#login-password").fill(defaultAuthCredentials.password);
@@ -6061,7 +6045,7 @@ async function inspectWorkbenchGateLayout(browser, url, viewport, options = {})
}
}
async function inspectMinimalWorkbenchLayout(browser, url, viewport) {
async function inspectMinimalWorkbenchLayout(browser, url, viewport, options = {}) {
const page = await browser.newPage({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: 1,
@@ -6093,12 +6077,14 @@ async function inspectMinimalWorkbenchLayout(browser, url, viewport) {
observations.conversationVisible &&
observations.rightSidebarVisible &&
observations.rightSidebarToggleVisible;
const screenshots = [await captureElementScreenshot(page, "[data-app-shell]", options.artifactRoot, viewport, `minimal-${viewport.id}-shell`)].filter(Boolean);
return {
id: `layout-minimal-${viewport.id}`,
status: pass ? "pass" : "blocked",
viewport: { id: viewport.id, width: viewport.width, height: viewport.height },
summary: `${viewport.id} workbench opens after auth and exposes core conversation plus Device Pod controls.`,
observations,
artifacts: { screenshots },
failures: pass ? [] : [
{
selector: "[data-app-shell], #command-input, #conversation-list, #device-pod-sidebar, #right-sidebar-toggle",
+1 -1
View File
@@ -29,7 +29,7 @@ function bunExecutable() {
function classifyRuntimeDurableReadiness(payload = {}, options = {}) {
const script = [
"import { classifyRuntimeDurableReadiness } from './web/hwlab-cloud-web/live-status.ts';",
"import { classifyRuntimeDurableReadiness } from './tools/src/runtime-durable-readiness.ts';",
"const input = JSON.parse(await Bun.stdin.text());",
"const output = classifyRuntimeDurableReadiness(input.payload, input.options);",
"process.stdout.write(JSON.stringify(output));"
+20 -1
View File
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { readdirSync, statSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -228,7 +229,7 @@ export async function runM3IoControlSourceChecks({ repoRoot: root = repoRoot } =
readRepoText(root, "internal/cloud/server.ts"),
readRepoText(root, "internal/cloud/json-rpc.ts"),
readRepoText(root, "internal/cloud/m3-io-control.ts"),
Promise.all(["web/hwlab-cloud-web/app.ts","web/hwlab-cloud-web/app-device-pod.ts","web/hwlab-cloud-web/app-conversation.ts","web/hwlab-cloud-web/app-trace.ts","web/hwlab-cloud-web/app-helpers.ts"].map((file) => readRepoText(root, file))).then((parts) => parts.join("\n")),
readCloudWebReactSource(root),
readRepoText(root, "web/hwlab-cloud-web/index.html"),
readRepoText(root, "scripts/artifact-publish.mjs"),
readRepoText(root, "internal/dev-entrypoint/cloud-web-routes.mjs")
@@ -325,6 +326,24 @@ export async function runM3IoControlSourceChecks({ repoRoot: root = repoRoot } =
];
}
async function readCloudWebReactSource(root) {
const sourceRoot = path.join(root, "web/hwlab-cloud-web/src");
const files = listCloudWebSourceFiles(sourceRoot);
const parts = await Promise.all(files.map((file) => readFile(file, "utf8")));
return parts.join("\n");
}
function listCloudWebSourceFiles(dir) {
const files = [];
for (const entry of readdirSync(dir)) {
const full = path.join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) files.push(...listCloudWebSourceFiles(full));
else if (/\.(ts|tsx|css)$/u.test(full)) files.push(full);
}
return files.sort();
}
export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "", artifactPublisherSource = "", cloudWebRouteSource = "" }) {
const source = `${appSource}\n${htmlSource}`;
const fetchTargets = literalFetchTargets(appSource);