|
|
|
@@ -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",
|
|
|
|
|