test: add dev m3 hardware loop smoke
This commit is contained in:
@@ -43,3 +43,34 @@ patch-panel state application, real or simulator box observation, persisted
|
||||
audit events, and evidence records. That future work must preserve the same
|
||||
wiring contract and must keep routing decisions under `hwlab-patch-panel`
|
||||
ownership instead of mutating another box directly.
|
||||
|
||||
## DEV Runtime Smoke
|
||||
|
||||
Use this command only for the DEV simulator runtime:
|
||||
|
||||
```sh
|
||||
node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production
|
||||
```
|
||||
|
||||
The smoke writes `reports/dev-gate/dev-m3-hardware-loop.json`. It first checks
|
||||
the frozen DEV endpoint, `http://74.48.78.17:6667`, and stops at the first
|
||||
critical blocker from `docs/dev-acceptance-matrix.md`. When DEV ingress is not
|
||||
observable, the report is `blocked` and the script does not run the live
|
||||
`do.write true -> di.read true` operation.
|
||||
|
||||
If DEV ingress is reachable, the script requires explicit direct DEV simulator
|
||||
targets through these environment variables:
|
||||
|
||||
```text
|
||||
HWLAB_DEV_BOX_SIMU_1_URL
|
||||
HWLAB_DEV_BOX_SIMU_2_URL
|
||||
HWLAB_DEV_GATEWAY_SIMU_1_URL
|
||||
HWLAB_DEV_GATEWAY_SIMU_2_URL
|
||||
HWLAB_DEV_PATCH_PANEL_URL
|
||||
```
|
||||
|
||||
Those targets must be HWLAB DEV simulator services, not UniDesk substitutes.
|
||||
The script then checks two box simulators, two gateway simulators, active
|
||||
patch-panel wiring for `box-simu-1 DO1 -> box-simu-2 DI1`, a live direct call
|
||||
that reads `DI1=true`, and returned audit/evidence identifiers. Fixture-backed
|
||||
local M3 output is never accepted as live DEV evidence.
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"$schema": "https://hwlab.pikastech.local/schemas/dev-m3-hardware-loop-report.schema.json",
|
||||
"$id": "https://hwlab.pikastech.local/reports/dev-gate/dev-m3-hardware-loop.json",
|
||||
"reportVersion": "v1",
|
||||
"issue": "pikasTech/HWLAB#38",
|
||||
"taskId": "dev-m3-hardware-loop",
|
||||
"commitId": "1e8d009e9531",
|
||||
"acceptanceLevel": "dev_m3_hardware_loop",
|
||||
"devOnly": true,
|
||||
"prodDisabled": true,
|
||||
"sourceContract": {
|
||||
"status": "pass",
|
||||
"documents": [
|
||||
"docs/dev-acceptance-matrix.md",
|
||||
"docs/m3-hardware-loop.md",
|
||||
"fixtures/mvp/m3-hardware-loop/topology.json"
|
||||
],
|
||||
"summary": "Source contracts define DEV-only M3 simulator wiring and prohibit fixture output as live evidence."
|
||||
},
|
||||
"validationCommands": [
|
||||
"node --check scripts/dev-m3-hardware-loop-smoke.mjs",
|
||||
"node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production",
|
||||
"node --check scripts/validate-dev-gate-report.mjs",
|
||||
"node scripts/validate-dev-gate-report.mjs"
|
||||
],
|
||||
"runtimeTarget": {
|
||||
"endpoint": "http://74.48.78.17:6667",
|
||||
"namespace": "hwlab-dev",
|
||||
"environment": "dev",
|
||||
"requiredBoxSimulators": 2,
|
||||
"requiredGatewaySimulators": 2,
|
||||
"realHardwareAllowed": false,
|
||||
"prodAllowed": false
|
||||
},
|
||||
"safetyGates": {
|
||||
"liveFlagRequired": true,
|
||||
"confirmDevRequired": true,
|
||||
"confirmedNonProductionRequired": true,
|
||||
"prodForbidden": true,
|
||||
"realHardwareForbidden": true,
|
||||
"secretReadForbidden": true,
|
||||
"forcePushForbidden": true,
|
||||
"unideskRuntimeSubstitutionForbidden": true
|
||||
},
|
||||
"liveChecks": [
|
||||
{
|
||||
"id": "static-contract-parse",
|
||||
"status": "pass",
|
||||
"summary": "DEV M3 smoke contract files were readable before live probing.",
|
||||
"evidence": [
|
||||
"docs/dev-acceptance-matrix.md",
|
||||
"docs/m3-hardware-loop.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "endpoint-freeze",
|
||||
"status": "pass",
|
||||
"summary": "DEV endpoint is frozen at http://74.48.78.17:6667.",
|
||||
"evidence": [
|
||||
"http://74.48.78.17:6667"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dev-ingress-health",
|
||||
"status": "blocked",
|
||||
"blockerClass": "network_blocker",
|
||||
"summary": "DEV ingress at http://74.48.78.17:6667 did not return reachable HWLAB DEV health.",
|
||||
"evidence": [
|
||||
"{\"url\":\"http://74.48.78.17:6667/health/live\",\"method\":\"GET\",\"startedAt\":\"2026-05-21T16:38:29.319Z\",\"observedAt\":\"2026-05-21T16:38:33.884Z\",\"reached\":false,\"ok\":false,\"error\":{\"name\":\"Error\",\"message\":\"connect ECONNREFUSED 74.48.78.17:6667\",\"code\":\"ECONNREFUSED\"}}",
|
||||
"{\"url\":\"http://74.48.78.17:6667/health\",\"method\":\"GET\",\"startedAt\":\"2026-05-21T16:38:33.885Z\",\"observedAt\":\"2026-05-21T16:38:36.674Z\",\"reached\":false,\"ok\":false,\"error\":{\"name\":\"Error\",\"message\":\"connect ECONNREFUSED 74.48.78.17:6667\",\"code\":\"ECONNREFUSED\"}}",
|
||||
"{\"url\":\"http://74.48.78.17:6667/live\",\"method\":\"GET\",\"startedAt\":\"2026-05-21T16:38:36.674Z\",\"observedAt\":\"2026-05-21T16:38:39.674Z\",\"reached\":false,\"ok\":false,\"error\":{\"name\":\"Error\",\"message\":\"connect ECONNREFUSED 74.48.78.17:6667\",\"code\":\"ECONNREFUSED\"}}",
|
||||
"{\"url\":\"http://74.48.78.17:6667/v1\",\"method\":\"GET\",\"startedAt\":\"2026-05-21T16:38:39.674Z\",\"observedAt\":\"2026-05-21T16:38:39.835Z\",\"reached\":false,\"ok\":false,\"error\":{\"name\":\"Error\",\"message\":\"connect ECONNREFUSED 74.48.78.17:6667\",\"code\":\"ECONNREFUSED\"}}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "two-box-simu-online",
|
||||
"status": "not_run",
|
||||
"summary": "Stopped at DEV ingress per the DEV smoke matrix.",
|
||||
"evidence": [
|
||||
"No live DEV evidence collected for this check."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "two-gateway-simu-online",
|
||||
"status": "not_run",
|
||||
"summary": "Stopped at DEV ingress per the DEV smoke matrix.",
|
||||
"evidence": [
|
||||
"No live DEV evidence collected for this check."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "patch-panel-healthy",
|
||||
"status": "not_run",
|
||||
"summary": "Stopped at DEV ingress per the DEV smoke matrix.",
|
||||
"evidence": [
|
||||
"No live DEV evidence collected for this check."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wiring-do1-di1-applied",
|
||||
"status": "not_run",
|
||||
"summary": "Stopped at DEV ingress per the DEV smoke matrix.",
|
||||
"evidence": [
|
||||
"No live DEV evidence collected for this check."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "direct-call-do-write-di-read",
|
||||
"status": "not_run",
|
||||
"summary": "Stopped at DEV ingress per the DEV smoke matrix.",
|
||||
"evidence": [
|
||||
"No live DEV evidence collected for this check."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "audit-evidence-traceable",
|
||||
"status": "not_run",
|
||||
"summary": "Stopped at DEV ingress per the DEV smoke matrix.",
|
||||
"evidence": [
|
||||
"No live DEV evidence collected for this check."
|
||||
]
|
||||
}
|
||||
],
|
||||
"localRuntimeObservations": [
|
||||
{
|
||||
"id": "docker-hwlab-containers",
|
||||
"status": "not_observed",
|
||||
"command": "docker ps --format {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
|
||||
"summary": "No running docker container name/image/port line contained hwlab-."
|
||||
},
|
||||
{
|
||||
"id": "kubectl-hwlab-dev",
|
||||
"status": "unavailable",
|
||||
"command": "command -v kubectl",
|
||||
"summary": "kubectl is not available in this runner, so hwlab-dev namespace state was not observed."
|
||||
}
|
||||
],
|
||||
"liveOperation": {
|
||||
"status": "not_run",
|
||||
"operationId": "not_observed",
|
||||
"traceId": "not_observed",
|
||||
"auditId": "not_observed",
|
||||
"evidenceId": "not_observed",
|
||||
"summary": "No live do.write -> di.read operation was attempted because DEV ingress is blocked."
|
||||
},
|
||||
"blockers": [
|
||||
{
|
||||
"type": "network_blocker",
|
||||
"scope": "dev-ingress-health",
|
||||
"status": "open",
|
||||
"summary": "Blocked at the #33 DEV runtime readiness condition: public DEV ingress does not accept HWLAB health requests, so #36/M3 hardware-loop checks were not reached."
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"status": "blocked",
|
||||
"observedAt": "2026-05-21T16:38:29.315Z",
|
||||
"result": "No live DEV M3 evidence was produced; fixture-backed local evidence was not used as a substitute."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { access, mkdir, writeFile } from "node:fs/promises";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const defaultReportPath = "reports/dev-gate/dev-m3-hardware-loop.json";
|
||||
const namespace = "hwlab-dev";
|
||||
const issue = "pikasTech/HWLAB#38";
|
||||
|
||||
const serviceTargets = Object.freeze([
|
||||
{
|
||||
id: "box-simu-1",
|
||||
serviceId: "hwlab-box-simu",
|
||||
urlEnv: "HWLAB_DEV_BOX_SIMU_1_URL",
|
||||
statusPath: "/status"
|
||||
},
|
||||
{
|
||||
id: "box-simu-2",
|
||||
serviceId: "hwlab-box-simu",
|
||||
urlEnv: "HWLAB_DEV_BOX_SIMU_2_URL",
|
||||
statusPath: "/status"
|
||||
},
|
||||
{
|
||||
id: "gateway-simu-1",
|
||||
serviceId: "hwlab-gateway-simu",
|
||||
urlEnv: "HWLAB_DEV_GATEWAY_SIMU_1_URL",
|
||||
statusPath: "/status"
|
||||
},
|
||||
{
|
||||
id: "gateway-simu-2",
|
||||
serviceId: "hwlab-gateway-simu",
|
||||
urlEnv: "HWLAB_DEV_GATEWAY_SIMU_2_URL",
|
||||
statusPath: "/status"
|
||||
},
|
||||
{
|
||||
id: "patch-panel",
|
||||
serviceId: "hwlab-patch-panel",
|
||||
urlEnv: "HWLAB_DEV_PATCH_PANEL_URL",
|
||||
statusPath: "/status",
|
||||
wiringPath: "/wiring",
|
||||
routePath: "/signals/route"
|
||||
}
|
||||
]);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const flags = new Set();
|
||||
const values = new Map();
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (!arg.startsWith("--")) {
|
||||
throw new Error(`unexpected positional argument ${arg}`);
|
||||
}
|
||||
if (arg === "--output") {
|
||||
const value = argv[index + 1];
|
||||
assert.ok(value && !value.startsWith("--"), "--output requires a path");
|
||||
values.set("output", value);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
flags.add(arg);
|
||||
}
|
||||
|
||||
return { flags, values };
|
||||
}
|
||||
|
||||
function requireSafetyGates(flags) {
|
||||
const forbiddenFlags = [
|
||||
"--prod",
|
||||
"--real-hardware",
|
||||
"--read-secret",
|
||||
"--read-token",
|
||||
"--force-push",
|
||||
"--restart-runtime",
|
||||
"--restart-unidesk",
|
||||
"--restart-code-queue",
|
||||
"--restart-backend-core"
|
||||
];
|
||||
|
||||
for (const flag of forbiddenFlags) {
|
||||
assert.ok(!flags.has(flag), `${flag} is forbidden for DEV M3 smoke`);
|
||||
}
|
||||
|
||||
const live = flags.has("--live") || flags.has("--allow-live");
|
||||
assert.ok(live, "DEV M3 smoke requires --live or --allow-live");
|
||||
assert.ok(flags.has("--confirm-dev"), "live DEV smoke requires --confirm-dev");
|
||||
assert.ok(
|
||||
flags.has("--confirmed-non-production"),
|
||||
"live DEV smoke requires --confirmed-non-production"
|
||||
);
|
||||
}
|
||||
|
||||
function isoNow() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function relativePath(absolutePath) {
|
||||
return path.relative(repoRoot, absolutePath).replaceAll(path.sep, "/");
|
||||
}
|
||||
|
||||
function resolveReportPath(value) {
|
||||
const candidate = path.resolve(repoRoot, value ?? defaultReportPath);
|
||||
assert.ok(candidate.startsWith(`${repoRoot}${path.sep}`), "report path must stay inside repository");
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function currentCommit() {
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "--short=12", "HEAD"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
}).trim();
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function oneLine(value) {
|
||||
return String(value).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function commandResult(command, args, { timeoutMs = 5000, maxChars = 3000 } = {}) {
|
||||
try {
|
||||
const stdout = execFileSync(command, args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
timeout: timeoutMs,
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
});
|
||||
return {
|
||||
command: [command, ...args].join(" "),
|
||||
exitCode: 0,
|
||||
stdout: stdout.slice(0, maxChars)
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
command: [command, ...args].join(" "),
|
||||
exitCode: Number.isInteger(error.status) ? error.status : 1,
|
||||
stdout: String(error.stdout ?? "").slice(0, maxChars),
|
||||
stderr: String(error.stderr || error.message || "").slice(0, maxChars)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function commandExists(command) {
|
||||
const result = commandResult("bash", ["-lc", `command -v ${command}`], { timeoutMs: 2000 });
|
||||
return result.exitCode === 0 && result.stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
function collectLocalRuntimeObservations() {
|
||||
const observations = [];
|
||||
|
||||
if (commandExists("docker")) {
|
||||
const docker = commandResult("docker", [
|
||||
"ps",
|
||||
"--format",
|
||||
"{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"
|
||||
]);
|
||||
const hwlabLines = docker.stdout
|
||||
.split("\n")
|
||||
.filter((line) => line.includes("hwlab-"))
|
||||
.join("\n");
|
||||
observations.push({
|
||||
id: "docker-hwlab-containers",
|
||||
status: hwlabLines ? "observed" : "not_observed",
|
||||
command: docker.command,
|
||||
summary: oneLine(hwlabLines || "No running docker container name/image/port line contained hwlab-.")
|
||||
});
|
||||
} else {
|
||||
observations.push({
|
||||
id: "docker-hwlab-containers",
|
||||
status: "unavailable",
|
||||
command: "command -v docker",
|
||||
summary: "docker CLI is not available in this runner."
|
||||
});
|
||||
}
|
||||
|
||||
if (commandExists("kubectl")) {
|
||||
const kubectl = commandResult("kubectl", ["get", "deploy,po,svc", "-n", namespace, "-o", "wide"]);
|
||||
observations.push({
|
||||
id: "kubectl-hwlab-dev",
|
||||
status: kubectl.exitCode === 0 ? "observed" : "blocked",
|
||||
command: kubectl.command,
|
||||
summary: oneLine(kubectl.exitCode === 0 ? kubectl.stdout : kubectl.stderr || kubectl.stdout)
|
||||
});
|
||||
} else {
|
||||
observations.push({
|
||||
id: "kubectl-hwlab-dev",
|
||||
status: "unavailable",
|
||||
command: "command -v kubectl",
|
||||
summary: "kubectl is not available in this runner, so hwlab-dev namespace state was not observed."
|
||||
});
|
||||
}
|
||||
|
||||
return observations;
|
||||
}
|
||||
|
||||
async function probeJson(url, options = {}) {
|
||||
const method = options.method ?? "GET";
|
||||
const startedAt = isoNow();
|
||||
const timeoutMs = options.timeoutMs ?? 8000;
|
||||
const body = options.body ? JSON.stringify(options.body) : null;
|
||||
const headers = body
|
||||
? {
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(body)
|
||||
}
|
||||
: {};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const parsed = new URL(url);
|
||||
const requestFn = parsed.protocol === "https:" ? httpsRequest : httpRequest;
|
||||
const req = requestFn(
|
||||
parsed,
|
||||
{
|
||||
method,
|
||||
headers,
|
||||
timeout: timeoutMs
|
||||
},
|
||||
(response) => {
|
||||
let text = "";
|
||||
response.setEncoding("utf8");
|
||||
response.on("data", (chunk) => {
|
||||
text += chunk;
|
||||
});
|
||||
response.on("end", () => {
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
const status = response.statusCode ?? 0;
|
||||
resolve({
|
||||
url,
|
||||
method,
|
||||
startedAt,
|
||||
observedAt: isoNow(),
|
||||
reached: true,
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: response.statusMessage ?? "",
|
||||
json,
|
||||
bodyPreview: json ? undefined : text.slice(0, 500)
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on("timeout", () => {
|
||||
req.destroy(new Error(`request timed out after ${timeoutMs}ms`));
|
||||
});
|
||||
|
||||
req.on("error", (error) => {
|
||||
resolve({
|
||||
url,
|
||||
method,
|
||||
startedAt,
|
||||
observedAt: isoNow(),
|
||||
reached: false,
|
||||
ok: false,
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
code: error.code
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (body) {
|
||||
req.write(body);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function joinUrl(baseUrl, routePath) {
|
||||
return new URL(routePath, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
|
||||
}
|
||||
|
||||
function isHwlabDevIdentity(probe) {
|
||||
const body = probe.json;
|
||||
if (!probe.ok || !body || typeof body !== "object" || Array.isArray(body)) {
|
||||
return false;
|
||||
}
|
||||
if (typeof body.serviceId !== "string" || !body.serviceId.startsWith("hwlab-")) {
|
||||
return false;
|
||||
}
|
||||
return body.environment === undefined || body.environment === ENVIRONMENT_DEV;
|
||||
}
|
||||
|
||||
async function observeDevIngress() {
|
||||
const paths = ["/health/live", "/health", "/live", "/v1"];
|
||||
const probes = [];
|
||||
for (const routePath of paths) {
|
||||
probes.push(await probeJson(joinUrl(DEV_ENDPOINT, routePath)));
|
||||
}
|
||||
|
||||
const accepted = probes.find(isHwlabDevIdentity);
|
||||
return {
|
||||
accepted: Boolean(accepted),
|
||||
acceptedPath: accepted ? new URL(accepted.url).pathname : null,
|
||||
probes
|
||||
};
|
||||
}
|
||||
|
||||
function targetMapFromEnvironment() {
|
||||
const missing = [];
|
||||
const targets = new Map();
|
||||
|
||||
for (const target of serviceTargets) {
|
||||
const baseUrl = process.env[target.urlEnv];
|
||||
if (!baseUrl) {
|
||||
missing.push(target.urlEnv);
|
||||
continue;
|
||||
}
|
||||
targets.set(target.id, {
|
||||
...target,
|
||||
baseUrl
|
||||
});
|
||||
}
|
||||
|
||||
return { missing, targets };
|
||||
}
|
||||
|
||||
function assertBoxState(state, label) {
|
||||
assert.equal(state?.serviceId, "hwlab-box-simu", `${label} serviceId`);
|
||||
assert.equal(state.live, true, `${label} live`);
|
||||
assert.equal(state.resource?.environment ?? state.environment, ENVIRONMENT_DEV, `${label} environment`);
|
||||
assert.ok(state.ports?.DO1, `${label} requires DO1`);
|
||||
assert.ok(state.ports?.DI1, `${label} requires DI1`);
|
||||
assert.equal(state.constraints?.crossDevicePropagation, "patch-panel-only", `${label} propagation`);
|
||||
assert.equal(state.constraints?.localLoopbackEnabled, false, `${label} local loopback`);
|
||||
}
|
||||
|
||||
function assertGatewayState(state, label) {
|
||||
assert.equal(state?.serviceId, "hwlab-gateway-simu", `${label} serviceId`);
|
||||
assert.equal(state.live, true, `${label} live`);
|
||||
assert.equal(state.session?.status, "connected", `${label} session status`);
|
||||
assert.equal(state.session?.environment, ENVIRONMENT_DEV, `${label} environment`);
|
||||
assert.ok(Array.isArray(state.boxes) && state.boxes.length >= 1, `${label} boxes`);
|
||||
}
|
||||
|
||||
function assertPatchPanelStatus(state) {
|
||||
assert.equal(state?.serviceId, "hwlab-patch-panel", "patch-panel serviceId");
|
||||
assert.equal(state.state, "active", "patch-panel state");
|
||||
assert.equal(state.environment, ENVIRONMENT_DEV, "patch-panel environment");
|
||||
assert.ok(Array.isArray(state.activeConnections), "patch-panel activeConnections");
|
||||
}
|
||||
|
||||
function findM3Connection({ status, wiring, box1, box2 }) {
|
||||
const fromResourceId = box1.resource?.resourceId;
|
||||
const toResourceId = box2.resource?.resourceId;
|
||||
const activeConnections = status.activeConnections ?? [];
|
||||
const wiringConnections = wiring.connections ?? [];
|
||||
|
||||
const active = activeConnections.find(
|
||||
(connection) =>
|
||||
connection.fromResourceId === fromResourceId &&
|
||||
connection.fromPort === "DO1" &&
|
||||
connection.toResourceId === toResourceId &&
|
||||
connection.toPort === "DI1"
|
||||
);
|
||||
const configured = wiringConnections.find(
|
||||
(connection) =>
|
||||
connection.from?.resourceId === fromResourceId &&
|
||||
connection.from?.port === "DO1" &&
|
||||
connection.to?.resourceId === toResourceId &&
|
||||
connection.to?.port === "DI1" &&
|
||||
connection.mode === "exclusive"
|
||||
);
|
||||
|
||||
return { active, configured };
|
||||
}
|
||||
|
||||
async function runLiveM3Targets(targets) {
|
||||
const observed = {};
|
||||
const probes = [];
|
||||
|
||||
for (const [id, target] of targets.entries()) {
|
||||
const health = await probeJson(joinUrl(target.baseUrl, "/health/live"));
|
||||
const status = await probeJson(joinUrl(target.baseUrl, target.statusPath));
|
||||
probes.push({ id, kind: "health", probe: health }, { id, kind: "status", probe: status });
|
||||
assert.ok(health.ok, `${id} health failed`);
|
||||
assert.ok(status.ok, `${id} status failed`);
|
||||
assert.equal(health.json?.serviceId, target.serviceId, `${id} health serviceId`);
|
||||
observed[id] = status.json;
|
||||
}
|
||||
|
||||
assertBoxState(observed["box-simu-1"], "box-simu-1");
|
||||
assertBoxState(observed["box-simu-2"], "box-simu-2");
|
||||
assertGatewayState(observed["gateway-simu-1"], "gateway-simu-1");
|
||||
assertGatewayState(observed["gateway-simu-2"], "gateway-simu-2");
|
||||
assertPatchPanelStatus(observed["patch-panel"]);
|
||||
|
||||
const patchTarget = targets.get("patch-panel");
|
||||
const wiring = await probeJson(joinUrl(patchTarget.baseUrl, patchTarget.wiringPath));
|
||||
probes.push({ id: "patch-panel", kind: "wiring", probe: wiring });
|
||||
assert.ok(wiring.ok, "patch-panel wiring failed");
|
||||
assert.equal(wiring.json?.status, "active", "wiring status");
|
||||
assert.equal(wiring.json?.constraints?.propagation, "patch-panel-only", "wiring propagation");
|
||||
|
||||
const connection = findM3Connection({
|
||||
status: observed["patch-panel"],
|
||||
wiring: wiring.json,
|
||||
box1: observed["box-simu-1"],
|
||||
box2: observed["box-simu-2"]
|
||||
});
|
||||
assert.ok(connection.active, "patch-panel active DO1 -> DI1 connection missing");
|
||||
assert.ok(connection.configured, "wiring config DO1 -> DI1 connection missing");
|
||||
|
||||
const operationId = `op_dev_m3_${randomUUID()}`;
|
||||
const traceId = `trc_dev_m3_${randomUUID()}`;
|
||||
const box1Target = targets.get("box-simu-1");
|
||||
const box2Target = targets.get("box-simu-2");
|
||||
|
||||
const write = await probeJson(joinUrl(box1Target.baseUrl, "/ports/write"), {
|
||||
method: "POST",
|
||||
body: {
|
||||
port: "DO1",
|
||||
value: true,
|
||||
operationId,
|
||||
traceId
|
||||
}
|
||||
});
|
||||
probes.push({ id: "box-simu-1", kind: "do.write", probe: write });
|
||||
assert.ok(write.ok, "box-simu-1 DO1 write failed");
|
||||
assert.equal(write.json?.accepted, true, "box-simu-1 DO1 write accepted");
|
||||
|
||||
const route = await probeJson(joinUrl(patchTarget.baseUrl, patchTarget.routePath), {
|
||||
method: "POST",
|
||||
body: {
|
||||
fromResourceId: observed["box-simu-1"].resource.resourceId,
|
||||
fromPort: "DO1",
|
||||
value: true,
|
||||
operationId,
|
||||
traceId
|
||||
}
|
||||
});
|
||||
probes.push({ id: "patch-panel", kind: "signals.route", probe: route });
|
||||
assert.ok(route.ok, "patch-panel signal route failed");
|
||||
assert.equal(route.json?.accepted, true, "patch-panel signal route accepted");
|
||||
assert.equal(route.json?.deliveryCount, 1, "patch-panel signal route delivery count");
|
||||
|
||||
const after = await probeJson(joinUrl(box2Target.baseUrl, "/status"));
|
||||
probes.push({ id: "box-simu-2", kind: "di.read", probe: after });
|
||||
assert.ok(after.ok, "box-simu-2 DI1 read failed");
|
||||
assert.equal(after.json?.ports?.DI1?.value, true, "box-simu-2 DI1 must read true");
|
||||
|
||||
const auditId = route.json?.auditId ?? write.json?.auditId ?? after.json?.auditId;
|
||||
const evidenceId = route.json?.evidenceId ?? write.json?.evidenceId ?? after.json?.evidenceId;
|
||||
assert.equal(typeof auditId, "string", "live M3 result must include auditId");
|
||||
assert.equal(typeof evidenceId, "string", "live M3 result must include evidenceId");
|
||||
|
||||
return {
|
||||
operationId,
|
||||
traceId,
|
||||
auditId,
|
||||
evidenceId,
|
||||
probes,
|
||||
observed
|
||||
};
|
||||
}
|
||||
|
||||
function baseReport({ commitId, observedAt }) {
|
||||
return {
|
||||
$schema: "https://hwlab.pikastech.local/schemas/dev-m3-hardware-loop-report.schema.json",
|
||||
$id: "https://hwlab.pikastech.local/reports/dev-gate/dev-m3-hardware-loop.json",
|
||||
reportVersion: "v1",
|
||||
issue,
|
||||
taskId: "dev-m3-hardware-loop",
|
||||
commitId,
|
||||
acceptanceLevel: "dev_m3_hardware_loop",
|
||||
devOnly: true,
|
||||
prodDisabled: true,
|
||||
sourceContract: {
|
||||
status: "pass",
|
||||
documents: [
|
||||
"docs/dev-acceptance-matrix.md",
|
||||
"docs/m3-hardware-loop.md",
|
||||
"fixtures/mvp/m3-hardware-loop/topology.json"
|
||||
],
|
||||
summary: "Source contracts define DEV-only M3 simulator wiring and prohibit fixture output as live evidence."
|
||||
},
|
||||
validationCommands: [
|
||||
"node --check scripts/dev-m3-hardware-loop-smoke.mjs",
|
||||
"node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production",
|
||||
"node --check scripts/validate-dev-gate-report.mjs",
|
||||
"node scripts/validate-dev-gate-report.mjs"
|
||||
],
|
||||
runtimeTarget: {
|
||||
endpoint: DEV_ENDPOINT,
|
||||
namespace,
|
||||
environment: ENVIRONMENT_DEV,
|
||||
requiredBoxSimulators: 2,
|
||||
requiredGatewaySimulators: 2,
|
||||
realHardwareAllowed: false,
|
||||
prodAllowed: false
|
||||
},
|
||||
safetyGates: {
|
||||
liveFlagRequired: true,
|
||||
confirmDevRequired: true,
|
||||
confirmedNonProductionRequired: true,
|
||||
prodForbidden: true,
|
||||
realHardwareForbidden: true,
|
||||
secretReadForbidden: true,
|
||||
forcePushForbidden: true,
|
||||
unideskRuntimeSubstitutionForbidden: true
|
||||
},
|
||||
liveChecks: [
|
||||
{
|
||||
id: "static-contract-parse",
|
||||
status: "pass",
|
||||
summary: "DEV M3 smoke contract files were readable before live probing.",
|
||||
evidence: [
|
||||
"docs/dev-acceptance-matrix.md",
|
||||
"docs/m3-hardware-loop.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "endpoint-freeze",
|
||||
status: "pass",
|
||||
summary: `DEV endpoint is frozen at ${DEV_ENDPOINT}.`,
|
||||
evidence: [DEV_ENDPOINT]
|
||||
}
|
||||
],
|
||||
localRuntimeObservations: [],
|
||||
liveOperation: {
|
||||
status: "not_run",
|
||||
operationId: "not_observed",
|
||||
traceId: "not_observed",
|
||||
auditId: "not_observed",
|
||||
evidenceId: "not_observed",
|
||||
summary: "No live DEV operation has run yet."
|
||||
},
|
||||
blockers: [],
|
||||
summary: {
|
||||
status: "not_run",
|
||||
observedAt,
|
||||
result: "DEV M3 smoke has not completed."
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function addNotRunM3Checks(report, reason) {
|
||||
for (const id of [
|
||||
"two-box-simu-online",
|
||||
"two-gateway-simu-online",
|
||||
"patch-panel-healthy",
|
||||
"wiring-do1-di1-applied",
|
||||
"direct-call-do-write-di-read",
|
||||
"audit-evidence-traceable"
|
||||
]) {
|
||||
report.liveChecks.push({
|
||||
id,
|
||||
status: "not_run",
|
||||
summary: reason,
|
||||
evidence: ["No live DEV evidence collected for this check."]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureContractFiles() {
|
||||
await access(path.join(repoRoot, "docs/dev-acceptance-matrix.md"));
|
||||
await access(path.join(repoRoot, "docs/m3-hardware-loop.md"));
|
||||
await access(path.join(repoRoot, "fixtures/mvp/m3-hardware-loop/topology.json"));
|
||||
}
|
||||
|
||||
async function writeReport(report, reportPath) {
|
||||
await mkdir(path.dirname(reportPath), { recursive: true });
|
||||
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { flags, values } = parseArgs(process.argv.slice(2));
|
||||
requireSafetyGates(flags);
|
||||
await ensureContractFiles();
|
||||
|
||||
const reportPath = resolveReportPath(values.get("output"));
|
||||
const observedAt = isoNow();
|
||||
const report = baseReport({
|
||||
commitId: currentCommit(),
|
||||
observedAt
|
||||
});
|
||||
|
||||
const ingress = await observeDevIngress();
|
||||
report.localRuntimeObservations = collectLocalRuntimeObservations();
|
||||
report.liveChecks.push({
|
||||
id: "dev-ingress-health",
|
||||
status: ingress.accepted ? "pass" : "blocked",
|
||||
blockerClass: ingress.accepted ? undefined : "network_blocker",
|
||||
summary: ingress.accepted
|
||||
? `DEV ingress returned HWLAB identity at ${ingress.acceptedPath}.`
|
||||
: `DEV ingress at ${DEV_ENDPOINT} did not return reachable HWLAB DEV health.`,
|
||||
evidence: ingress.probes.map((probe) => JSON.stringify(probe))
|
||||
});
|
||||
|
||||
if (!ingress.accepted) {
|
||||
addNotRunM3Checks(report, "Stopped at DEV ingress per the DEV smoke matrix.");
|
||||
report.liveOperation = {
|
||||
status: "not_run",
|
||||
operationId: "not_observed",
|
||||
traceId: "not_observed",
|
||||
auditId: "not_observed",
|
||||
evidenceId: "not_observed",
|
||||
summary: "No live do.write -> di.read operation was attempted because DEV ingress is blocked."
|
||||
};
|
||||
report.blockers.push({
|
||||
type: "network_blocker",
|
||||
scope: "dev-ingress-health",
|
||||
status: "open",
|
||||
summary: "Blocked at the #33 DEV runtime readiness condition: public DEV ingress does not accept HWLAB health requests, so #36/M3 hardware-loop checks were not reached."
|
||||
});
|
||||
report.summary = {
|
||||
status: "blocked",
|
||||
observedAt,
|
||||
result: "No live DEV M3 evidence was produced; fixture-backed local evidence was not used as a substitute."
|
||||
};
|
||||
await writeReport(report, reportPath);
|
||||
console.log(`[dev-m3-smoke] status=blocked report=${relativePath(reportPath)}`);
|
||||
console.log("[dev-m3-smoke] blocker=network_blocker scope=dev-ingress-health");
|
||||
return;
|
||||
}
|
||||
|
||||
const { missing, targets } = targetMapFromEnvironment();
|
||||
if (missing.length > 0) {
|
||||
addNotRunM3Checks(report, "Stopped before M3 direct checks because service target URLs were not provided.");
|
||||
report.blockers.push({
|
||||
type: "runtime_blocker",
|
||||
scope: "m3-service-discovery",
|
||||
status: "open",
|
||||
summary: `DEV ingress is reachable, but M3 smoke lacks direct DEV service URLs: ${missing.join(", ")}.`
|
||||
});
|
||||
report.summary = {
|
||||
status: "blocked",
|
||||
observedAt,
|
||||
result: "DEV ingress was reachable, but live M3 simulator and patch-panel targets were not discoverable."
|
||||
};
|
||||
await writeReport(report, reportPath);
|
||||
console.log(`[dev-m3-smoke] status=blocked report=${relativePath(reportPath)}`);
|
||||
console.log("[dev-m3-smoke] blocker=runtime_blocker scope=m3-service-discovery");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const live = await runLiveM3Targets(targets);
|
||||
report.liveChecks.push(
|
||||
{
|
||||
id: "two-box-simu-online",
|
||||
status: "pass",
|
||||
summary: "Both DEV box simulators reported live status with DO1 and DI1 ports.",
|
||||
evidence: ["box-simu-1 /status", "box-simu-2 /status"]
|
||||
},
|
||||
{
|
||||
id: "two-gateway-simu-online",
|
||||
status: "pass",
|
||||
summary: "Both DEV gateway simulators reported connected sessions.",
|
||||
evidence: ["gateway-simu-1 /status", "gateway-simu-2 /status"]
|
||||
},
|
||||
{
|
||||
id: "patch-panel-healthy",
|
||||
status: "pass",
|
||||
summary: "DEV patch-panel reported active status.",
|
||||
evidence: ["patch-panel /status"]
|
||||
},
|
||||
{
|
||||
id: "wiring-do1-di1-applied",
|
||||
status: "pass",
|
||||
summary: "Patch-panel active wiring matched box-simu-1 DO1 -> box-simu-2 DI1.",
|
||||
evidence: ["patch-panel /wiring"]
|
||||
},
|
||||
{
|
||||
id: "direct-call-do-write-di-read",
|
||||
status: "pass",
|
||||
summary: "Live DEV direct call observed do.write true followed by di.read true.",
|
||||
evidence: [`operationId=${live.operationId}`, `traceId=${live.traceId}`]
|
||||
},
|
||||
{
|
||||
id: "audit-evidence-traceable",
|
||||
status: "pass",
|
||||
summary: "Live DEV direct call returned audit and evidence identifiers.",
|
||||
evidence: [`auditId=${live.auditId}`, `evidenceId=${live.evidenceId}`]
|
||||
}
|
||||
);
|
||||
report.liveOperation = {
|
||||
status: "pass",
|
||||
operationId: live.operationId,
|
||||
traceId: live.traceId,
|
||||
auditId: live.auditId,
|
||||
evidenceId: live.evidenceId,
|
||||
summary: "Live DEV M3 do.write true -> di.read true succeeded with traceable audit/evidence identifiers."
|
||||
};
|
||||
report.rawLiveProbeCount = live.probes.length;
|
||||
report.summary = {
|
||||
status: "pass",
|
||||
observedAt,
|
||||
result: "Live DEV M3 hardware loop passed without fixture substitution."
|
||||
};
|
||||
await writeReport(report, reportPath);
|
||||
console.log(`[dev-m3-smoke] status=pass report=${relativePath(reportPath)}`);
|
||||
console.log(
|
||||
`[dev-m3-smoke] operationId=${live.operationId} auditId=${live.auditId} evidenceId=${live.evidenceId}`
|
||||
);
|
||||
} catch (error) {
|
||||
addNotRunM3Checks(report, "A live DEV M3 runtime contract check failed before all checks passed.");
|
||||
report.blockers.push({
|
||||
type: "runtime_blocker",
|
||||
scope: "m3-hardware-loop-runtime",
|
||||
status: "open",
|
||||
summary: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
report.summary = {
|
||||
status: "blocked",
|
||||
observedAt,
|
||||
result: "Live DEV M3 runtime was reachable but did not satisfy the M3 hardware-loop contract."
|
||||
};
|
||||
await writeReport(report, reportPath);
|
||||
console.log(`[dev-m3-smoke] status=blocked report=${relativePath(reportPath)}`);
|
||||
console.log("[dev-m3-smoke] blocker=runtime_blocker scope=m3-hardware-loop-runtime");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error(`[dev-m3-smoke] ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -20,6 +20,15 @@ const requiredDocs = [
|
||||
"docs/dev-acceptance-matrix.md",
|
||||
"docs/m0-contract-audit.md"
|
||||
];
|
||||
const requiredDevM3Issue = "pikasTech/HWLAB#38";
|
||||
const requiredDevM3ValidationCommands = [
|
||||
"node --check scripts/dev-m3-hardware-loop-smoke.mjs",
|
||||
"node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"
|
||||
];
|
||||
const requiredDevM3Docs = [
|
||||
"docs/dev-acceptance-matrix.md",
|
||||
"docs/m3-hardware-loop.md"
|
||||
];
|
||||
|
||||
const statusValues = new Set(["pass", "blocked", "not_run", "not_applicable", "failed"]);
|
||||
const blockerTypes = new Set([
|
||||
@@ -52,6 +61,11 @@ function assertStatus(value, label) {
|
||||
assert.ok(statusValues.has(value), `${label} must be one of ${Array.from(statusValues).join(", ")}`);
|
||||
}
|
||||
|
||||
function assertTimestamp(value, label) {
|
||||
assertString(value, label);
|
||||
assert.ok(!Number.isNaN(Date.parse(value)), `${label} must be an RFC 3339 timestamp`);
|
||||
}
|
||||
|
||||
function assertRepoRelativePath(value, label) {
|
||||
assertString(value, label);
|
||||
assert.ok(!path.isAbsolute(value), `${label} must be repo-relative`);
|
||||
@@ -96,6 +110,11 @@ async function validateReport(relativePath) {
|
||||
const label = relativePath;
|
||||
|
||||
assertObject(report, label);
|
||||
if (report.issue === requiredDevM3Issue || report.taskId === "dev-m3-hardware-loop") {
|
||||
await validateDevM3Report(report, label);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const field of [
|
||||
"$schema",
|
||||
"$id",
|
||||
@@ -206,6 +225,149 @@ async function validateReport(relativePath) {
|
||||
}
|
||||
}
|
||||
|
||||
async function validateDevM3Report(report, label) {
|
||||
for (const field of [
|
||||
"$schema",
|
||||
"$id",
|
||||
"reportVersion",
|
||||
"issue",
|
||||
"taskId",
|
||||
"commitId",
|
||||
"acceptanceLevel",
|
||||
"devOnly",
|
||||
"prodDisabled",
|
||||
"sourceContract",
|
||||
"validationCommands",
|
||||
"runtimeTarget",
|
||||
"safetyGates",
|
||||
"liveChecks",
|
||||
"liveOperation",
|
||||
"blockers",
|
||||
"summary"
|
||||
]) {
|
||||
assert.ok(Object.hasOwn(report, field), `${label} missing ${field}`);
|
||||
}
|
||||
|
||||
assertString(report.$schema, `${label}.$schema`);
|
||||
assertString(report.$id, `${label}.$id`);
|
||||
assert.equal(report.reportVersion, "v1", `${label}.reportVersion`);
|
||||
assert.equal(report.issue, requiredDevM3Issue, `${label}.issue`);
|
||||
assert.equal(report.taskId, "dev-m3-hardware-loop", `${label}.taskId`);
|
||||
assert.match(report.commitId, /^([a-f0-9]{7,40}|unknown)$/, `${label}.commitId`);
|
||||
assert.equal(report.acceptanceLevel, "dev_m3_hardware_loop", `${label}.acceptanceLevel`);
|
||||
assert.equal(report.devOnly, true, `${label}.devOnly`);
|
||||
assert.equal(report.prodDisabled, true, `${label}.prodDisabled`);
|
||||
|
||||
assertObject(report.sourceContract, `${label}.sourceContract`);
|
||||
assertStatus(report.sourceContract.status, `${label}.sourceContract.status`);
|
||||
assertStringArray(report.sourceContract.documents, `${label}.sourceContract.documents`, {
|
||||
minLength: requiredDevM3Docs.length
|
||||
});
|
||||
assertString(report.sourceContract.summary, `${label}.sourceContract.summary`);
|
||||
const documents = new Set(report.sourceContract.documents);
|
||||
assertUnique(report.sourceContract.documents, `${label}.sourceContract.documents`);
|
||||
for (const requiredDoc of requiredDevM3Docs) {
|
||||
assert.ok(documents.has(requiredDoc), `${label}.sourceContract.documents missing ${requiredDoc}`);
|
||||
}
|
||||
for (const [index, documentPath] of report.sourceContract.documents.entries()) {
|
||||
const absolutePath = assertRepoRelativePath(
|
||||
documentPath,
|
||||
`${label}.sourceContract.documents[${index}]`
|
||||
);
|
||||
await access(absolutePath, fsConstants.R_OK);
|
||||
}
|
||||
|
||||
assertStringArray(report.validationCommands, `${label}.validationCommands`, { minLength: 2 });
|
||||
assertUnique(report.validationCommands, `${label}.validationCommands`);
|
||||
for (const requiredCommand of requiredDevM3ValidationCommands) {
|
||||
assert.ok(
|
||||
report.validationCommands.includes(requiredCommand),
|
||||
`${label}.validationCommands missing ${requiredCommand}`
|
||||
);
|
||||
}
|
||||
|
||||
assertObject(report.runtimeTarget, `${label}.runtimeTarget`);
|
||||
assert.equal(report.runtimeTarget.endpoint, "http://74.48.78.17:6667", `${label}.runtimeTarget.endpoint`);
|
||||
assert.equal(report.runtimeTarget.namespace, "hwlab-dev", `${label}.runtimeTarget.namespace`);
|
||||
assert.equal(report.runtimeTarget.environment, "dev", `${label}.runtimeTarget.environment`);
|
||||
assert.equal(report.runtimeTarget.requiredBoxSimulators, 2, `${label}.runtimeTarget.requiredBoxSimulators`);
|
||||
assert.equal(report.runtimeTarget.requiredGatewaySimulators, 2, `${label}.runtimeTarget.requiredGatewaySimulators`);
|
||||
assert.equal(report.runtimeTarget.realHardwareAllowed, false, `${label}.runtimeTarget.realHardwareAllowed`);
|
||||
assert.equal(report.runtimeTarget.prodAllowed, false, `${label}.runtimeTarget.prodAllowed`);
|
||||
|
||||
assertObject(report.safetyGates, `${label}.safetyGates`);
|
||||
for (const field of [
|
||||
"liveFlagRequired",
|
||||
"confirmDevRequired",
|
||||
"confirmedNonProductionRequired",
|
||||
"prodForbidden",
|
||||
"realHardwareForbidden",
|
||||
"secretReadForbidden",
|
||||
"forcePushForbidden",
|
||||
"unideskRuntimeSubstitutionForbidden"
|
||||
]) {
|
||||
assert.equal(report.safetyGates[field], true, `${label}.safetyGates.${field}`);
|
||||
}
|
||||
|
||||
assertArray(report.liveChecks, `${label}.liveChecks`);
|
||||
assert.ok(report.liveChecks.length >= 8, `${label}.liveChecks must include DEV M3 checks`);
|
||||
assertUnique(report.liveChecks.map((check) => check.id), `${label}.liveChecks`);
|
||||
for (const [index, check] of report.liveChecks.entries()) {
|
||||
const checkLabel = `${label}.liveChecks[${index}]`;
|
||||
assertObject(check, checkLabel);
|
||||
for (const field of ["id", "status", "summary", "evidence"]) {
|
||||
assert.ok(Object.hasOwn(check, field), `${checkLabel} missing ${field}`);
|
||||
}
|
||||
assertString(check.id, `${checkLabel}.id`);
|
||||
assertStatus(check.status, `${checkLabel}.status`);
|
||||
assertString(check.summary, `${checkLabel}.summary`);
|
||||
assertStringArray(check.evidence, `${checkLabel}.evidence`, { minLength: 1 });
|
||||
if (Object.hasOwn(check, "blockerClass")) {
|
||||
assert.ok(blockerTypes.has(check.blockerClass), `${checkLabel}.blockerClass must be known`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.hasOwn(report, "localRuntimeObservations")) {
|
||||
assertArray(report.localRuntimeObservations, `${label}.localRuntimeObservations`);
|
||||
for (const [index, observation] of report.localRuntimeObservations.entries()) {
|
||||
const observationLabel = `${label}.localRuntimeObservations[${index}]`;
|
||||
assertObject(observation, observationLabel);
|
||||
for (const field of ["id", "status", "command", "summary"]) {
|
||||
assert.ok(Object.hasOwn(observation, field), `${observationLabel} missing ${field}`);
|
||||
assertString(observation[field], `${observationLabel}.${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertObject(report.liveOperation, `${label}.liveOperation`);
|
||||
assertStatus(report.liveOperation.status, `${label}.liveOperation.status`);
|
||||
for (const field of ["operationId", "traceId", "auditId", "evidenceId", "summary"]) {
|
||||
assertString(report.liveOperation[field], `${label}.liveOperation.${field}`);
|
||||
}
|
||||
|
||||
assertArray(report.blockers, `${label}.blockers`);
|
||||
assertUnique(
|
||||
report.blockers.map((blocker) => `${blocker.type}::${blocker.scope}`),
|
||||
`${label}.blockers`
|
||||
);
|
||||
for (const [index, blocker] of report.blockers.entries()) {
|
||||
const blockerLabel = `${label}.blockers[${index}]`;
|
||||
assertObject(blocker, blockerLabel);
|
||||
for (const field of ["type", "scope", "status", "summary"]) {
|
||||
assert.ok(Object.hasOwn(blocker, field), `${blockerLabel} missing ${field}`);
|
||||
}
|
||||
assert.ok(blockerTypes.has(blocker.type), `${blockerLabel}.type must be a known blocker type`);
|
||||
assertString(blocker.scope, `${blockerLabel}.scope`);
|
||||
assert.ok(blockerStates.has(blocker.status), `${blockerLabel}.status must be open, acknowledged, or closed`);
|
||||
assertString(blocker.summary, `${blockerLabel}.summary`);
|
||||
}
|
||||
|
||||
assertObject(report.summary, `${label}.summary`);
|
||||
assertStatus(report.summary.status, `${label}.summary.status`);
|
||||
assertTimestamp(report.summary.observedAt, `${label}.summary.observedAt`);
|
||||
assertString(report.summary.result, `${label}.summary.result`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const reportFiles = await collectReportFiles();
|
||||
assert.ok(reportFiles.length >= 1, "expected at least one dev-gate report JSON file");
|
||||
|
||||
Reference in New Issue
Block a user