0d06a4c5fe
- 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>
284 lines
15 KiB
TypeScript
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);
|
|
}
|
|
|