feat: add m5 mvp e2e dry run
This commit is contained in:
@@ -0,0 +1,632 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const planPath = "fixtures/mvp/m5-e2e/dry-run-plan.json";
|
||||
const checklistPath = "docs/dev-acceptance-checklist.json";
|
||||
const matrixPath = "docs/dev-acceptance-matrix.md";
|
||||
const runtimePath = "fixtures/mvp/runtime.json";
|
||||
const contractPath = "protocol/mvp-e2e-contract.md";
|
||||
const commonSchemaPath = "protocol/schemas/common.json";
|
||||
const DEV_ENDPOINT = "http://74.48.78.17:6667";
|
||||
const ENVIRONMENT_DEV = "dev";
|
||||
const JSON_RPC_VERSION = "2.0";
|
||||
let serviceIds = [];
|
||||
let serviceIdSet = new Set();
|
||||
|
||||
const requiredArtifactFields = [
|
||||
"serviceId",
|
||||
"commitId",
|
||||
"image",
|
||||
"tag",
|
||||
"digest",
|
||||
"buildSource",
|
||||
"deployEnv",
|
||||
"healthTimestamp"
|
||||
];
|
||||
|
||||
const requiredStepSequence = [
|
||||
"step-dev-health",
|
||||
"step-gateway-box-status",
|
||||
"step-wiring",
|
||||
"step-direct-hardware-call",
|
||||
"step-audit",
|
||||
"step-agent-session",
|
||||
"step-agent-hardware-call",
|
||||
"step-evidence",
|
||||
"step-cleanup"
|
||||
];
|
||||
|
||||
const requiredTraceStates = ["accepted", "dispatched", "executed", "completed"];
|
||||
|
||||
function readJSON(relativePath) {
|
||||
return readFile(path.join(repoRoot, relativePath), "utf8").then((raw) => JSON.parse(raw));
|
||||
}
|
||||
|
||||
function byKey(items, key) {
|
||||
const map = new Map();
|
||||
for (const item of items) {
|
||||
assert.ok(item && typeof item === "object", `${key} item must be an object`);
|
||||
assert.ok(item[key], `${key} item missing ${key}`);
|
||||
assert.equal(map.has(item[key]), false, `${key} ${item[key]} must be unique`);
|
||||
map.set(item[key], item);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function requireDevObject(label, object, environmentField = "environment") {
|
||||
assert.equal(object[environmentField], ENVIRONMENT_DEV, `${label} ${environmentField}`);
|
||||
}
|
||||
|
||||
function requireTimestamp(label, value) {
|
||||
assert.equal(typeof value, "string", `${label} timestamp must be a string`);
|
||||
assert.equal(Number.isNaN(Date.parse(value)), false, `${label} timestamp must parse`);
|
||||
}
|
||||
|
||||
function requireServiceId(label, serviceId) {
|
||||
assert.equal(serviceIdSet.has(serviceId), true, `${label} unknown serviceId ${serviceId}`);
|
||||
}
|
||||
|
||||
function assertArrayIncludesAll(label, actual, expected) {
|
||||
assert.ok(Array.isArray(actual), `${label} must be an array`);
|
||||
for (const item of expected) {
|
||||
assert.equal(actual.includes(item), true, `${label} missing ${item}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoProdTarget(value, trail = "plan") {
|
||||
if (typeof value === "string") {
|
||||
assert.equal(value.includes(":6666"), false, `${trail} must not target PROD port`);
|
||||
assert.equal(/\bprod\b/i.test(value), false, `${trail} must not target PROD`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => assertNoProdTarget(item, `${trail}[${index}]`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if (
|
||||
key === "prohibitedActions" ||
|
||||
key === "failureCriteria" ||
|
||||
key === "failRule" ||
|
||||
key === "digest" ||
|
||||
key === "sha256"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
assertNoProdTarget(nested, `${trail}.${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertObjectHasOnlyDryRunSafety(plan) {
|
||||
assert.equal(plan.mode, "dry-run", "plan mode");
|
||||
assert.equal(plan.safety.allowNetwork, false, "network must be disabled");
|
||||
assert.equal(plan.safety.allowDeploy, false, "deploy must be disabled");
|
||||
assert.equal(plan.safety.allowBrowserE2E, false, "browser e2e must be disabled");
|
||||
assert.equal(plan.safety.allowSecrets, false, "secret reads must be disabled");
|
||||
assertArrayIncludesAll("prohibited actions", plan.safety.prohibitedActions, [
|
||||
"real-dev-deploy",
|
||||
"prod-deploy",
|
||||
"prod-smoke",
|
||||
"browser-e2e",
|
||||
"heavyweight-e2e",
|
||||
"secret-or-token-read",
|
||||
"force-push",
|
||||
"unidesk-runtime-substitution"
|
||||
]);
|
||||
}
|
||||
|
||||
function validateMeta(meta) {
|
||||
assert.ok(meta && typeof meta === "object" && !Array.isArray(meta), "meta must be an object");
|
||||
assert.ok(meta.traceId, "meta.traceId is required");
|
||||
requireServiceId("meta.serviceId", meta.serviceId);
|
||||
assert.equal(meta.environment, ENVIRONMENT_DEV, "meta.environment");
|
||||
}
|
||||
|
||||
function validateRequest(envelope) {
|
||||
assert.ok(envelope && typeof envelope === "object" && !Array.isArray(envelope), "request must be an object");
|
||||
assert.equal(envelope.jsonrpc, JSON_RPC_VERSION, "request jsonrpc");
|
||||
assert.notEqual(envelope.id, undefined, "request id");
|
||||
assert.match(envelope.method, /^[a-z][a-z0-9]*(\.[a-z][a-z0-9_]*)+$/, "request method");
|
||||
if (Object.hasOwn(envelope, "params")) {
|
||||
assert.ok(
|
||||
envelope.params && typeof envelope.params === "object" && !Array.isArray(envelope.params),
|
||||
"request params must be an object"
|
||||
);
|
||||
}
|
||||
validateMeta(envelope.meta);
|
||||
}
|
||||
|
||||
function validateResponse(envelope) {
|
||||
assert.ok(envelope && typeof envelope === "object" && !Array.isArray(envelope), "response must be an object");
|
||||
assert.equal(envelope.jsonrpc, JSON_RPC_VERSION, "response jsonrpc");
|
||||
assert.notEqual(envelope.id, undefined, "response id");
|
||||
const hasResult = Object.hasOwn(envelope, "result");
|
||||
const hasError = Object.hasOwn(envelope, "error");
|
||||
assert.notEqual(hasResult, hasError, "response must contain exactly one of result or error");
|
||||
if (hasError) {
|
||||
assert.ok(envelope.error && typeof envelope.error === "object", "response error must be an object");
|
||||
assert.equal(Number.isInteger(envelope.error.code), true, "response error code");
|
||||
assert.ok(envelope.error.message, "response error message");
|
||||
}
|
||||
validateMeta(envelope.meta);
|
||||
}
|
||||
|
||||
function assertEndpointFreeze(plan, checklist, runtime, contract) {
|
||||
assert.equal(plan.environment, ENVIRONMENT_DEV, "plan environment");
|
||||
assert.equal(plan.endpoint, DEV_ENDPOINT, "plan endpoint");
|
||||
assert.equal(checklist.environment, ENVIRONMENT_DEV, "checklist environment");
|
||||
assert.equal(checklist.endpoint, DEV_ENDPOINT, "checklist endpoint");
|
||||
assert.equal(runtime.endpoints.dev, DEV_ENDPOINT, "runtime fixture endpoint");
|
||||
assert.equal(contract.includes(DEV_ENDPOINT), true, "MVP contract must include DEV endpoint");
|
||||
assertNoProdTarget(plan);
|
||||
}
|
||||
|
||||
function assertArtifacts(plan, checklist) {
|
||||
assert.deepEqual(checklist.artifactObservabilityFields, requiredArtifactFields);
|
||||
const artifacts = byKey(plan.artifacts, "serviceId");
|
||||
const requiredServiceIds = new Set([
|
||||
"hwlab-edge-proxy",
|
||||
"hwlab-tunnel-client",
|
||||
"hwlab-router",
|
||||
"hwlab-cloud-api",
|
||||
"hwlab-cloud-web",
|
||||
"hwlab-gateway",
|
||||
"hwlab-gateway-simu",
|
||||
"hwlab-box-simu",
|
||||
"hwlab-patch-panel",
|
||||
"hwlab-agent-mgr",
|
||||
"hwlab-agent-worker",
|
||||
"hwlab-agent-skills",
|
||||
"hwlab-cli"
|
||||
]);
|
||||
|
||||
for (const serviceId of requiredServiceIds) {
|
||||
assert.equal(artifacts.has(serviceId), true, `artifact missing ${serviceId}`);
|
||||
}
|
||||
|
||||
for (const artifact of artifacts.values()) {
|
||||
requireServiceId(`artifact ${artifact.serviceId}`, artifact.serviceId);
|
||||
for (const field of requiredArtifactFields) {
|
||||
assert.ok(artifact[field], `artifact ${artifact.serviceId} missing ${field}`);
|
||||
}
|
||||
assert.equal(artifact.deployEnv, ENVIRONMENT_DEV, `artifact ${artifact.serviceId} deployEnv`);
|
||||
requireTimestamp(`artifact ${artifact.serviceId}.healthTimestamp`, artifact.healthTimestamp);
|
||||
if (artifact.digest === "not_applicable") {
|
||||
assert.ok(
|
||||
artifact.digestNotApplicableReason,
|
||||
`artifact ${artifact.serviceId} must explain not_applicable digest`
|
||||
);
|
||||
} else {
|
||||
assert.match(artifact.digest, /^sha256:[a-f0-9]{64}$/, `artifact ${artifact.serviceId} digest`);
|
||||
}
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
function assertHealth(plan, checklist, artifacts) {
|
||||
const healthById = byKey(plan.health, "healthId");
|
||||
const healthByComponent = byKey(plan.health, "component");
|
||||
const healthByService = new Map();
|
||||
for (const health of plan.health) {
|
||||
if (!healthByService.has(health.serviceId)) {
|
||||
healthByService.set(health.serviceId, health);
|
||||
}
|
||||
}
|
||||
|
||||
for (const contract of checklist.healthContracts) {
|
||||
const health = healthByComponent.get(contract.component);
|
||||
assert.ok(health, `health missing component ${contract.component}`);
|
||||
assert.equal(health.serviceId, contract.serviceId, `health ${contract.component} serviceId`);
|
||||
assert.equal(artifacts.has(health.serviceId), true, `health ${contract.component} missing artifact`);
|
||||
assert.equal(health.status, "healthy", `health ${contract.component} status`);
|
||||
assert.equal(health.deployEnv, ENVIRONMENT_DEV, `health ${contract.component} deployEnv`);
|
||||
assert.equal(health.dryRun, true, `health ${contract.component} dryRun`);
|
||||
requireTimestamp(`health ${contract.component}`, health.healthTimestamp);
|
||||
}
|
||||
|
||||
assert.equal(healthByService.get("hwlab-edge-proxy").endpoint, `${DEV_ENDPOINT}/health`);
|
||||
assert.equal(healthByService.get("hwlab-cloud-web").output.directHardwareCall, false);
|
||||
assert.equal(healthByService.get("hwlab-gateway").output.patchPanelRequired, true);
|
||||
assert.equal(healthByService.get("hwlab-box-simu").output.patchPanelOnlyPropagation, true);
|
||||
assert.equal(healthByService.get("hwlab-agent-mgr").output.workerLeak, false);
|
||||
|
||||
return { byId: healthById, byService: healthByService, count: plan.health.length };
|
||||
}
|
||||
|
||||
function assertAcceptanceSteps(plan, checklist) {
|
||||
const checklistStepIds = new Set(checklist.smokeSteps.map((step) => step.id));
|
||||
assert.deepEqual(
|
||||
checklist.smokeSteps.map((step) => step.order),
|
||||
checklist.smokeSteps.map((_, index) => index + 1),
|
||||
"checklist smoke steps must be ordered"
|
||||
);
|
||||
|
||||
const referencedStepIds = new Set();
|
||||
for (const step of plan.steps) {
|
||||
for (const acceptanceStepId of step.acceptanceStepIds) {
|
||||
assert.equal(
|
||||
checklistStepIds.has(acceptanceStepId),
|
||||
true,
|
||||
`${step.id} references unknown acceptance step ${acceptanceStepId}`
|
||||
);
|
||||
referencedStepIds.add(acceptanceStepId);
|
||||
}
|
||||
}
|
||||
|
||||
assertArrayIncludesAll("dry-run acceptance coverage", [...referencedStepIds], [
|
||||
"static-contract-parse",
|
||||
"endpoint-freeze",
|
||||
"dev-ingress-health",
|
||||
"edge-route",
|
||||
"frp-tunnel",
|
||||
"d601-router",
|
||||
"cloud-surface",
|
||||
"gateway-sim-patch-panel",
|
||||
"agent-runtime",
|
||||
"artifact-observability"
|
||||
]);
|
||||
}
|
||||
|
||||
function assertStepGraph(plan) {
|
||||
const steps = plan.steps.slice().sort((a, b) => a.order - b.order);
|
||||
assert.deepEqual(
|
||||
steps.map((step) => step.id),
|
||||
requiredStepSequence,
|
||||
"M5 sequence must cover required dry-run flow"
|
||||
);
|
||||
assert.deepEqual(plan.expectedSequence, requiredStepSequence, "expectedSequence must match required sequence");
|
||||
|
||||
const seen = new Set();
|
||||
for (const step of steps) {
|
||||
assert.equal(seen.has(step.id), false, `duplicate step ${step.id}`);
|
||||
assert.ok(Array.isArray(step.inputs) && step.inputs.length > 0, `${step.id} inputs`);
|
||||
assert.ok(Array.isArray(step.outputs) && step.outputs.length > 0, `${step.id} outputs`);
|
||||
for (const dependency of step.requires) {
|
||||
assert.equal(seen.has(dependency), true, `${step.id} requires ${dependency} before it is produced`);
|
||||
}
|
||||
seen.add(step.id);
|
||||
}
|
||||
}
|
||||
|
||||
function assertProjectGatewayAndBoxes(plan) {
|
||||
requireDevObject("project", plan.project);
|
||||
assert.equal(plan.project.status, "active", "project status");
|
||||
|
||||
const gatewaySessions = byKey(plan.gatewaySessions, "gatewaySessionId");
|
||||
assert.equal(gatewaySessions.has("gws_m5-0001"), true, "real gateway boundary session is required");
|
||||
assert.equal(gatewaySessions.has("gws_m5-simu-0001"), true, "gateway simulator session is required");
|
||||
|
||||
for (const session of gatewaySessions.values()) {
|
||||
assert.equal(session.projectId, plan.project.projectId, `gateway ${session.gatewaySessionId} project`);
|
||||
assert.ok(["hwlab-gateway", "hwlab-gateway-simu"].includes(session.serviceId), "gateway serviceId");
|
||||
assert.equal(session.status, "connected", `gateway ${session.gatewaySessionId} status`);
|
||||
requireDevObject(`gateway ${session.gatewaySessionId}`, session);
|
||||
}
|
||||
|
||||
const resources = byKey(plan.boxResources, "resourceId");
|
||||
const capabilities = byKey(plan.boxCapabilities, "capabilityId");
|
||||
|
||||
for (const resource of resources.values()) {
|
||||
assert.equal(resource.projectId, plan.project.projectId, `resource ${resource.resourceId} project`);
|
||||
assert.equal(gatewaySessions.has(resource.gatewaySessionId), true, `resource ${resource.resourceId} gateway`);
|
||||
assert.equal(resource.state, "available", `resource ${resource.resourceId} state`);
|
||||
requireDevObject(`resource ${resource.resourceId}`, resource);
|
||||
}
|
||||
|
||||
for (const capability of capabilities.values()) {
|
||||
assert.equal(capability.projectId, plan.project.projectId, `capability ${capability.capabilityId} project`);
|
||||
assert.equal(resources.has(capability.resourceId), true, `capability ${capability.capabilityId} resource`);
|
||||
assert.match(capability.name, /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/);
|
||||
}
|
||||
|
||||
return { gatewaySessions, resources, capabilities };
|
||||
}
|
||||
|
||||
function assertWiring(plan, { gatewaySessions, resources, capabilities }) {
|
||||
const wiring = plan.wiringConfig;
|
||||
assert.equal(wiring.projectId, plan.project.projectId, "wiring project");
|
||||
assert.equal(gatewaySessions.has(wiring.gatewaySessionId), true, "wiring gateway");
|
||||
assert.equal(wiring.status, "active", "wiring status");
|
||||
assert.equal(wiring.constraints.patchPanelOwnsRouting, true, "wiring must be patch-panel-owned");
|
||||
assert.equal(wiring.constraints.directBoxBypassAllowed, false, "wiring must reject direct box bypass");
|
||||
|
||||
for (const connection of wiring.connections) {
|
||||
for (const side of ["from", "to"]) {
|
||||
const endpoint = connection[side];
|
||||
assert.equal(resources.has(endpoint.resourceId), true, `wiring ${side} resource`);
|
||||
assert.equal(capabilities.has(endpoint.capabilityId), true, `wiring ${side} capability`);
|
||||
assert.equal(
|
||||
capabilities.get(endpoint.capabilityId).resourceId,
|
||||
endpoint.resourceId,
|
||||
`wiring ${side} capability must belong to resource`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const patch = plan.patchPanelStatus;
|
||||
assert.equal(patch.projectId, plan.project.projectId, "patch project");
|
||||
assert.equal(patch.gatewaySessionId, wiring.gatewaySessionId, "patch gateway");
|
||||
assert.equal(patch.wiringConfigId, wiring.wiringConfigId, "patch wiring");
|
||||
assert.equal(patch.serviceId, "hwlab-patch-panel", "patch service");
|
||||
assert.equal(patch.state, "active", "patch state");
|
||||
requireDevObject("patch", patch);
|
||||
requireTimestamp("patch observedAt", patch.observedAt);
|
||||
|
||||
const activeConnectionKeys = new Set(
|
||||
patch.activeConnections.map(
|
||||
(connection) =>
|
||||
`${connection.fromResourceId}:${connection.fromPort}->${connection.toResourceId}:${connection.toPort}`
|
||||
)
|
||||
);
|
||||
for (const connection of wiring.connections) {
|
||||
const key = `${connection.from.resourceId}:${connection.from.port}->${connection.to.resourceId}:${connection.to.port}`;
|
||||
assert.equal(activeConnectionKeys.has(key), true, `patch panel missing active connection ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertRpcAndOperations(plan, { gatewaySessions, resources, capabilities }) {
|
||||
const operations = byKey(plan.operations, "operationId");
|
||||
const rpcByName = byKey(plan.rpc, "name");
|
||||
|
||||
for (const rpc of rpcByName.values()) {
|
||||
validateRequest(rpc.request);
|
||||
validateResponse(rpc.response);
|
||||
assert.equal(rpc.request.meta.environment, ENVIRONMENT_DEV, `${rpc.name} request env`);
|
||||
assert.equal(rpc.response.meta.environment, ENVIRONMENT_DEV, `${rpc.name} response env`);
|
||||
assert.equal(rpc.request.meta.traceId, rpc.response.meta.traceId, `${rpc.name} trace continuity`);
|
||||
assert.equal(rpc.request.params.dryRun, undefined, `${rpc.name} params must not use top-level dryRun`);
|
||||
assert.equal(rpc.request.params.input.dryRun, true, `${rpc.name} input dryRun`);
|
||||
assert.equal(rpc.response.result.operationId, rpc.operationId, `${rpc.name} response operation`);
|
||||
assert.equal(operations.has(rpc.operationId), true, `${rpc.name} operation exists`);
|
||||
}
|
||||
|
||||
assert.equal(rpcByName.has("direct-hardware-request"), true, "direct RPC is required");
|
||||
assert.equal(rpcByName.has("agent-hardware-request"), true, "agent RPC is required");
|
||||
|
||||
for (const operation of operations.values()) {
|
||||
assert.equal(operation.projectId, plan.project.projectId, `operation ${operation.operationId} project`);
|
||||
assert.equal(gatewaySessions.has(operation.gatewaySessionId), true, `operation ${operation.operationId} gateway`);
|
||||
assert.equal(resources.has(operation.resourceId), true, `operation ${operation.operationId} resource`);
|
||||
assert.equal(capabilities.has(operation.capabilityId), true, `operation ${operation.operationId} capability`);
|
||||
assert.equal(operation.status, "succeeded", `operation ${operation.operationId} status`);
|
||||
assert.equal(operation.input.dryRun, true, `operation ${operation.operationId} dryRun`);
|
||||
assert.equal(operation.output.patchPanelStatusId, plan.patchPanelStatus.patchPanelStatusId);
|
||||
requireDevObject(`operation ${operation.operationId}`, operation);
|
||||
}
|
||||
|
||||
const agentOperation = operations.get("op_m5-agent-0001");
|
||||
assert.equal(agentOperation.agentSessionId, plan.agentSession.agentSessionId, "agent operation session");
|
||||
assert.equal(agentOperation.workerSessionId, plan.workerSession.workerSessionId, "agent operation worker");
|
||||
|
||||
return { operations, rpcByName };
|
||||
}
|
||||
|
||||
function assertAgent(plan, { gatewaySessions }) {
|
||||
const { agentSession, workerSession } = plan;
|
||||
assert.equal(agentSession.projectId, plan.project.projectId, "agent project");
|
||||
assert.equal(agentSession.serviceId, "hwlab-agent-mgr", "agent service");
|
||||
assert.equal(agentSession.status, "completed", "agent status");
|
||||
assert.equal(agentSession.metadata.workerSessionId, workerSession.workerSessionId, "agent worker link");
|
||||
requireDevObject("agent", agentSession);
|
||||
|
||||
assert.equal(workerSession.agentSessionId, agentSession.agentSessionId, "worker agent link");
|
||||
assert.equal(workerSession.projectId, plan.project.projectId, "worker project");
|
||||
assert.equal(workerSession.serviceId, "hwlab-agent-worker", "worker service");
|
||||
assert.equal(workerSession.status, "completed", "worker status");
|
||||
assert.equal(gatewaySessions.has(workerSession.gatewaySessionId), true, "worker gateway");
|
||||
assert.equal(workerSession.metadata.scopedProjectId, plan.project.projectId, "worker project scope");
|
||||
requireDevObject("worker", workerSession);
|
||||
}
|
||||
|
||||
function assertTraceAuditEvidenceAndCleanup(plan, { operations }) {
|
||||
const traceEvents = byKey(plan.traceEvents, "traceEventId");
|
||||
const auditEvents = byKey(plan.auditEvents, "auditId");
|
||||
const evidenceRecords = byKey(plan.evidenceRecords, "evidenceId");
|
||||
const cleanupRecords = byKey(plan.cleanup, "cleanupId");
|
||||
|
||||
for (const trace of traceEvents.values()) {
|
||||
assert.equal(trace.projectId, plan.project.projectId, `trace ${trace.traceEventId} project`);
|
||||
requireServiceId(`trace ${trace.traceEventId}`, trace.serviceId);
|
||||
requireDevObject(`trace ${trace.traceEventId}`, trace);
|
||||
requireTimestamp(`trace ${trace.traceEventId}`, trace.occurredAt);
|
||||
if (trace.operationId) {
|
||||
assert.equal(operations.has(trace.operationId), true, `trace ${trace.traceEventId} operation`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const operationId of ["op_m5-direct-0001", "op_m5-agent-0001"]) {
|
||||
const states = plan.traceEvents
|
||||
.filter((trace) => trace.operationId === operationId)
|
||||
.map((trace) => trace.metadata.state);
|
||||
assert.deepEqual(states, requiredTraceStates, `${operationId} trace lifecycle`);
|
||||
}
|
||||
|
||||
for (const audit of auditEvents.values()) {
|
||||
assert.equal(audit.projectId, plan.project.projectId, `audit ${audit.auditId} project`);
|
||||
requireServiceId(`audit ${audit.auditId}`, audit.serviceId);
|
||||
requireDevObject(`audit ${audit.auditId}`, audit);
|
||||
requireTimestamp(`audit ${audit.auditId}`, audit.occurredAt);
|
||||
assert.ok(audit.outcome, `audit ${audit.auditId} outcome`);
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
[...auditEvents.values()].some((audit) => audit.action === "hardware.operation.request"),
|
||||
true,
|
||||
"hardware operation audit required"
|
||||
);
|
||||
assert.equal(auditEvents.has("aud_m5-cleanup"), true, "cleanup audit required");
|
||||
|
||||
for (const evidence of evidenceRecords.values()) {
|
||||
assert.equal(evidence.projectId, plan.project.projectId, `evidence ${evidence.evidenceId} project`);
|
||||
assert.equal(operations.has(evidence.operationId), true, `evidence ${evidence.evidenceId} operation`);
|
||||
requireServiceId(`evidence ${evidence.evidenceId}`, evidence.serviceId);
|
||||
requireDevObject(`evidence ${evidence.evidenceId}`, evidence);
|
||||
requireTimestamp(`evidence ${evidence.evidenceId}`, evidence.createdAt);
|
||||
}
|
||||
|
||||
for (const operationId of operations.keys()) {
|
||||
assert.equal(
|
||||
[...evidenceRecords.values()].some((evidence) => evidence.operationId === operationId),
|
||||
true,
|
||||
`evidence missing for ${operationId}`
|
||||
);
|
||||
}
|
||||
|
||||
for (const cleanup of cleanupRecords.values()) {
|
||||
assert.equal(cleanup.status, "completed", `cleanup ${cleanup.cleanupId} status`);
|
||||
assert.equal(cleanup.environment, ENVIRONMENT_DEV, `cleanup ${cleanup.cleanupId} environment`);
|
||||
requireServiceId(`cleanup ${cleanup.cleanupId}`, cleanup.serviceId);
|
||||
requireTimestamp(`cleanup ${cleanup.cleanupId}`, cleanup.completedAt);
|
||||
}
|
||||
|
||||
assert.equal(cleanupRecords.get("cln_m5-worker").outputs.workspaceReleased, true, "worker cleanup release");
|
||||
assert.equal(cleanupRecords.get("cln_m5-gateway-state").outputs.transientStateCleared, true);
|
||||
|
||||
return { traceEvents, auditEvents, evidenceRecords, cleanupRecords };
|
||||
}
|
||||
|
||||
async function assertEvidenceFiles(evidenceRecords) {
|
||||
for (const evidence of evidenceRecords.values()) {
|
||||
const absolutePath = path.join(repoRoot, evidence.uri);
|
||||
const content = await readFile(absolutePath);
|
||||
const digest = crypto.createHash("sha256").update(content).digest("hex");
|
||||
const stats = await stat(absolutePath);
|
||||
assert.equal(digest, evidence.sha256, `evidence ${evidence.evidenceId} sha256`);
|
||||
assert.equal(stats.size, evidence.sizeBytes, `evidence ${evidence.evidenceId} sizeBytes`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertStepInputsOutputs(plan, indexes) {
|
||||
const resolvers = {
|
||||
health: (id) => indexes.healthIds.has(id),
|
||||
gatewaySession: (id) => indexes.gatewaySessions.has(id),
|
||||
boxResource: (id) => indexes.resources.has(id),
|
||||
resource: (id) => indexes.resources.has(id),
|
||||
capability: (id) => indexes.capabilities.has(id),
|
||||
wiringConfig: (id) => id === plan.wiringConfig.wiringConfigId,
|
||||
patchPanelStatus: (id) => id === plan.patchPanelStatus.patchPanelStatusId,
|
||||
rpc: (id) => indexes.rpcByName.has(id),
|
||||
operation: (id) => indexes.operations.has(id),
|
||||
audit: (id) => indexes.auditEvents.has(id),
|
||||
agentSession: (id) => id === plan.agentSession.agentSessionId,
|
||||
workerSession: (id) => id === plan.workerSession.workerSessionId,
|
||||
trace: (id) => indexes.traceIds.has(id),
|
||||
evidence: (id) => indexes.evidenceRecords.has(id),
|
||||
cleanup: (id) => indexes.cleanupRecords.has(id)
|
||||
};
|
||||
|
||||
for (const step of plan.steps) {
|
||||
for (const input of step.inputs) {
|
||||
if (input === "endpoint") {
|
||||
continue;
|
||||
}
|
||||
const [type, id] = input.split(":");
|
||||
assert.ok(type && id, `${step.id} input ${input} must be typed`);
|
||||
assert.ok(resolvers[type], `${step.id} input ${input} has unknown type ${type}`);
|
||||
assert.equal(resolvers[type](id), true, `${step.id} input ${input} does not resolve`);
|
||||
}
|
||||
}
|
||||
|
||||
const allOutputs = new Set([
|
||||
"route_m5-dev",
|
||||
"tun_m5-d601",
|
||||
"hwlab-dev",
|
||||
"hwlab-cloud-api",
|
||||
"hwlab-cloud-web",
|
||||
...indexes.gatewaySessions.keys(),
|
||||
...indexes.resources.keys(),
|
||||
...indexes.capabilities.keys(),
|
||||
plan.wiringConfig.wiringConfigId,
|
||||
plan.patchPanelStatus.patchPanelStatusId,
|
||||
...indexes.operations.keys(),
|
||||
...indexes.traceIds,
|
||||
...indexes.auditEvents.keys(),
|
||||
plan.agentSession.agentSessionId,
|
||||
plan.workerSession.workerSessionId,
|
||||
...indexes.evidenceRecords.keys(),
|
||||
...indexes.cleanupRecords.keys()
|
||||
]);
|
||||
|
||||
for (const step of plan.steps) {
|
||||
for (const output of step.outputs) {
|
||||
assert.equal(allOutputs.has(output), true, `${step.id} output ${output} does not resolve`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertDependencies(plan) {
|
||||
assertArrayIncludesAll(
|
||||
"M0-M4 dependencies",
|
||||
plan.m0ToM4Dependencies.map((dependency) => dependency.id),
|
||||
["m0-contract-audit", "m1-local-smoke", "m2-cloud-core", "m3-hardware-loop", "m4-agent-runtime"]
|
||||
);
|
||||
|
||||
const dependencyStepIds = new Set(plan.steps.map((step) => step.id));
|
||||
for (const dependency of plan.m0ToM4Dependencies) {
|
||||
assert.ok(dependency.provides.length > 0, `${dependency.id} provides`);
|
||||
assert.equal(dependency.status, "contract_only", `${dependency.id} status`);
|
||||
for (const stepId of dependency.requiredFor) {
|
||||
assert.equal(dependencyStepIds.has(stepId), true, `${dependency.id} references unknown ${stepId}`);
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(plan.realDevGate.command, "hwlab-cli test e2e --env dev --mvp");
|
||||
assert.equal(plan.realDevGate.requiresAllBlockersClear, true);
|
||||
assert.equal(plan.realDevGate.requiresDryRunPass, true);
|
||||
assert.equal(plan.realDevGate.requiresHumanApprovalForRealDev, true);
|
||||
}
|
||||
|
||||
const [plan, checklist, runtime, contract, matrix, commonSchema] = await Promise.all([
|
||||
readJSON(planPath),
|
||||
readJSON(checklistPath),
|
||||
readJSON(runtimePath),
|
||||
readFile(path.join(repoRoot, contractPath), "utf8"),
|
||||
readFile(path.join(repoRoot, matrixPath), "utf8"),
|
||||
readJSON(commonSchemaPath)
|
||||
]);
|
||||
|
||||
serviceIds = commonSchema.$defs.serviceId.enum;
|
||||
serviceIdSet = new Set(serviceIds);
|
||||
|
||||
assert.equal(matrix.includes("HWLAB DEV Acceptance Matrix"), true, "acceptance matrix exists");
|
||||
assertObjectHasOnlyDryRunSafety(plan);
|
||||
assertEndpointFreeze(plan, checklist, runtime, contract);
|
||||
const artifacts = assertArtifacts(plan, checklist);
|
||||
const health = assertHealth(plan, checklist, artifacts);
|
||||
assertAcceptanceSteps(plan, checklist);
|
||||
assertStepGraph(plan);
|
||||
const topology = assertProjectGatewayAndBoxes(plan);
|
||||
assertWiring(plan, topology);
|
||||
assertAgent(plan, topology);
|
||||
const rpcAndOperations = assertRpcAndOperations(plan, topology);
|
||||
const evidenceIndexes = assertTraceAuditEvidenceAndCleanup(plan, rpcAndOperations);
|
||||
await assertEvidenceFiles(evidenceIndexes.evidenceRecords);
|
||||
assertStepInputsOutputs(plan, {
|
||||
...topology,
|
||||
...rpcAndOperations,
|
||||
...evidenceIndexes,
|
||||
healthIds: new Set(health.byId.keys()),
|
||||
traceIds: new Set(plan.traceEvents.map((trace) => trace.traceId))
|
||||
});
|
||||
assertDependencies(plan);
|
||||
|
||||
console.log(
|
||||
[
|
||||
`M5 MVP E2E dry-run passed: ${plan.steps.length} steps`,
|
||||
`${artifacts.size} artifacts`,
|
||||
`${health.count} health contracts`,
|
||||
`${rpcAndOperations.operations.size} hardware operations`,
|
||||
`${evidenceIndexes.evidenceRecords.size} evidence records`,
|
||||
"0 network calls"
|
||||
].join(", ")
|
||||
);
|
||||
Reference in New Issue
Block a user