112 lines
6.9 KiB
TypeScript
112 lines
6.9 KiB
TypeScript
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 repoRoot = path.resolve(rootDir, "../..");
|
|
|
|
const requiredFiles = Object.freeze([
|
|
"index.html",
|
|
"src/main.tsx",
|
|
"src/App.tsx",
|
|
"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/state/workbench.ts",
|
|
"src/types/domain.ts",
|
|
"src/styles/workbench.css",
|
|
"favicon.svg",
|
|
"favicon.ico",
|
|
"help.md"
|
|
]);
|
|
|
|
for (const file of requiredFiles) {
|
|
if (!fs.existsSync(path.resolve(rootDir, file))) throw new Error(`missing React web asset: ${file}`);
|
|
}
|
|
console.log("hwlab-cloud-web check: React module assets present");
|
|
|
|
const html = readWeb("index.html");
|
|
const appTsx = readWeb("src/App.tsx");
|
|
const apiClientSource = readWeb("src/services/api/client.ts");
|
|
const appSource = readCloudWebAppSource(rootDir);
|
|
const css = readWeb("src/styles/workbench.css");
|
|
|
|
assert.match(html, /<div id="root"><\/div>/u, "index.html must only expose React mount root");
|
|
assert.match(html, /src="\/src\/main\.tsx"/u, "index.html must load React TSX entry");
|
|
assert.doesNotMatch(html, /workbench-shell|device-pod-sidebar|command-input|conversation-list|session-tabs/u, "index.html must not contain business DOM shell");
|
|
for (const removed of ["app.ts", "app-device-pod.ts", "app-conversation.ts", "app-helpers.ts", "app-skills.ts", "styles.css", "web-types.d.ts"]) {
|
|
assert.equal(fs.existsSync(path.join(rootDir, removed)), false, `${removed} must not remain as a legacy runtime path`);
|
|
}
|
|
assertIncludes(appSource, "createRoot", "React root must be used");
|
|
assertIncludes(appSource, "React.StrictMode", "React StrictMode must wrap the app");
|
|
assertIncludes(appSource, "useWorkbenchStore", "state hook must own workbench state");
|
|
assertIncludes(appSource, "fetchJson", "API client must own fetch calls");
|
|
assertIncludes(appSource, "DevicePodSidebar", "Device Pod sidebar component must exist");
|
|
assertIncludes(appSource, "CommandBar", "command bar component must exist");
|
|
assertIncludes(appSource, "SkillsView", "skills component must exist");
|
|
assertIncludes(appSource, "SettingsView", "settings component must exist");
|
|
assertIncludes(appTsx, "useWorkbenchStore(auth.authState === \"authenticated\")", "Workbench data requests must wait until auth is authenticated");
|
|
assertIncludes(apiClientSource, "adapter: (): Promise<ApiResult<LiveProbePayload>> => fetchJson(\"/v1/rpc/system.health\", { method: \"POST\"", "adapter health must use POST /v1/rpc/system.health");
|
|
assertIncludes(appSource, "selectVisibleDevicePodId", "Device Pod detail probes must be selected from the authenticated visible list");
|
|
assertIncludes(appSource, "api.devicePods()", "Device Pod list must be fetched before detail probes");
|
|
assert.doesNotMatch(appSource, /api\.devicePods\(\),\s*api\.devicePodStatus/u, "Device Pod status must not be requested in the initial parallel live probe");
|
|
assertIncludes(readRepo("internal/dev-entrypoint/cloud-web-routes.mjs"), "normalizedPath.startsWith(\"/v1/rpc/\")", "Cloud Web must proxy REST RPC bridge routes to cloud-api");
|
|
assertIncludes(appSource, "id=\"command-input\"", "React runtime must render command input selector");
|
|
assertIncludes(appSource, "id=\"conversation-list\"", "React runtime must render conversation list selector");
|
|
assertIncludes(appSource, "id=\"device-pod-sidebar\"", "React runtime must render Device Pod sidebar selector");
|
|
assertIncludes(appSource, "id=\"device-event-scroll\"", "React runtime must render Device Pod event stream selector");
|
|
assertIncludes(appSource, "id=\"logout-button\"", "React runtime must render logout selector");
|
|
assert.doesNotMatch(appSource, /document\.getElementById\((?!["']root["'])|querySelector\(["']#(?:command-input|conversation-list|device-pod-sidebar)/u, "major UI must not be driven by global DOM element lookups");
|
|
assert.doesNotMatch(appSource, /\bconst\s+el\s*=/u, "global el map must not return");
|
|
|
|
const sourceFiles = collectSourceFiles(path.join(rootDir, "src"));
|
|
for (const file of sourceFiles) {
|
|
const rel = path.relative(rootDir, file);
|
|
const lines = fs.readFileSync(file, "utf8").split("\n").length;
|
|
assert.ok(lines <= 400, `${rel} exceeds 400 lines (${lines}); split the React module`);
|
|
}
|
|
|
|
for (const directory of ["components/layout", "components/auth", "components/sessions", "components/conversation", "components/command-bar", "components/device-pod", "components/settings", "components/skills", "hooks", "services/api", "state", "types", "styles"]) {
|
|
assert.ok(fs.existsSync(path.join(rootDir, "src", directory)), `missing React module boundary src/${directory}`);
|
|
}
|
|
assert.match(css, /@media \(max-width: 720px\)/u, "mobile layout contract must be explicit");
|
|
assert.match(css, /grid-template-columns:\s*56px\s+minmax\(0,\s*1fr\)/u, "390px mobile layout must not keep three squeezed columns");
|
|
assert.match(css, /\.device-event-scroll\s*\{[\s\S]*?overscroll-behavior:\s*contain;/u, "event stream scroll must be contained");
|
|
assert.match(css, /\.session-tabs\s*\{[\s\S]*?overflow-x:\s*hidden;/u, "session sidebar must not horizontally scroll");
|
|
|
|
const cloudApiServer = readRepo("internal/cloud/server.ts");
|
|
const deployJson = readRepo("deploy/deploy.json");
|
|
assertIncludes(cloudApiServer, "/v1/device-pods", "cloud-api must expose Device Pod REST routes");
|
|
assertIncludes(cloudApiServer, "/v1/skills", "cloud-api must expose Skills REST routes");
|
|
assertIncludes(deployJson, "hwlab-device-pod", "deploy manifest must include Device Pod service");
|
|
|
|
await buildCloudWebDist(rootDir);
|
|
console.log("hwlab-cloud-web check: dist bundle built by Vite");
|
|
await assertCloudWebDistFresh(rootDir);
|
|
console.log("hwlab-cloud-web check: dist freshness verified");
|
|
console.log("hwlab-cloud-web check ok: React + strict TypeScript module boundaries and runtime selectors are present");
|
|
|
|
function readWeb(relativePath: string): string { return fs.readFileSync(path.resolve(rootDir, relativePath), "utf8"); }
|
|
function readRepo(relativePath: string): string { return fs.readFileSync(path.resolve(repoRoot, relativePath), "utf8"); }
|
|
function assertIncludes(source: string, term: string, message: string): void { assert.ok(source.includes(term), message); }
|
|
function collectSourceFiles(dir: string): string[] {
|
|
const out: string[] = [];
|
|
for (const entry of fs.readdirSync(dir)) {
|
|
const full = path.join(dir, entry);
|
|
const stat = fs.statSync(full);
|
|
if (stat.isDirectory()) out.push(...collectSourceFiles(full));
|
|
else if (/\.(ts|tsx|css)$/u.test(full)) out.push(full);
|
|
}
|
|
return out;
|
|
}
|