Files
pikasTech-HWLAB/web/hwlab-cloud-web/scripts/check.ts
T
2026-05-31 11:39:18 +08:00

278 lines
13 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-device-pod.ts",
"app-conversation.ts",
"app-trace.ts",
"app-helpers.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}`);
}
await buildCloudWebDist(rootDir);
await assertCloudWebDistFresh(rootDir);
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");
const rightSidebar = sectionById(html, "aside", "device-pod-sidebar");
assert.ok(rightSidebar, "Device Pod right sidebar must exist");
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, /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");
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");
assert.match(styles, /\.right-sidebar\s*\{[^}]*grid-template-rows:\s*auto minmax\(0, 1fr\);[^}]*overflow-x:\s*hidden;[^}]*overflow-y:\s*auto;[^}]*scrollbar-gutter:\s*stable;[^}]*\}/u, "right sidebar must be vertically scrollable and leave remaining space to the event stream");
assert.match(styles, /\.device-pod-status\s*\{[^}]*grid-template-rows:\s*auto auto auto;[^}]*align-content:\s*start;[^}]*\}/u, "Device Pod summary section must stay content-sized");
assert.match(styles, /\.device-pod-workspace\s*\{[^}]*grid-template-rows:\s*auto minmax\(180px, 1fr\) auto;[^}]*\}/u, "Device Pod workspace must reserve vertical space for the event stream");
assert.match(styles, /\.device-pod-summary \.kv-list\s*\{[^}]*grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\);[^}]*\}/u, "Pod Summary key/value rows must use a compact two-column layout");
assert.match(styles, /\.device-event-panel\s*\{[^}]*min-height:\s*180px;[^}]*\}/u, "event panel must keep a usable minimum height");
assert.match(styles, /\.device-event-scroll\s*\{[^}]*min-height:\s*132px;[^}]*overflow:\s*auto;[^}]*\}/u, "event stream must keep a usable scroll viewport");
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");
assert.match(app, /followEvents[\s\S]*unreadEvents[\s\S]*lastEventLineCount/u, "event stream must track follow/unread state");
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");
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");
await assertDevicePodEvidenceSummaryDoesNotCrash();
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}`);
}
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}`);
}
assertIncludes(cloudApiServer, "/v1/device-pods", "cloud-api must expose Device Pod REST routes");
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 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 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, "\\$&");
}