Files
pikasTech-HWLAB/web/hwlab-cloud-web/scripts/check.mjs
T
2026-05-28 08:06:24 +08:00

257 lines
10 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 } 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.mjs");
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");
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");
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.mjs"),
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);
}
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, "\\$&");
}