Files
pikasTech-HWLAB/web/hwlab-cloud-web/scripts/check.ts
T
2026-06-03 10:40:50 +08:00

355 lines
19 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, 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",
"styles.css",
"app.ts",
"app-skills.ts",
"app-session-tabs.ts",
"app-device-pod.ts",
"app-conversation.ts",
"app-trace.ts",
"app-helpers.ts",
"composer-policy.ts",
"auth.ts",
"code-agent-facts.ts",
"code-agent-status.ts",
"message-markdown.ts",
"live-status.ts",
"runtime.ts",
"favicon.svg",
"favicon.ico",
"help.md",
"third_party/marked/marked.esm.js",
"third_party/marked/LICENSE"
]);
for (const file of requiredFiles) {
const filePath = path.resolve(rootDir, file);
if (!fs.existsSync(filePath)) throw new Error(`missing web asset: ${file}`);
}
console.log("hwlab-cloud-web check: runtime assets present");
await buildCloudWebDist(rootDir);
console.log("hwlab-cloud-web check: dist bundle built");
await assertCloudWebDistFresh(rootDir);
console.log("hwlab-cloud-web check: dist freshness verified");
const html = readWeb("index.html");
const styles = readWeb("styles.css");
const app = readCloudWebAppSource(rootDir);
assertTraceHelpersBound(readWeb("app-trace.ts"));
const liveStatus = readWeb("live-status.ts");
const helpMarkdown = readWeb("help.md");
const faviconSvg = readWeb("favicon.svg");
const faviconIco = readWeb("favicon.ico");
const distContractScript = readWeb("scripts/dist-contract.ts");
const cloudApiServer = readRepo("internal/cloud/server.ts");
const cloudApiPayloads = readRepo("internal/cloud/server-rest-payloads.ts");
const devicePodData = readRepo("internal/device-pod/fake-data.mjs");
const devicePodService = readRepo("cmd/hwlab-device-pod/main.ts");
const protocol = readRepo("internal/protocol/index.mjs");
const deployJson = readRepo("deploy/deploy.json");
const artifactCatalog = readRepo("deploy/artifact-catalog.dev.json");
console.log("hwlab-cloud-web check: source files loaded");
const rightSidebar = sectionById(html, "aside", "device-pod-sidebar");
console.log("hwlab-cloud-web check: right sidebar extracted");
assert.ok(rightSidebar, "Device Pod right sidebar must exist");
assert.match(html, /data-route="skills"/u, "Cloud Web must expose the skills activity route");
assert.match(html, /id="session-tabs"[^>]*role="tablist"/u, "Cloud Web workspace must expose session tabs");
assert.match(html, /class="session-sidebar"[^>]*id="session-sidebar"/u, "Cloud Web must place sessions in a second-level sidebar");
assert.match(html, /id="command-new-session"/u, "Cloud Web command bar must expose explicit Code Agent session creation");
assertIncludes(app, "/v1/agent/sessions", "Cloud Web must create/select Code Agent sessions explicitly");
assertIncludes(app, "session_required", "Cloud Web must block normal turns until a session is explicitly created");
assertIncludes(app, "session_not_usable", "Cloud Web must block failed/stale Code Agent sessions instead of auto rolling");
assert.match(html, /id="skills"[^>]*data-view="skills"/u, "Cloud Web must expose a skills view");
assert.match(html, /id="skill-upload-input"[^>]*webkitdirectory/u, "Skills view must support directory upload");
assertIncludes(app, "/v1/skills", "skills frontend must call /v1/skills");
assertIncludes(app, "/v1/skills/uploads", "skills frontend must call /v1/skills/uploads");
assert.match(app, /function\s+initSkillsPanel\s*\(/u, "missing initSkillsPanel");
assert.match(app, /function\s+loadSkillsSurface\s*\(/u, "missing loadSkillsSurface");
assert.match(app, /function\s+uploadSelectedSkillFiles\s*\(/u, "missing uploadSelectedSkillFiles");
assert.match(app, /function\s+renderSkillPreview\s*\(/u, "missing renderSkillPreview");
assertIncludes(app, "promptAssembly", "skills frontend must render AgentRun prompt assembly summary");
assertIncludes(app, "引导 prompt 装配", "skills frontend must label the prompt assembly panel");
assertIncludes(app, "ResourceBundleRef.promptRefs", "skills frontend must expose prompt assembly source metadata");
assertIncludes(styles, ".skill-prompt-assembly", "skills frontend must style prompt assembly rows");
assertIncludes(styles, ".skill-prompt-row", "skills frontend must style prompt assembly refs");
assertIncludes(app, "sessionTabsFromMessages", "workspace frontend must group messages by session");
assertIncludes(app, "activeSessionKey", "workspace frontend must track the active session tab");
assertIncludes(app, "renderSessionSidebar", "workspace frontend must render account-scoped sessions in the second-level sidebar");
assertIncludes(app, "selectSessionSidebarSession", "workspace frontend must switch sessions from the second-level sidebar");
assertIncludes(styles, ".session-tabs", "workspace frontend must style session tabs");
assertIncludes(styles, ".session-tab", "workspace frontend must style individual session tabs");
assertIncludes(app, "sessionTabsFromConversations", "workspace frontend must build session tabs from account conversations");
assertIncludes(app, "/v1/agent/conversations", "workspace frontend must load account-scoped sessions");
assertIncludes(app, "select-conversation", "workspace frontend must switch sessions through workbench select-conversation");
assertIncludes(styles, ".session-sidebar", "workspace frontend must style the second-level session sidebar");
assertIncludes(styles, "grid-template-columns: var(--rail-width) var(--session-sidebar-width)", "workspace shell must place session sidebar to the right of activity rail");
assert.match(cssRule(styles, ".activity-rail"), /overflow-x:\s*hidden;/u, "activity rail must not horizontally scroll");
assert.match(cssRule(styles, ".session-tabs"), /overflow-x:\s*hidden;/u, "session sidebar must not horizontally scroll");
assert.match(cssRule(styles, ".rail-button"), /white-space:\s*nowrap;/u, "activity rail labels must not auto-wrap");
assert.match(cssRule(styles, ".session-tab-label"), /white-space:\s*nowrap;/u, "session labels must not auto-wrap");
console.log("hwlab-cloud-web check: skills contracts verified");
for (const term of [
"Device Pod",
"设备目标看板",
"id=\"device-pod-select\"",
"id=\"device-pod-summary\"",
"data-device-detail=\"pod\"",
"id=\"device-pod-interfaces\"",
"id=\"device-event-scroll\"",
"id=\"device-event-text\"",
"id=\"device-detail-dialog\""
]) {
assertIncludes(rightSidebar, term, `right sidebar missing ${term}`);
}
assert.doesNotMatch(rightSidebar, /<details\b/iu, "right sidebar must not use internal expand/collapse details");
assert.doesNotMatch(rightSidebar, /readonly-rpc|复核入口/iu, "right sidebar must not render the readonly RPC review note");
assert.doesNotMatch(rightSidebar, /data-hardware-tab|m3-control|hardware-list|wiring-body|records-list/iu, "right sidebar must not keep legacy hardware panels");
assert.match(rightSidebar, /class="summary-tile"[^>]*role="button"[^>]*tabindex="0"/u, "Pod summary is a click/keyboard summary tile");
assert.match(rightSidebar, /<dialog\b[\s\S]*id="device-detail-dialog"/u, "summary details must open in a dialog");
assert.match(cssRule(styles, ".right-sidebar"), /grid-template-rows:\s*auto\s+auto\s+auto\s+auto\s+minmax\(0,\s*1fr\);/u, "right sidebar must reserve remaining height for Device Pod workspace instead of stacking it below the status area");
assert.match(cssRule(styles, ".right-sidebar"), /overflow-y:\s*hidden;/u, "right sidebar must not rely on outer scrolling to reveal Device Pod workspace");
assert.match(cssRules(styles, ".device-pod-workspace"), /grid-template-rows:\s*auto\s+minmax\(0,\s*1fr\);/u, "Device Pod workspace must keep the event stream inside its remaining row");
assert.match(cssRules(styles, ".device-pod-workspace"), /overflow:\s*hidden;/u, "Device Pod workspace must not be pushed below the right sidebar viewport");
assert.match(cssRule(styles, ".device-event-panel"), /grid-template-rows:\s*auto\s+minmax\(132px,\s*1fr\);/u, "event stream must keep a bounded scrollable body inside the visible workspace");
for (const selector of [
".device-pod-status",
".device-pod-workspace",
".summary-tile",
".device-event-panel",
".device-event-scroll",
".device-detail-dialog"
]) {
assertIncludes(styles, selector, `missing Device Pod style ${selector}`);
}
assert.match(styles, /\.device-event-scroll\s*\{[\s\S]*?overscroll-behavior:\s*contain;/u, "event stream scroll must be contained");
console.log("hwlab-cloud-web check: device pod layout contracts verified");
for (const fn of [
"initDevicePodPanel",
"renderDevicePodPanel",
"renderDevicePodSummary",
"renderDevicePodInterfaces",
"renderDeviceEventStream",
"markDeviceEventScrollIntent",
"deviceEventUserActive",
"setDeviceEventFollow",
"showDeviceDetail"
]) {
assert.match(app, new RegExp(`function\\s+${fn}\\s*\\(`, "u"), `missing ${fn}`);
}
for (const route of [
"/v1/device-pods",
"/status",
"/events?limit=120",
"/debug-probe/chip-id",
"/io-probe/uart/1",
"/io-probe/uart/1/tail?maxBytes=12000"
]) {
assertIncludes(app, route, `Device Pod frontend route missing ${route}`);
}
assert.match(app, /showModal\(\)/u, "Device Pod detail must use a modal dialog when available");
assertIncludes(app, "followEvents", "event stream must track follow state");
assertIncludes(app, "unreadEvents", "event stream must track unread state");
assertIncludes(app, "lastEventLineCount", "event stream must track last line count");
assert.match(app, /eventScrollUserActiveUntil/u, "event stream must track user scroll activity");
assert.match(app, /function\s+isCodeAgentTimeoutError\s*\(/u, "restored Code Agent timeout reconciliation helper must be defined");
assert.match(app, /function\s+renderDrafts\s*\(/u, "restored Code Agent state must not call an undefined draft renderer");
assert.doesNotMatch(functionBody(app, "loadLiveSurface"), /\/v1\/m3|audit\.event\.query|evidence\.record\.query/u, "live surface must use Device Pod routes, not legacy hardware/evidence panels");
console.log("hwlab-cloud-web check: device pod app contracts verified");
assert.match(liveStatus, /function classifyDevicePodProbe/u, "live status must classify Device Pod probe");
assert.match(liveStatus, /function shortEvidenceToken\s*\(/u, "live status must define profile hash evidence shortener locally");
assert.doesNotMatch(functionBody(app, "shouldReconcileRestoredCodeAgentResult"), /return\s+true\s*;/u, "restored Code Agent state must not blindly fetch stale result traces");
assert.doesNotMatch(functionBody(app, "shouldReconcileRestoredCodeAgentResult"), /isRecoverableCodeAgentTerminalMessage/u, "restored Code Agent state must not fetch stale result traces just because a timeout is recoverable");
assert.doesNotMatch(liveStatus, /classifyM3|serviceForM3Layer|\/v1\/m3/u, "live status must not keep legacy M3 probes");
console.log("hwlab-cloud-web check: live status static contracts verified");
await assertDevicePodEvidenceSummaryDoesNotCrash();
console.log("hwlab-cloud-web check: live status dynamic contract verified");
assert.match(html, /rel="icon"[^>]*href="\/favicon\.svg"/u, "Cloud Web must declare a repo-owned favicon");
assert.match(faviconSvg, /<svg\b[\s\S]*HW/u, "favicon.svg must be a real HWLAB SVG icon");
assert.equal(faviconIco, faviconSvg, "favicon.ico compatibility asset must mirror the repo-owned SVG icon until binary ICO generation exists");
const activeFrontend = `${html}\n${app}\n${liveStatus}\n${helpMarkdown}`;
for (const forbidden of [
"Gateway-SIMU",
"BOX-SIMU",
"Patch Panel",
"hwlab-patch-panel",
"gateway-simu",
"box-simu",
"m3-control",
"/v1/m3",
"renderHardwareStatus",
"renderWiringList",
"renderRecords",
"messageM3EvidencePanel",
"M3_TRUSTED_ROUTE",
"写入 DO1",
"读取 DI1"
]) {
assertDoesNotInclude(activeFrontend, forbidden, `legacy frontend marker returned: ${forbidden}`);
}
for (const helpTerm of ["Device Pod 看板", "summary", "弹窗", "纯文本事件流", "blocker", "跟随"]){
assertIncludes(helpMarkdown, helpTerm, `help.md missing ${helpTerm}`);
}
console.log("hwlab-cloud-web check: frontend cleanup contracts verified");
for (const oldAsset of ["code-agent-m3-evidence.mjs", "wiring-status.mjs", "workbench-hardware-panel.mjs"]){
assertDoesNotInclude(distContractScript, oldAsset, `legacy web runtime asset still copied: ${oldAsset}`);
}
console.log("hwlab-cloud-web check: legacy asset cleanup verified");
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_USER_SKILLS_DIR", "deploy manifest must define uploaded skills PVC env");
assertIncludes(deployJson, "/data/user-skills", "deploy manifest must keep uploaded skills outside /app/skills");
assertIncludes(readRepo("deploy/k8s/base/workloads.yaml"), "hwlab-user-skills", "base workload must include uploaded skills PVC");
assertIncludes(cloudApiPayloads, "buildDevicePodCloudApiPayload", "cloud-api payload module must keep the Device Pod compatibility payload helper");
assertIncludes(cloudApiPayloads, "device_pod_authority_unavailable", "Device Pod compatibility helper must block instead of fake fallback");
assertIncludes(devicePodData, "buildDevicePodRestPayload", "Legacy Device Pod fixtures must keep the REST payload helper for old smokes");
assertIncludes(devicePodService, "device-pod-executor-v1", "Device Pod service must expose the executor contract");
assertIncludes(devicePodService, "hwlab-cloud-api", "Device Pod service must defer user-facing authority to cloud-api");
assertIncludes(devicePodService, "fake: false", "Device Pod service must not report fake runtime data");
assertIncludes(protocol, "hwlab-device-pod", "protocol service ids must include Device Pod");
assertIncludes(deployJson, "hwlab-device-pod", "deploy manifest must include Device Pod service");
assertIncludes(artifactCatalog, "hwlab-device-pod", "artifact catalog must include Device Pod service");
console.log("hwlab-cloud-web check: service contracts verified");
console.log("hwlab-cloud-web check ok: Device Pod summary sidebar, modal detail, simple event stream, TS build, and executor authority contracts are present");
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, "\\$&");
}
function readWeb(relativePath) {
return fs.readFileSync(path.resolve(rootDir, relativePath), "utf8");
}
function readRepo(relativePath) {
return fs.readFileSync(path.resolve(repoRoot, relativePath), "utf8");
}
function assertIncludes(source, term, message) {
assert.ok(source.includes(term), message);
}
function assertDoesNotInclude(source, term, message) {
assert.equal(source.includes(term), false, message);
}
async function assertDevicePodEvidenceSummaryDoesNotCrash() {
const { classifyWorkbenchLiveStatus } = await import(pathToFileURL(path.resolve(rootDir, "live-status.ts")).href);
const status = classifyWorkbenchLiveStatus({
healthLive: { ok: true, data: { serviceId: "hwlab-cloud-api", status: "ok" } },
restIndex: { ok: true, data: { serviceId: "hwlab-cloud-api", status: "ok" } },
health: { ok: true, data: { serviceId: "hwlab-cloud-api", status: "ok" } },
adapter: { ok: true, data: { serviceId: "hwlab-cloud-api", status: "ok" } },
devicePodStatus: {
ok: true,
data: {
status: "ready",
serviceId: "hwlab-device-pod",
summary: {
devicePodId: "device-pod-71-freq",
targetId: "71-FREQ",
profileHash: "abcdef0123456789fedcba9876543210",
latestEvent: { refs: { traceId: "trc_live_status", operationId: "op_live_status" } }
}
}
}
});
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);
}
function sectionById(source, tagName, id) {
const startPattern = new RegExp(`<${tagName}\\b[^>]*\\bid=["']${escapeRegExp(id)}["'][^>]*>`, "iu");
const startMatch = startPattern.exec(source);
if (!startMatch) return "";
const start = startMatch.index;
const tagPattern = new RegExp(`</?${tagName}\\b[^>]*>`, "giu");
tagPattern.lastIndex = start;
let depth = 0;
for (const match of source.slice(start).matchAll(tagPattern)) {
const absoluteEnd = start + match.index + match[0].length;
if (match[0].startsWith("</")) {
depth -= 1;
if (depth === 0) return source.slice(start, absoluteEnd);
} else {
depth += 1;
}
}
return source.slice(start);
}
function cssRule(source, selector) {
const match = new RegExp(`${escapeRegExp(selector)}\\s*\\{`, "u").exec(source);
if (!match) return "";
const bodyStart = match.index + match[0].length;
const bodyEnd = source.indexOf("}", bodyStart);
return bodyEnd >= 0 ? source.slice(match.index, bodyEnd + 1) : source.slice(match.index);
}
function cssRules(source, selector) {
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{`, "gu");
const rules = [];
for (const match of source.matchAll(pattern)) {
const bodyStart = match.index + match[0].length;
const bodyEnd = source.indexOf("}", bodyStart);
rules.push(bodyEnd >= 0 ? source.slice(match.index, bodyEnd + 1) : source.slice(match.index));
}
return rules.join("\n");
}
function functionBody(source, name) {
const match = new RegExp(`function\\s+${escapeRegExp(name)}\\s*\\([^)]*\\)\\s*\\{`, "u").exec(source);
if (!match) return "";
let depth = 0;
for (let index = match.index + match[0].length - 1; index < source.length; index += 1) {
const char = source[index];
if (char === "{") depth += 1;
if (char === "}") {
depth -= 1;
if (depth === 0) return source.slice(match.index, index + 1);
}
}
return source.slice(match.index);
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}