// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-010401 Web工作台 draft-2026-06-18-r1.
// Responsibility: Cloud Web source-shape checks for Workbench projection and UI integration invariants.

import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { assertCloudWebDistFresh, buildCloudWebDist, readCloudWebAppSource } from "./dist-contract.ts";

const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");

const requiredFiles = Object.freeze([
  "index.html",
  "src/main.ts",
  "src/App.vue",
  "src/router/index.ts",
  "src/router/guards.ts",
  "src/router/title.ts",
  "src/api/client.ts",
  "src/api/auth.ts",
  "src/api/workbench.ts",
  "src/api/workbench-events.ts",
  "src/api/agent.ts",
  "src/config/workbench-runtime-policy.ts",
  "src/utils/workbench-key.ts",
  "src/utils/scoped-cache.ts",
  "src/utils/scheduler/async-queue.ts",
  "src/utils/scheduler/coalesced-event-queue.ts",
  "src/utils/scheduler/keyed-singleflight.ts",
  "src/utils/safe-storage.ts",
  "src/utils/workbench-health.ts",
  "src/utils/workbench-error-runtime.ts",
  "src/utils/workbench-realtime-runtime.ts",
  "src/utils/workbench-storage-runtime.ts",
  "src/utils/workbench-stream-transport.ts",
  "src/stores/index.ts",
  "src/stores/app.ts",
  "src/stores/auth.ts",
  "src/stores/workbench.ts",
  "src/stores/workbench-colada-keys.ts",
  "src/stores/workbench-colada-queries.ts",
  "src/stores/workbench-colada-mutations.ts",
  "src/stores/workbench-colada-reducer.ts",
  "src/stores/workbench-event-reducer.ts",
  "src/stores/workbench-message-projection-runtime.ts",
  "src/stores/workbench-realtime-plan.ts",
  "src/stores/workbench-timeline-model.ts",
  "src/stores/workbench-session-cache.ts",
  "src/composables/useWorkbenchScrollRuntime.ts",
  "src/composables/workbench-trace-snapshot.ts",
  "src/composables/useAutoRefresh.ts",
  "src/composables/useClipboard.ts",
  "src/composables/useForm.ts",
  "src/composables/useTableLoader.ts",
  "src/components/common/BaseDialog.vue",
  "src/components/common/ConfirmDialog.vue",
  "src/components/common/Pagination.vue",
  "src/components/layout/AppShell.vue",
  "src/components/layout/TablePageLayout.vue",
  "src/components/common/EmptyState.vue",
  "src/components/workbench/MessageTraceDebugPanel.vue",
  "src/views/workbench/CodeWorkbenchView.vue",
  "src/views/admin/AccessView.vue",
  "src/views/admin/ProviderProfilesView.vue",
  "src/views/admin/HwpodGroupsView.vue",
  "src/views/user/DashboardView.vue",
  "src/views/user/ApiKeysView.vue",
  "src/styles/workbench.css",
  "src/style.css",
  "tailwind.config.js",
  "postcss.config.js",
  "favicon.svg",
  "favicon.ico",
  "help.md"
]);

for (const file of requiredFiles) {
  assert.ok(fs.existsSync(path.resolve(rootDir, file)), `missing Vue web asset: ${file}`);
}

const html = readWeb("index.html");
const pkg = JSON.parse(readWeb("package.json")) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
const appSource = readCloudWebAppSource(rootDir);
const workbenchStoreSource = readWeb("src/stores/workbench.ts");
const workbenchColadaSource = `${readWeb("src/stores/workbench-colada-keys.ts")}\n${readWeb("src/stores/workbench-colada-queries.ts")}\n${readWeb("src/stores/workbench-colada-mutations.ts")}\n${readWeb("src/stores/workbench-colada-reducer.ts")}`;
const workbenchRuntimePolicySource = readWeb("src/config/workbench-runtime-policy.ts");
const workbenchRealtimeRuntimeSource = `${readWeb("src/utils/workbench-realtime-runtime.ts")}\n${readWeb("src/utils/workbench-stream-transport.ts")}`;
const workbenchPerformanceSource = readWeb("src/utils/workbench-performance.ts");
const workbenchEventReducerSource = readWeb("src/stores/workbench-event-reducer.ts");
const workbenchMessageProjectionRuntimeSource = readWeb("src/stores/workbench-message-projection-runtime.ts");
const workbenchRealtimePlanSource = readWeb("src/stores/workbench-realtime-plan.ts");
const workbenchTimelineRuntimeSource = readWeb("src/stores/workbench-timeline-model.ts");
const workbenchScrollRuntimeSource = readWeb("src/composables/useWorkbenchScrollRuntime.ts");
const workbenchErrorRuntimeSource = readWeb("src/utils/workbench-error-runtime.ts");
const workbenchHealthRuntimeSource = readWeb("src/utils/workbench-health.ts");
const traceTimelineSource = readWeb("src/components/agent/TraceTimeline.vue");
const traceRendererSource = fs.readFileSync(path.resolve(rootDir, "..", "..", "tools/src/hwlab-cli/trace-renderer.ts"), "utf8");
const messageTraceDebugSource = readWeb("src/components/workbench/MessageTraceDebugPanel.vue");
const conversationPanelSource = readWeb("src/components/workbench/ConversationPanel.vue");
const serverWorkbenchRealtimeHttpSource = fs.readFileSync(path.resolve(rootDir, "..", "..", "internal/cloud/server-workbench-realtime-http.ts"), "utf8");

assert.match(html, /<div id="app"><\/div>/u, "index.html must expose Vue mount root");
assert.match(html, /src="\/src\/main\.ts"/u, "index.html must load Vue TS entry");
assert.doesNotMatch(html, /HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP/u, "workspace bootstrap must not start during HTML parse");
assert.doesNotMatch(html, /HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP/u, "workspace snapshot must not be injected before Vue boot");
assert.doesNotMatch(html, /\/v1\/workbench\/workspace/u, "HTML parse must not request legacy workspace authority");
assert.doesNotMatch(html, /\/auth\/workspace-bootstrap/u, "HTML parse bootstrap must not use stale auth workspace bootstrap route");
assert.doesNotMatch(html, /src="\/src\/main\.tsx"|id="root"/u, "React entry must be gone");

for (const dep of ["vue", "pinia", "@pinia/colada", "vue-router", "axios", "@vueuse/core", "vue-i18n"]) {
  assert.ok(pkg.dependencies?.[dep], `missing Vue/Sub2API dependency ${dep}`);
}
for (const dep of ["@vitejs/plugin-vue", "tailwindcss", "postcss", "autoprefixer", "vue-tsc", "vitest"]) {
  assert.ok(pkg.devDependencies?.[dep], `missing Vue/Tailwind devDependency ${dep}`);
}
for (const removed of ["react", "react-dom", "react-markdown", "remark-gfm"]) {
  assert.equal(pkg.dependencies?.[removed], undefined, `React dependency must be removed: ${removed}`);
}

for (const directory of ["api", "stores", "composables", "components/common", "components/layout", "components/workbench", "components/agent", "components/hwpod", "components/access", "views/admin", "views/user", "views/workbench", "router", "i18n", "styles", "types", "utils"]) {
  assert.ok(fs.existsSync(path.join(rootDir, "src", directory)), `missing Sub2API-style module boundary src/${directory}`);
}

assertIncludes(appSource, "createApp", "Vue app must use createApp");
assertIncludes(appSource, "createPinia", "Pinia must be installed");
assertIncludes(appSource, "PiniaColada", "Pinia Colada must be installed after Pinia");
assertIncludes(appSource, "createRouter", "Vue Router must be installed");
assertIncludes(appSource, "defineStore", "Pinia stores must exist");
assertIncludes(appSource, "installChunkRecovery", "router must recover from stale dynamic route chunks");
assertIncludes(appSource, "resolveDocumentTitle", "route meta title handling must remain centralized");
assertIncludes(appSource, "showSuccess", "app store must expose Sub2API-style success toast helper");
assertIncludes(appSource, "showError", "app store must expose Sub2API-style error toast helper");
assertIncludes(appSource, "useTableLoader", "table loader composable must remain available for route tables");
assertIncludes(appSource, "useForm", "form composable must remain available for CRUD dialogs");
assertIncludes(appSource, "hwlab_session", "auth comments/code must preserve Web session cookie boundary");
assertIncludes(appSource, "activityRef", "Code Agent inactivity-timeout activityRef must be preserved");
assertIncludes(workbenchStoreSource, "createWorkbenchStreamTransportRuntime", "Workbench store must enter SSE through the realtime runtime boundary");
assertIncludes(workbenchStoreSource, "workbenchRuntimePolicy", "Workbench store must consume runtime tunables through workbenchRuntimePolicy");
assertIncludes(workbenchRuntimePolicySource, "runtimePolicyConfig", "Workbench runtime policy must read the injected YAML-backed web config");
assert.doesNotMatch(workbenchStoreSource, /const\s+(?:DEFAULT_CODE_AGENT_TIMEOUT_MS|TRACE_HYDRATION_PAGE_LIMIT|SESSION_LIST_PAGE_LIMIT|WORKBENCH_[A-Z0-9_]+)\s*=\s*[0-9_]+/u, "Workbench store must not own hardcoded runtime policy numbers");
assertIncludes(workbenchRealtimeRuntimeSource, "connectWorkbenchEvents", "Workbench realtime runtime must own the unified SSE EventSource entry");
assertIncludes(workbenchRealtimeRuntimeSource, "WorkbenchStreamTransportRecovery", "SSE transport must own recovery actions, not just wrap EventSource");
assertIncludes(workbenchRealtimeRuntimeSource, "cursorByKey", "SSE transport runtime must own cursor replay state");
assertIncludes(workbenchColadaSource, "workbenchColadaKeys", "Workbench Colada modules must own query/mutation key hierarchy");
assertIncludes(workbenchColadaSource, "useQueryCache", "Workbench Colada modules must own query cache access");
assertIncludes(workbenchColadaSource, "useQuery", "Workbench Colada reducer must keep server-state in Colada cache");
assertIncludes(workbenchColadaSource, "useMutation", "Workbench mutations must execute through Pinia Colada");
assertIncludes(workbenchStoreSource, "useWorkbenchColadaQueries", "Workbench store must read server-state through Colada query facade");
assertIncludes(workbenchStoreSource, "useWorkbenchColadaMutations", "Workbench store must run admission/cancel through Colada mutations");
assertIncludes(workbenchStoreSource, "useWorkbenchColadaReducer", "Workbench store must use Colada cache as projection reducer authority");
assert.doesNotMatch(workbenchStoreSource, /createWorkbench(?:ReadHydration|ReadDetail|ScheduledTask|TraceHydrationQueue|TraceDetailReadQueue)Runtime|runWorkbench(?:ReadHydration|ReadDetail)/u, "Workbench store must not use legacy Workbench request runtimes");
assert.doesNotMatch(workbenchStoreSource, /api\.workbench\.(?:sessions|sessionMessages|turn|traceEvents)/u, "Workbench store must not call Workbench read APIs outside Colada queries");
assertIncludes(workbenchEventReducerSource, "reduceWorkbenchRealtimeEvent", "Realtime event reducer must own SSE event classification");
assertIncludes(workbenchEventReducerSource, "workbench-event-reducer", "Realtime event reducer must emit module diagnostics for monitor root cause");
assertIncludes(workbenchStoreSource, "reduceWorkbenchRealtimeEvent", "Workbench store must consume migrated realtime reducer actions");
assertIncludes(workbenchRealtimePlanSource, "planWorkbenchRealtimeApply", "Realtime planner must own store apply-step planning");
assertIncludes(workbenchRealtimePlanSource, "planWorkbenchRealtimeRecovery", "Realtime planner must own recovery action planning");
assertIncludes(workbenchStoreSource, "planWorkbenchRealtimeApply", "Workbench store must execute realtime apply plans instead of branching on reducer actions");
assertIncludes(workbenchStoreSource, "planWorkbenchRealtimeRecovery", "Workbench store must execute realtime recovery plans instead of branching on recovery actions");
assert.doesNotMatch(workbenchStoreSource, /function applyWorkbenchRealtimeAction[\s\S]{0,500}switch \(action\.type\)/u, "Workbench store must not switch directly on WorkbenchRealtimeAction");
assert.doesNotMatch(workbenchStoreSource, /function applyRealtimeEvent[\s\S]{0,900}event\.type ===/u, "Workbench store applyRealtimeEvent must not branch directly on raw SSE event.type");
assertIncludes(workbenchTimelineRuntimeSource, "TurnGap", "Timeline runtime must preserve OpenCode TurnGap row parity");
assertIncludes(workbenchTimelineRuntimeSource, "AssistantPart", "Timeline runtime must preserve OpenCode AssistantPart row parity");
assertIncludes(workbenchTimelineRuntimeSource, "unwrapErrorMessage", "Timeline runtime must preserve OpenCode readable error row behavior");
assertIncludes(workbenchTimelineRuntimeSource, "WORKBENCH_TIMELINE_OPENCODE_PARITY", "Timeline runtime must carry an explicit OpenCode row parity table");
assertIncludes(workbenchTimelineRuntimeSource, "not-applicable", "Timeline parity table must document HWLAB-not-applicable OpenCode rows");
assertIncludes(workbenchTimelineRuntimeSource, "Permission", "Timeline parity table must explicitly account for OpenCode permission rows");
assertIncludes(workbenchScrollRuntimeSource, "createWorkbenchScrollPersistenceRuntime", "Scroll runtime must preserve OpenCode session/tab scroll persistence");
assertIncludes(conversationPanelSource, "sessionKey: scrollSessionKey", "ConversationPanel must use session-scoped scroll persistence in production");
assertIncludes(workbenchErrorRuntimeSource, "formatServerError", "Error runtime must preserve OpenCode readable server error formatting");
assertIncludes(workbenchErrorRuntimeSource, "projectionDiagnosticFromFailure", "Error runtime must own projection diagnostic envelopes");
assertIncludes(workbenchErrorRuntimeSource, "messageDiagnosticView", "Error runtime must own Workbench message diagnostic view models");
assertIncludes(conversationPanelSource, "messageDiagnosticView", "ConversationPanel must consume message diagnostics from ErrorRuntime");
assert.doesNotMatch(conversationPanelSource, /function\s+(?:messageApiError|messageErrorDiagnostic|projectionDiagnosticText|normalizeErrorDiagnostic)\b/u, "ConversationPanel must not rebuild Workbench diagnostics locally");
assertIncludes(workbenchMessageProjectionRuntimeSource, "mergeTerminalResultTrace", "Message projection runtime must own terminal result trace merge");
assertIncludes(workbenchMessageProjectionRuntimeSource, "messageTimingPatchForMerge", "Message projection runtime must own timing patch merge");
assertIncludes(workbenchMessageProjectionRuntimeSource, "messageStatusPatchForTerminalMerge", "Message projection runtime must own terminal status patch merge");
assertIncludes(workbenchMessageProjectionRuntimeSource, "projectionFromResult", "Message projection runtime must own result projection extraction");
assertIncludes(workbenchMessageProjectionRuntimeSource, "normalizeAgentError", "Message projection runtime must own agent error normalization");
assertIncludes(workbenchStoreSource, "workbench-message-projection-runtime", "Workbench store must consume message projection runtime instead of owning pure merge helpers");
assert.doesNotMatch(workbenchStoreSource, /function (?:mergeTerminalResultTrace|messageTimingPatchForMerge|messageStatusPatchForTerminalMerge|projectionFromResult|normalizeAgentError|clearRunnerTraceTransientDiagnostics|shouldClearCompletedTurnDiagnostics|nonBlockingProjection)\b/u, "Workbench store must not re-own pure trace/message/projection helper algorithms");
assertIncludes(workbenchHealthRuntimeSource, "createScopedCache", "ScopedCache must be used by a production Workbench runtime, not only by tests/checks");
assert.doesNotMatch(workbenchStoreSource, /function (?:normalizeProjectionDiagnostic|projectionDiagnosticFromApiFailure|projectionDiagnosticFromFailure|normalizeProjectionBlocker|normalizeErrorDiagnostic|normalizeApiErrorRecord|agentErrorFromProjection)\b/u, "Workbench store must call ErrorRuntime directly instead of reintroducing diagnostic wrapper ownership");
assertIncludes(workbenchStoreSource, "cleanupDroppedWorkbenchSessionCaches", "Workbench store must run OpenCode-style dropped session cache cleanup after trim");
assertIncludes(workbenchStoreSource, "cleanupWorkbenchServerStateSessions", "Workbench store must clean dropped session trace/turn authority after trim");
assert.doesNotMatch(workbenchStoreSource, /case "session\.forget"[\s\S]{0,400}sessionStatusById: withoutKey\(state\.sessionStatusById/u, "Session forget must not leave trace/turn authority cleanup out of reducer helpers");
assert.doesNotMatch(workbenchStoreSource, /traceHydration(?:InFlight|Queued|Queue)|traceHydrationPumpActive|terminalRealtimeRefreshInFlight|realtimeSessionMessagesInFlight|realtimeErrorGapFillLastAtByKey|activeTraceRestGapFillTimers|realtimeOutboxSeqByKey|sessionListRefreshInFlight|sessionListRefreshTimers/u, "Workbench store must not reintroduce migrated in-flight/timer/cursor state");
assert.doesNotMatch(workbenchStoreSource, /scheduleRealtimeGapHydration|hydrateRealtimeGap/u, "Workbench realtime consumer must not repair projection through REST gap fill");
assert.doesNotMatch(workbenchStoreSource, /subscribeToTrace|TRACE_POLL_INTERVAL_MS/u, "Workbench store must not reintroduce active trace polling");
assertIncludes(workbenchColadaSource, "invalidateQueries", "Realtime recovery must resolve to Colada invalidation/refetch instead of a bespoke REST scheduler");
assertIncludes(workbenchColadaSource, "staleTime", "Workbench query min-interval budget must be expressed as Colada staleTime");
assertIncludes(workbenchPerformanceSource, "recordWorkbenchRuntimeDiagnostic", "Workbench performance probe must record runtime diagnostics for monitor root cause visibility");
assertIncludes(workbenchPerformanceSource, "clearResourceTimings", "Workbench performance probe must bound browser ResourceTiming retention after API enrichment");
assertIncludes(workbenchStoreSource, "recordWorkbenchRuntimeDiagnostic", "Workbench store must surface SSE recovery diagnostics to the performance probe");
assertIncludes(workbenchRealtimePlanSource, "recovery.actions.includes(\"events-reconnect\")", "Realtime recovery planner must reconnect the projection SSE explicitly");
assert.doesNotMatch(workbenchRealtimePlanSource, /schedule-session-list/u, "Realtime recovery planner must not restore legacy session-list repair actions");
assertIncludes(workbenchRealtimePlanSource, "afterOutboxSeq: finiteNumber(recovery.outboxSeq)", "Realtime recovery planner must resume projection SSE from the durable outbox cursor");
assert.doesNotMatch(workbenchRealtimePlanSource, /force:\s*true/u, "Realtime recovery planner must not turn transport recovery into force-refresh work");
assertIncludes(workbenchColadaSource, "const state = await queryCache.refresh(entry);", "Workbench reads must preserve Colada staleTime/min-interval governance");
assert.doesNotMatch(workbenchColadaSource, /queryCache\.fetch\(entry/u, "Workbench reads must not call queryCache.fetch(entry), which bypasses Colada freshness governance");
assert.doesNotMatch(workbenchStoreSource, /workbenchColadaQueries\.fetchSession(?:Detail|Messages)?\(/u, "HTTP session detail and messages must not participate in Workbench business projection");
assert.doesNotMatch(workbenchStoreSource, /refreshRealtimeSessionMessages[\s\S]{0,900}refreshSessionMessageProjectionPage\(id, \{ force: true \}\)/u, "Realtime session message recovery must not force-bypass the message projection refresh budget");
assert.doesNotMatch(workbenchStoreSource, /handleRealtimeStreamError[\s\S]{0,1200}refreshSessions\([^;]+force:\s*true/u, "Realtime stream errors must not force-refresh the full session list");
assert.doesNotMatch(workbenchStoreSource, /refreshActiveTraceFromRest[\s\S]{0,1200}refreshSessions\([^;]+force:\s*true/u, "Active trace sync replay must not force-refresh the full session list");
assert.doesNotMatch(workbenchStoreSource, /refreshTerminalTraceFromRest[\s\S]{0,1200}refreshSessions\([^;]+force:\s*true/u, "Terminal trace REST refresh must not force-refresh the full session list");
assert.doesNotMatch(workbenchStoreSource, /message\.runnerTrace(?:\?\.|\.)status/u, "Workbench message lifecycle must not be inferred from runnerTrace.status");
assert.doesNotMatch(conversationPanelSource, /message\.runnerTrace(?:\?\.|\.)status/u, "ConversationPanel must not override message completion from runnerTrace.status");
assert.doesNotMatch(serverWorkbenchRealtimeHttpSource, /return\s+turnSnapshot\(context\)|\.\.\.turnSnapshot\(context\)|blockedTraceEventProjection\(context\.projection/u, "Realtime turn snapshots must not fall back to legacy trace/result projection");
assertIncludes(serverWorkbenchRealtimeHttpSource, "blockedRealtimeTurnSnapshot", "Realtime read-model gaps must produce blocked diagnostic turn snapshots");
assertIncludes(serverWorkbenchRealtimeHttpSource, "workbench_facts_session_missing", "Realtime read-model gaps must expose a facts-missing blocker");
assertIncludes(appSource, "/v1/workbench/events", "Workbench realtime client must use the RESTful same-origin events endpoint");
assertIncludes(appSource, "/v1/workbench/traces/", "trace detail read must use Workbench read-model trace API");
assert.doesNotMatch(appSource, /\/v1\/agent\/(?:turns|traces|chat\/result)\//u, "Cloud Web must not call legacy Code Agent read-through turn/trace/result APIs");
assert.doesNotMatch(workbenchStoreSource, /api\.(?:agent|workbench)\.(?:getAgentTurn|getAgentTrace|turn|traceEvents|sessions|sessionMessages)/u, "Workbench store turn/trace/session reads must go through Colada queries");
assert.doesNotMatch(appSource, /\/v1\/agent\/chat\/trace\//u, "Cloud Web must not use the legacy action-style chat trace API");
assert.doesNotMatch(appSource, /HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP/u, "early workspace bootstrap helper must not remain in Cloud Web app source");
assert.doesNotMatch(appSource, /\/auth\/workspace-bootstrap/u, "stale auth workspace bootstrap route must not remain in Cloud Web app source");
assert.doesNotMatch(appSource, /trace-meta-details/u, "Trace details must not render a second message-detail exclamation button");
assert.doesNotMatch(traceTimelineSource, /trace-meta-panel|trace-summary-chip|trace-meta-actions|原始事件|traceEventLabel/u, "Expanded TraceTimeline body must not render debug identity or raw-event controls");
assertIncludes(appSource, "MessageTraceDebugPanel", "Message details dialog must mount the trace debug panel behind the top-right details button");
assertIncludes(messageTraceDebugSource, "trace-meta-panel", "Trace identity and raw-event controls must live inside message details");
assertIncludes(appSource, "scrollbar-width: none;", "Platform sidebar must scroll without a visible scrollbar");
assertIncludes(appSource, "border-collapse: collapse;", "Markdown tables must use GitHub-like bordered table styling");
assertIncludes(appSource, "grid-template-columns: minmax(0, 1fr);", "Trace rows must give the readable body the full content width");
assertIncludes(traceTimelineSource, "toolCommandPreview", "Trace tool summaries must expose command previews in the collapsed single-line row");
assertIncludes(traceTimelineSource, "trace-tool-command-preview", "Trace tool command preview must render inside the collapsed summary");
assertIncludes(traceTimelineSource, "traceToolSummary(row)", "Trace tool command preview must use the shared summary model");
assertIncludes(traceRendererSource, "if (row.preview)", "Shared trace tool summary must prefer request-side row.preview over response body text");
assertIncludes(appSource, "text-overflow: ellipsis;", "Trace tool command preview must keep long commands visually clipped on one line");
assertIncludes(appSource, "const primaryAction = computed", "Workbench composer must derive a single primary send/cancel/steer action");
assertIncludes(appSource, "primaryAction.value === \"cancel\"", "Composer primary button must switch to cancel while a turn is running and the draft is empty");
assertIncludes(appSource, "primaryAction.value === \"steer\"", "Composer primary button must switch to steer while a turn is running and the draft has text");
assertIncludes(appSource, ":data-action=\"primaryAction\"", "Composer primary button must expose the active turn/steer/cancel action");
assert.doesNotMatch(appSource, /取消运行中 Trace/u, "Workbench must not render a separate stale cancel-running-trace button");
assertIncludes(appSource, "session_required", "explicit session policy must be represented");
assertIncludes(appSource, "grid-auto-rows: max-content;", "Session list rows must keep content height instead of stretching a single tab");
assertIncludes(appSource, "CodeWorkbenchView", "Code Workbench must be a route view, not the app shell");
assert.doesNotMatch(appSource, /from ["']react["']|React\.StrictMode|createRoot|\.tsx/u, "React runtime references must not remain");

await buildCloudWebDist(rootDir);
console.log("hwlab-cloud-web check: dist bundle built by Vite/Vue");
await assertCloudWebDistFresh(rootDir);
console.log("hwlab-cloud-web check ok: Vue + Pinia + Router + Tailwind architecture is present");

function readWeb(relativePath: string): string {
  return fs.readFileSync(path.resolve(rootDir, relativePath), "utf8");
}

function assertIncludes(source: string, term: string, message: string): void {
  assert.ok(source.includes(term), message);
}
