Files
pikasTech-HWLAB/web/hwlab-cloud-web/scripts/check.ts
T
Lyon 0d06a4c5fe fix(web): migrate Cloud Web from vanilla DOM to React + TypeScript + Vite (#756) (#764)
- Replace monolithic app.ts / app-conversation.ts / app-device-pod.ts /
  app-skills.ts / app-helpers.ts / app-session-tabs.ts / auth.ts and the
  364-line index.html with a Vite-built React + TypeScript SPA.
- New src/ module tree: main.tsx, App.tsx, components/{layout,auth,
  conversation,command-bar,device-pod,skills,settings,help},
  hooks/, services/api, services/auth, state, types, logic/ (pure
  helpers from the old tree), styles/.
- Pure-logic modules (composer-policy, code-agent-facts, code-agent-
  status, live-status, message-markdown, app-trace, app-session-tabs,
  runtime) move to src/logic/ with their tests preserved; tests still
  auto-discover.
- Build pipeline switches from Bun.build to Vite: vite.config.ts
  inlines everything into a single app.js + app-assets/ for the dist;
  scripts/dist-contract.ts now runs vite build and asserts the dist
  against a fresh build instead of comparing dist to source.
- scripts/check.ts now verifies the React entry, the mount-only
  index.html, Vite dist, the composer / session / device pod / skills
  / settings / help contracts, the React state shape, and the new
  test file locations. It also refuses to run if any legacy app-*.ts
  or auth.ts survives at the top level.
- New scripts/frontend-guard.ts enforces the 400-line migration target,
  refuses the legacy el map and document.getElementById markers,
  refuses to keep app-*.ts / auth.ts at the top level, and verifies
  the required module directory layout.
- Update scripts/src/dev-cloud-workbench-smoke-lib.mjs to read the new
  src/services/, src/logic/ paths and refresh cloudWebModuleSourceFiles.
- index.html is now a mount shell only (no login shell, no workbench
  shell, no device pod sidebar markup). React renders the full UI from
  src/main.tsx, satisfying the issue-756 migration goal of catching
  unclosed tags at the TypeScript/JSX compile step instead of relying
  on browser DOM healing.
- web:check (now: check + frontend-guard + tsc-check + bun test) is
  green: 51 pass / 0 fail, Vite build produces dist/app.js (379 kB)
  + dist/index.html + dist/app-assets/. Vite build is reproducible;
  dist freshness is verified against a fresh Vite build.
- Grandfathered pure-logic files (> 600 lines): src/logic/app-trace.ts
  (1367), src/logic/live-status.ts (1006), src/logic/code-agent-facts.ts
  (548), src/logic/code-agent-status.ts (572). Reason: each is a tested
  pure-logic classifier with a public contract consumed by both the
  CLI renderer and the Web renderer; splitting now would fork the
  public function shape across two PRs and the migration target is the
  React entry + module split, not a pure-logic refactor.

Refs #756. Closes #756 once merged + deployed to hwlab-v02.

Co-authored-by: Codex <codex@local>
2026-06-03 16:53:25 +08:00

284 lines
15 KiB
TypeScript

import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { assertCloudWebDistFresh, buildCloudWebDist } from "./dist-contract.ts";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const requiredSourceFiles = Object.freeze([
"src/main.tsx",
"src/App.tsx",
"src/components/auth/LoginView.tsx",
"src/components/layout/Workbench.tsx",
"src/components/layout/AppShell.tsx",
"src/components/layout/ActivityRail.tsx",
"src/components/layout/SessionSidebar.tsx",
"src/components/layout/CenterWorkspace.tsx",
"src/components/layout/WorkspaceView.tsx",
"src/components/layout/RightSidebar.tsx",
"src/components/conversation/ConversationView.tsx",
"src/components/conversation/MessageList.tsx",
"src/components/conversation/MessageItem.tsx",
"src/components/command-bar/CommandBar.tsx",
"src/components/device-pod/DevicePodPanel.tsx",
"src/components/device-pod/DevicePodSummary.tsx",
"src/components/device-pod/DevicePodInterfaces.tsx",
"src/components/device-pod/DeviceEventStream.tsx",
"src/components/device-pod/DeviceDetailDialog.tsx",
"src/components/skills/SkillsView.tsx",
"src/components/settings/SettingsView.tsx",
"src/components/help/HelpView.tsx",
"src/hooks/useAuth.ts",
"src/hooks/useWorkspace.ts",
"src/hooks/useDevicePod.ts",
"src/hooks/useSkills.ts",
"src/hooks/useTracePolling.ts",
"src/hooks/useCodeAgentSubmit.ts",
"src/services/api/client.ts",
"src/services/api/auth.ts",
"src/services/api/sessions.ts",
"src/services/api/conversations.ts",
"src/services/api/codeAgent.ts",
"src/services/api/devicePods.ts",
"src/services/api/skills.ts",
"src/services/api/workspace.ts",
"src/services/auth.ts",
"src/state/workbenchState.tsx",
"src/logic/composer-policy.ts",
"src/logic/code-agent-facts.ts",
"src/logic/code-agent-status.ts",
"src/logic/live-status.ts",
"src/logic/message-markdown.ts",
"src/logic/app-trace.ts",
"src/logic/app-session-tabs.ts",
"src/logic/runtime.ts",
"vite.config.ts",
"styles.css",
"favicon.svg",
"favicon.ico",
"help.md",
"third_party/marked/marked.esm.js",
"third_party/marked/LICENSE",
]);
for (const file of requiredSourceFiles) {
const filePath = path.resolve(rootDir, file);
if (!fs.existsSync(filePath)) throw new Error(`missing web asset: ${file}`);
}
console.log("hwlab-cloud-web check: react/typescript source tree present");
const html = readWeb("index.html");
const styles = readWeb("styles.css");
const traceSource = readWeb("src/logic/app-trace.ts");
const messageMarkdown = readWeb("src/logic/message-markdown.ts");
const sessionTabs = readWeb("src/logic/app-session-tabs.ts");
const composerPolicy = readWeb("src/logic/composer-policy.ts");
const liveStatus = readWeb("src/logic/live-status.ts");
const codeAgentFacts = readWeb("src/logic/code-agent-facts.ts");
const codeAgentStatus = readWeb("src/logic/code-agent-status.ts");
const appEntry = readWeb("src/main.tsx");
const appComponent = readWeb("src/App.tsx");
const workbench = readWeb("src/components/layout/Workbench.tsx");
const skillsView = readWeb("src/components/skills/SkillsView.tsx");
const settingsView = readWeb("src/components/settings/SettingsView.tsx");
const helpView = readWeb("src/components/help/HelpView.tsx");
const commandBar = readWeb("src/components/command-bar/CommandBar.tsx");
const devicePodPanel = readWeb("src/components/device-pod/DevicePodPanel.tsx");
const messageItem = readWeb("src/components/conversation/MessageItem.tsx");
const composerClient = readWeb("src/services/api/client.ts");
const authService = readWeb("src/services/auth.ts");
const authApi = readWeb("src/services/api/auth.ts");
const stateContext = readWeb("src/state/workbenchState.tsx");
const distContractScript = readWeb("scripts/dist-contract.ts");
assertReactHtmlShell(html);
assertReactEntry(appEntry);
assertReactAppComposition(appComponent);
assertReactComponentSplit(workbench, skillsView, settingsView, helpView);
assertReactComposerContract(composerPolicy, workbench, commandBar);
assertReactCodeAgentRoutes(authService, composerClient);
assertReactSkillsContract(skillsView, styles);
assertReactDevicePodContract(devicePodPanel, styles);
assertReactLiveStatusContract(liveStatus);
assertReactCodeAgentFacts(codeAgentFacts, codeAgentStatus);
assertReactMarkdownContract(messageItem, messageMarkdown);
assertReactSessionTabsContract(sessionTabs, workbench);
assertReactStateShape(stateContext);
assertReactTestCoverage(traceSource, messageMarkdown, sessionTabs, composerPolicy, liveStatus, codeAgentFacts, codeAgentStatus);
assertTraceHelpersBound(traceSource);
assertDevicePodEvidenceSummaryDoesNotCrash();
console.log("hwlab-cloud-web check: react/typescript contracts verified");
await buildCloudWebDist(rootDir);
console.log("hwlab-cloud-web check: vite dist bundle built");
await assertCloudWebDistFresh(rootDir);
console.log("hwlab-cloud-web check: dist freshness verified");
for (const oldAsset of ["code-agent-m3-evidence.mjs", "wiring-status.mjs", "workbench-hardware-panel.mjs", "app.ts", "app-conversation.ts", "app-device-pod.ts", "app-skills.ts", "app-helpers.ts", "auth.ts"]) {
if (fs.existsSync(path.resolve(rootDir, oldAsset))) {
throw new Error(`legacy web runtime asset still present: ${oldAsset}`);
}
}
console.log("hwlab-cloud-web check: legacy asset cleanup verified");
console.log("hwlab-cloud-web check ok: React + TypeScript SPA, Vite dist, module boundary contracts and tests are present");
function readWeb(relativePath) {
return fs.readFileSync(path.resolve(rootDir, relativePath), "utf8");
}
function assertReactHtmlShell(source) {
assert.match(source, /<div id="root"[^>]*data-cloud-web-root/u, "index.html must mount React into #root");
assert.doesNotMatch(source, /class="login-shell"/u, "index.html must not inline the legacy login shell");
assert.doesNotMatch(source, /class="workbench-shell"/u, "index.html must not inline the legacy workbench shell");
assert.doesNotMatch(source, /id="login-form"/u, "index.html must not inline the legacy login form");
assert.doesNotMatch(source, /id="command-form"/u, "index.html must not inline the legacy command form");
assert.doesNotMatch(source, /id="device-pod-sidebar"/u, "index.html must not inline the legacy device pod sidebar");
assert.match(source, /<script type="module" src="\/src\/main\.tsx">/u, "index.html must declare /src/main.tsx as the Vite entry (Vite will rewrite this to the built /app.js)");
assert.match(source, /<link rel="stylesheet" href="\/styles\.css"\s*\/?>/u, "index.html must keep the repo-owned styles.css link");
}
function assertReactEntry(source) {
assert.match(source, /createRoot/u, "main.tsx must mount React via createRoot");
assert.match(source, /WorkbenchStateProvider/u, "main.tsx must wrap the App in WorkbenchStateProvider");
assert.match(source, /import \{ App \}/u, "main.tsx must import the App component");
}
function assertReactAppComposition(source) {
assert.match(source, /useAuthGate/u, "App.tsx must call useAuthGate to manage authentication");
assert.match(source, /LoginView/u, "App.tsx must render LoginView when unauthenticated");
assert.match(source, /Workbench/u, "App.tsx must render Workbench when authenticated");
assert.doesNotMatch(source, /document\.getElementById/u, "App.tsx must not depend on the legacy el map / document.getElementById pattern");
}
function assertReactComponentSplit(workbench, skills, settings, help) {
assert.match(workbench, /ActivityRail/u, "Workbench must compose ActivityRail");
assert.match(workbench, /SessionSidebar/u, "Workbench must compose SessionSidebar");
assert.match(workbench, /CenterWorkspace/u, "Workbench must compose CenterWorkspace");
assert.match(workbench, /RightSidebar/u, "Workbench must compose RightSidebar");
assert.match(skills, /uploadSkillFiles/u, "SkillsView must call the skills upload API");
assert.match(skills, /skill-prompt-assembly/u, "SkillsView must render the prompt assembly panel");
assert.match(settings, /settings-logout/u, "SettingsView must render the logout control");
assert.match(help, /help\.md/u, "HelpView must load /help.md");
}
function assertReactComposerContract(composer, workbench, commandBar) {
assert.match(composer, /computeCodeAgentComposerState/u, "composer-policy must export computeCodeAgentComposerState");
assert.match(workbench, /CenterWorkspace/u, "Workbench must delegate the center column to CenterWorkspace");
assert.match(commandBar, /steer 模式|turn 模式/u, "CommandBar must label the composer mode");
assert.doesNotMatch(commandBar, /document\.getElementById/u, "CommandBar must not depend on the legacy el map");
}
function assertReactCodeAgentRoutes(authService, client) {
assert.match(client, /\/v1/u, "API client must prefix all routes with /v1");
assert.match(authApi, /\/auth\/session/u, "auth API client must hit /auth/session");
assert.match(authApi, /\/auth\/login/u, "auth API client must hit /auth/login");
assert.match(authApi, /\/auth\/logout/u, "auth API client must hit /auth/logout");
assert.match(authService, /probeAuthSession|signInWithCredentials|performSignOut/u, "auth service must export session probes and sign-in helpers");
}
function assertReactSkillsContract(skills, styles) {
assert.match(skills, /id="skill-upload-input"[^>]*webkitDirectory/u, "SkillsView must support directory upload");
assertIncludes(styles, ".skill-prompt-assembly", "styles.css must style the prompt assembly panel");
assertIncludes(styles, ".skill-prompt-row", "styles.css must style the prompt assembly refs");
assertIncludes(skills, "ResourceBundleRef.promptRefs", "SkillsView must surface ResourceBundleRef.promptRefs");
}
function assertReactDevicePodContract(panel, styles) {
assert.match(panel, /DevicePodSummary|DevicePodInterfaces|DeviceEventStream/u, "DevicePodPanel must compose summary, interfaces, and event stream");
assertIncludes(styles, ".device-pod-status", "styles.css must keep .device-pod-status");
assertIncludes(styles, ".device-pod-workspace", "styles.css must keep .device-pod-workspace");
assertIncludes(styles, ".device-event-panel", "styles.css must keep .device-event-panel");
assertIncludes(styles, ".device-detail-dialog", "styles.css must keep .device-detail-dialog");
assertIncludes(styles, ".summary-tile", "styles.css must keep .summary-tile");
}
function assertReactLiveStatusContract(source) {
assert.match(source, /classifyWorkbenchLiveStatus/u, "live-status must export classifyWorkbenchLiveStatus");
assert.match(source, /classifyDevicePodProbe/u, "live-status must classify Device Pod probe");
assert.doesNotMatch(source, /classifyM3|serviceForM3Layer|\/v1\/m3/u, "live-status must not keep legacy M3 probes");
}
function assertReactCodeAgentFacts(facts, status) {
assert.match(facts, /codeAgentFactsFromMessage/u, "code-agent-facts must export codeAgentFactsFromMessage");
assert.match(status, /classifyCodeAgentStatusSummary/u, "code-agent-status must export classifyCodeAgentStatusSummary");
}
function assertReactMarkdownContract(messageItem, markdown) {
assert.match(messageItem, /renderMessageMarkdown|hardenRenderedMarkdown/u, "MessageItem must use message-markdown helpers");
assert.match(markdown, /renderMessageMarkdown/u, "message-markdown must export renderMessageMarkdown");
}
function assertReactSessionTabsContract(tabs, workbench) {
assert.match(tabs, /sessionTabsFromConversations/u, "session-tabs must export sessionTabsFromConversations");
assert.match(workbench, /sessionTabsFromConversations|SessionSidebar/u, "Workbench must use session tabs in the sidebar");
}
function assertReactStateShape(state) {
assert.match(state, /WorkbenchStateProvider/u, "workbenchState must export WorkbenchStateProvider");
assert.match(state, /useWorkbenchState/u, "workbenchState must export useWorkbenchState");
assert.match(state, /reducer/u, "workbenchState must use a reducer for predictable state transitions");
assert.match(state, /dispatch\(\{ type: "auth\/set"/u, "workbenchState must handle auth/set action");
assert.match(state, /dispatch\(\{ type: "route\/set"/u, "workbenchState must handle route/set action");
}
function assertReactTestCoverage(trace, markdown, tabs, composer, live, facts, status) {
for (const contract of [
{ file: "src/logic/app-trace.test.ts", name: "trace display rows" },
{ file: "src/logic/message-markdown.test.ts", name: "message markdown" },
{ file: "src/logic/session-tabs.test.ts", name: "session tabs" },
{ file: "src/logic/composer-policy.test.ts", name: "composer policy" },
{ file: "src/logic/live-status.test.ts", name: "live status" },
{ file: "src/logic/code-agent-facts.test.ts", name: "code agent facts" },
{ file: "src/logic/code-agent-status.test.ts", name: "code agent status" },
]) {
if (!fs.existsSync(path.resolve(rootDir, contract.file))) {
throw new Error(`missing test coverage: ${contract.name} (${contract.file})`);
}
}
}
function assertIncludes(source, term, message) {
assert.ok(source.includes(term), message);
}
function assertTraceHelpersBound(source) {
const traceHelperCalls = new Set();
for (const match of source.matchAll(/\b((?:is[A-Z][A-Za-z0-9_$]*TraceEvent)|(?:trace[A-Z][A-Za-z0-9_$]*)|(?:cleanTrace[A-Za-z0-9_$]*)|(?:compactTrace[A-Za-z0-9_$]*)|(?:rawTrace[A-Za-z0-9_$]*))\s*\(/gu)) {
traceHelperCalls.add(match[1]);
}
const missing = [...traceHelperCalls].filter((name) => !new RegExp(`\\bfunction\\s+${escapeRegExp(name)}\\s*\\(`, "u").test(source)).filter((name) => !new RegExp(`\\bexport\\s+function\\s+${escapeRegExp(name)}\\s*\\(`, "u").test(source));
assert.deepEqual(missing, [], `app-trace.ts has trace helper calls without local function definitions: ${missing.join(", ")}`);
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
async function assertDevicePodEvidenceSummaryDoesNotCrash() {
const { classifyWorkbenchLiveStatus } = await import(pathToFileURL(path.resolve(rootDir, "src/logic/live-status.ts")).href);
const status = classifyWorkbenchLiveStatus({
devicePodStatus: {
ok: true,
data: {
status: "ready",
serviceId: "hwlab-device-pod",
summary: {
devicePodId: "device-pod-71-freq",
targetId: "71-FREQ",
profileHash: "abcdef0123456789fedcba9876543210",
},
},
},
});
const devicePodProbe = status.probes.find((probe) => probe.serviceId === "hwlab-device-pod");
assert.ok(devicePodProbe, JSON.stringify(status.probes));
assert.equal(devicePodProbe.kind, "pass");
assert.equal(devicePodProbe.reasonCode, "device_pod_ready");
assert.match(devicePodProbe.evidenceSummary, /profile=abcdef0123456789/u);
assert.doesNotMatch(devicePodProbe.evidenceSummary, /fedcba9876543210/u);
}