Files
pikasTech-HWLAB/web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs
T
Lyon 7a0648088c fix: wire distinct M3 simulator identities
Merge PR #159 after Code Queue rebase and validation. Runner verified M3 cardinality, hardware-loop smoke, patch-panel runtime smoke, m3 readonly contract, and git diff check. This is source/config blocker removal only; it does not claim DEV-LIVE M3 pass.
2026-05-23 00:09:29 +08:00

120 lines
5.8 KiB
JavaScript

import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { gateSummary } from "../gate-summary.mjs";
import { runtime } from "../runtime.mjs";
const PUBLIC_FRONTEND_ENDPOINT = "http://74.48.78.17:16666";
const PUBLIC_API_EDGE_ENDPOINT = "http://74.48.78.17:16667";
const READ_ONLY_RPC_METHODS = Object.freeze([
"system.health",
"cloud.adapter.describe",
"audit.event.query",
"evidence.record.query"
]);
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repoRoot = path.resolve(rootDir, "../..");
function readText(relativePath) {
return fs.readFileSync(path.resolve(repoRoot, relativePath), "utf8");
}
function readJson(relativePath) {
return JSON.parse(readText(relativePath));
}
function uniqueSorted(values) {
return [...new Set(values)].sort();
}
function rpcCallsFrom(source) {
return [...source.matchAll(/callRpc\(\s*["']([^"']+)["']/gu)].map((match) => match[1]);
}
export function runCloudWebM3ReadonlyContract() {
const html = readText("web/hwlab-cloud-web/index.html");
const app = readText("web/hwlab-cloud-web/app.mjs");
const styles = readText("web/hwlab-cloud-web/styles.css");
const runtimeSource = readText("web/hwlab-cloud-web/runtime.mjs");
const frontendSource = `${html}\n${app}\n${styles}\n${runtimeSource}`;
const acceptance = readJson("docs/dev-acceptance-checklist.json");
assert.equal(acceptance.publicEndpoints.frontend, PUBLIC_FRONTEND_ENDPOINT, "public frontend must stay on :16666");
assert.equal(acceptance.publicEndpoints.api, PUBLIC_API_EDGE_ENDPOINT, "public API must stay on :16667");
assert.equal(acceptance.publicEndpoints.edge, PUBLIC_API_EDGE_ENDPOINT, "public edge must stay on :16667");
assert.equal(runtime.endpoints.frontend, PUBLIC_FRONTEND_ENDPOINT, "web runtime frontend endpoint");
assert.equal(runtime.endpoints.api, PUBLIC_API_EDGE_ENDPOINT, "web runtime API endpoint");
assert.equal(runtime.endpoints.edge, PUBLIC_API_EDGE_ENDPOINT, "web runtime edge endpoint");
assert.equal(runtime.endpoints.dev, PUBLIC_API_EDGE_ENDPOINT, "legacy runtime dev endpoint alias");
assert.equal(gateSummary.endpoint, PUBLIC_API_EDGE_ENDPOINT, "gate summary endpoint");
assert.deepEqual(
uniqueSorted(rpcCallsFrom(app)),
[...READ_ONLY_RPC_METHODS].sort(),
"Cloud Web must call only read-only JSON-RPC diagnostics methods"
);
assert.match(app, /fetchJson\("\/v1"\)/u, "Cloud Web must read the public REST index");
assert.match(app, /fetchJson\("\/json-rpc"/u, "Cloud Web must use same-origin JSON-RPC for diagnostics");
assert.match(app, /runtime\.serviceRoute\.join\(" -> "\)/u, "Cloud Web must render the observed route contract");
for (const [pattern, label] of [
[/DEV Control Console/iu, "generic control console label"],
[/Operator Commands/iu, "operator command entry point label"],
[/safe CLI entry points/iu, "manual command launcher copy"],
[/--live --confirm-dev --confirmed-non-production/iu, "live mutation command"],
[/hardware\.operation\.request/iu, "direct hardware operation RPC"],
[/hardware\.invoke\.shell/iu, "direct hardware shell RPC"],
[/audit\.event\.write/iu, "audit write RPC"],
[/evidence\.record\.write/iu, "evidence write RPC"],
[/\/v1\/rpc\//iu, "direct REST RPC bridge"],
[/\/wiring\/apply/iu, "patch-panel apply endpoint"],
[/\/wiring\/reload/iu, "patch-panel reload endpoint"],
[/method-pill[\s\S]{0,80}write/iu, "write method pill"]
]) {
assert.doesNotMatch(frontendSource, pattern, `Cloud Web frontend must not expose ${label}`);
}
const m3Milestone = gateSummary.milestones.find((item) => item.id === "M3");
assert.equal(m3Milestone?.status, "blocked", "Cloud Web must not present edge-only M3 as DEV-LIVE");
assert.match(m3Milestone.summary, /blocked by hardware loop topology/u, "M3 summary must explain the live blocker");
assert.ok(
gateSummary.blockers.some((blocker) => blocker.scope === "m3-hardware-loop-runtime"),
"M3 hardware-loop runtime blocker must remain visible"
);
assert.equal(gateSummary.topology.patchPanel.serviceId, "hwlab-patch-panel", "topology must route through patch-panel");
assert.deepEqual(gateSummary.topology.gateways.map((gateway) => gateway.gatewayId), ["gwsimu_1", "gwsimu_2"], "M3 workbench topology must expose two distinct gateway-simu identities");
assert.deepEqual(gateSummary.topology.boxResources.map((resource) => resource.resourceId), ["res_boxsimu_1", "res_boxsimu_2"], "M3 workbench topology must expose two distinct box-simu identities");
assert.deepEqual(gateSummary.topology.patchPanel.activeConnections, [
{
fromResourceId: "res_boxsimu_1",
fromPort: "DO1",
toResourceId: "res_boxsimu_2",
toPort: "DI1"
}
], "M3 workbench patch-panel links must show only res_boxsimu_1:DO1 -> res_boxsimu_2:DI1");
assert.doesNotMatch(
JSON.stringify(gateSummary.topology),
/res_m5-control-relay|res_m5-target-board|out1|reset/u,
"M5/reset dry-run wiring must not appear in the default M3 workbench topology"
);
assert.ok(gateSummary.health.length >= 1, "health diagnostics must remain visible");
assert.ok(gateSummary.evidenceRecords.every((record) => record.dryRun === true), "checked-in evidence must stay dry-run labeled");
assert.ok(
acceptance.m3EvidenceClassification.nonP0.includes("edge-only-diagnostic"),
"edge-only diagnostics must stay non-P0 support evidence"
);
assert.match(
acceptance.m3EvidenceClassification.promotionRule,
/Do not classify M3 support or non-P0 evidence as DEV-LIVE/u,
"M3 promotion rule must forbid edge-only DEV-LIVE promotion"
);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
runCloudWebM3ReadonlyContract();
console.log("Cloud Web M3 read-only diagnostics contract passed");
}