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/stores/index.ts", "src/stores/app.ts", "src/stores/auth.ts", "src/stores/workbench.ts", "src/composables/useTraceSubscription.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/DataTable.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; devDependencies?: Record }; const appSource = readCloudWebAppSource(rootDir); const workbenchStoreSource = readWeb("src/stores/workbench.ts"); const traceTimelineSource = readWeb("src/components/agent/TraceTimeline.vue"); const messageTraceDebugSource = readWeb("src/components/workbench/MessageTraceDebugPanel.vue"); assert.match(html, /
<\/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", "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, "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, "connectWorkbenchEvents", "Workbench must use the unified SSE realtime entry"); assertIncludes(workbenchStoreSource, "scheduleRealtimeGapHydration", "Workbench realtime disconnect/reconnect must trigger REST gap fill"); assertIncludes(workbenchStoreSource, "hydrateRealtimeGap", "Workbench realtime recovery must use REST snapshot/page gap fill"); assert.doesNotMatch(workbenchStoreSource, /subscribeToTrace|TRACE_POLL_INTERVAL_MS/u, "Workbench store must not reintroduce active trace polling"); assertIncludes(appSource, "/v1/workbench/events", "Workbench realtime client must use the RESTful same-origin events endpoint"); assertIncludes(appSource, "/v1/agent/traces/", "trace hydration must use RESTful Cloud Web same-origin trace API"); 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, "row.preview", "Trace tool command preview must use request-side row.preview instead of 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); }