246 lines
9.1 KiB
JavaScript
246 lines
9.1 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",
|
|
"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 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.doesNotMatch(liveStatus, /classifyM3|serviceForM3Layer|\/v1\/m3/u, "live status must not keep legacy M3 probes");
|
|
|
|
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, "\\$&");
|
|
}
|