Files
pikasTech-HWLAB/web/hwlab-cloud-web/scripts/check.mjs
T

293 lines
13 KiB
JavaScript

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repoRoot = path.resolve(rootDir, "../..");
const requiredFiles = Object.freeze([
"index.html",
"styles.css",
"auth.mjs",
"app.mjs",
"code-agent-facts.mjs",
"code-agent-status.mjs",
"message-markdown.mjs",
"live-status.mjs",
"runtime.mjs",
"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}`);
}
runJavaScriptSyntaxCheck();
const html = readWeb("index.html");
const styles = readWeb("styles.css");
const app = readWeb("app.mjs");
const liveStatus = readWeb("live-status.mjs");
const helpMarkdown = readWeb("help.md");
const faviconSvg = readWeb("favicon.svg");
const faviconIco = readWeb("favicon.ico");
const distContractScript = readWeb("scripts/dist-contract.mjs");
const cloudApiServer = readRepo("internal/cloud/server.ts");
const devicePodData = readRepo("internal/device-pod/fake-data.mjs");
const devicePodService = readRepo("cmd/hwlab-device-pod/main.mjs");
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", "弹窗", "纯文本事件流", "fake 数据", "跟随"]){
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(cloudApiServer, "buildDevicePodCloudApiPayload", "cloud-api must proxy/fallback Device Pod payloads");
assertIncludes(devicePodData, "buildDevicePodRestPayload", "Device Pod fake data must provide REST payload helper");
assertIncludes(devicePodService, "buildDevicePodRestPayload", "Device Pod service must use dynamic REST payload helper");
assertIncludes(devicePodService, "DEVICE_POD_SERVICE_ID", "Device Pod service must report service id");
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, JS static syntax, and fake service contracts are present");
function runJavaScriptSyntaxCheck() {
const files = new Set([
...collectJavaScriptFiles(rootDir),
path.resolve(repoRoot, "internal/cloud/server.ts"),
path.resolve(repoRoot, "internal/device-pod/fake-data.mjs"),
path.resolve(repoRoot, "cmd/hwlab-device-pod/main.mjs")
]);
for (const filePath of [...files].sort()) {
const result = spawnSync(process.execPath, ["--check", filePath], {
cwd: repoRoot,
encoding: "utf8",
windowsHide: true
});
assert.equal(
result.status,
0,
[
`JS static syntax check failed: ${path.relative(repoRoot, filePath)}`,
result.stdout.trim(),
result.stderr.trim()
].filter(Boolean).join("\n")
);
}
}
function collectJavaScriptFiles(dir) {
const ignoredDirs = new Set(["dist", "node_modules", ".cache", ".state"]);
const result = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.resolve(dir, entry.name);
if (entry.isDirectory()) {
if (!ignoredDirs.has(entry.name)) result.push(...collectJavaScriptFiles(fullPath));
continue;
}
if (entry.isFile() && /\.(?:mjs|js)$/u.test(entry.name)) result.push(fullPath);
}
return result;
}
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.mjs")).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, "\\$&");
}