fix: 修正 CaseRun HWPOD 合同复核

This commit is contained in:
root
2026-07-16 16:40:31 +02:00
parent 814ea533f3
commit bb0e3230df
5 changed files with 116 additions and 13 deletions
+9 -1
View File
@@ -204,7 +204,7 @@ test("case run exposes Keil hex and axf artifacts in evidence summary", () => {
test("case run normalizes v0.3 HWPOD build operation topology and typed wait blockers", () => {
const document = { metadata: { name: "d601-f103-v2" }, spec: { nodeBinding: { nodeId: "node-d601-f103-v2" }, workspace: { path: "F:\\Work\\case" } } };
const operation = summarizeHwpodOperationForTest({ body: { hwpodId: "d601-f103-v2", nodeId: "node-d601-f103-v2", results: [{ opId: "op_build", op: "debug.build", ok: true, status: "completed", output: { stdout: '{"job_id":"job-1"}' } }] } }, document, "debug.build");
const operation = summarizeHwpodOperationForTest({ body: { hwpodId: "d601-f103-v2", nodeId: "node-d601-f103-v2", results: [{ opId: "op_build", op: "debug.build", ok: true, status: "completed", workspacePath: "F:\\Work\\case", output: { stdout: '{"job_id":"job-1"}' } }] } }, document, "debug.build");
assert.equal(operation.topology.ok, true);
assert.equal(operation.jobId, "job-1");
assert.equal(buildWaitBlockerForTest(operation, { ok: true, status: "completed", warningCount: 7, artifacts: ["F:\\out.hex"] }, 0), null);
@@ -214,6 +214,14 @@ test("case run normalizes v0.3 HWPOD build operation topology and typed wait blo
assert.equal(buildWaitBlockerForTest(operation, { ok: true, status: "completed", artifacts: ["F:\\out.axf"] }, 0).code, "hwpod_build_hex_missing");
const mismatch = summarizeHwpodOperationForTest({ body: { nodeId: "wrong-node", results: [{ op: "debug.build", ok: true, output: { stdout: '{"job_id":"job-2"}' } }] } }, document, "debug.build");
assert.equal(buildWaitBlockerForTest(mismatch, null, 0).code, "hwpod_topology_mismatch");
const missingObserved = summarizeHwpodOperationForTest({ body: { results: [{ op: "debug.build", ok: true, output: { stdout: '{"job_id":"job-3"}' } }] } }, document, "debug.build");
assert.deepEqual(missingObserved.topology.blocker.details, [
{ field: "hwpodId", expected: "d601-f103-v2", observed: null },
{ field: "nodeId", expected: "node-d601-f103-v2", observed: null },
{ field: "workspacePath", expected: "F:\\Work\\case", observed: null }
]);
const yamlEmpty = summarizeHwpodOperationForTest({ body: { hwpodId: "response-only", nodeId: "response-node", results: [{ op: "debug.build", ok: true, workspacePath: "response-path", output: { stdout: '{"job_id":"job-4"}' } }] } }, { metadata: {}, spec: {} }, "debug.build");
assert.equal(yamlEmpty.topology.ok, true);
});
test("case run start returns immediately and status/result/logs are short polling commands", async () => {
+17 -4
View File
@@ -663,7 +663,7 @@ class NodeOpsExecutor:
baud = args.get("baudRate") or args.get("baudrate")
status = self._spawn_output([*command_base, "monitor", "status"], serial_dir, timeout_ms)
status_body = self._parse_json(status.get("stdout"))
data = status_body.get("data") if isinstance(status_body.get("data"), dict) else {}
data = status_body.get("data") if isinstance(status_body, dict) and isinstance(status_body.get("data"), dict) else {}
active_port = str(data.get("port") or "")
active_baud = data.get("baudRate")
monitor_start = None
@@ -672,10 +672,23 @@ class NodeOpsExecutor:
start_args = ["monitor", "start", *(["-p", port] if port else []), *(["-b", str(baud)] if baud else [])]
start = self._spawn_output([*command_base, *start_args], serial_dir, timeout_ms)
start_body = self._parse_json(start.get("stdout"))
monitor_start = start_body or start
if not start.get("ok") or start_body.get("success") is not True:
monitor_start = start_body if isinstance(start_body, dict) and start_body else start
if not start.get("ok") or not isinstance(start_body, dict) or start_body.get("success") is not True:
ports = self._spawn_output([*command_base, "ports"], serial_dir, timeout_ms)
return {"ok": False, "blockerCode": "hwpod_uart_monitor_start_failed", "summary": f"serial-monitor could not start on {port or 'the requested UART'}{('/' + str(baud)) if baud else ''}", "details": {"requestedPort": port or None, "baudRate": baud, "serialMonitor": {"dir": str(serial_dir), "command": command_base, "monitorStatus": status_body or status, "start": start_body or start, "ports": self._parse_json(ports.get("stdout")) or ports}}}
return {"ok": False, "blockerCode": "hwpod_uart_monitor_start_failed", "summary": f"serial-monitor could not start on {port or 'the requested UART'}{('/' + str(baud)) if baud else ''}", "details": {"requestedPort": port or None, "baudRate": baud, "serialMonitor": {"dir": str(serial_dir), "command": command_base, "monitorStatus": status_body or status, "start": monitor_start, "ports": self._parse_json(ports.get("stdout")) or ports}}}
confirmed_status = self._spawn_output([*command_base, "monitor", "status"], serial_dir, timeout_ms)
confirmed_body = self._parse_json(confirmed_status.get("stdout"))
confirmed_data = confirmed_body.get("data") if isinstance(confirmed_body, dict) and isinstance(confirmed_body.get("data"), dict) else {}
confirmed_port = str(confirmed_data.get("port") or "")
confirmed_baud = confirmed_data.get("baudRate")
confirmed_matches = confirmed_status.get("ok") and isinstance(confirmed_body, dict) and confirmed_body.get("success") is True and confirmed_data.get("isMonitoring") is True and (not port or confirmed_port.lower() == port.lower()) and (not baud or int(confirmed_baud or 0) == int(baud))
if not confirmed_matches:
return {"ok": False, "blockerCode": "hwpod_uart_monitor_binding_mismatch", "summary": "serial-monitor did not confirm the requested UART binding after start", "details": {"requestedPort": port or None, "baudRate": baud, "serialMonitor": {"dir": str(serial_dir), "command": command_base, "monitorStatus": status_body or status, "start": monitor_start, "confirmedStatus": confirmed_body or confirmed_status}}}
status = confirmed_status
status_body = confirmed_body
data = confirmed_data
active_port = confirmed_port
active_baud = confirmed_baud
limit = max(1, min(safe_int(args.get("limit"), max(1, safe_int(args.get("maxBytes"), 4096) // 80)), 200))
fetch_args = ["fetch", "-l", str(limit), "--session-only"]
if args.get("since"):
+59 -2
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
@@ -242,9 +243,11 @@ test("hwpod-node reads UART through serial-monitor binding", async () => {
try {
const fixture = path.join(root, "serial-monitor-fixture.mjs");
await writeFile(fixture, `
import { existsSync, writeFileSync } from "node:fs";
const marker = ${JSON.stringify(path.join(root, "monitor-started"))};
const args = process.argv.slice(2).join(" ");
if (args === "monitor status") console.log(JSON.stringify({ action: "monitor_status", success: true, data: { isMonitoring: false, port: null, baudRate: null, lastPort: "COM9", lastBaudRate: 115200 } }));
else if (args === "monitor start -p COM9 -b 115200") console.log(JSON.stringify({ action: "monitor_start", success: true, data: { port: "COM9", baudRate: 115200 } }));
if (args === "monitor status") console.log(JSON.stringify({ action: "monitor_status", success: true, data: existsSync(marker) ? { isMonitoring: true, port: "COM9", baudRate: 115200 } : { isMonitoring: false, port: null, baudRate: null, lastPort: "COM9", lastBaudRate: 115200 } }));
else if (args === "monitor start -p COM9 -b 115200") { writeFileSync(marker, "started"); console.log(JSON.stringify({ action: "monitor_start", success: true, data: { port: "COM9", baudRate: 115200 } })); }
else if (args === "ports") console.log(JSON.stringify({ action: "list_ports", success: true, data: { ports: [{ path: "COM9" }] } }));
else if (args.startsWith("fetch")) console.log(JSON.stringify({ action: "fetch_data", success: true, data: [{ timestamp: "2026-06-06T10:00:01Z", port: "COM9", data: "UART OK" }], count: 1, totalCount: 1, hasMore: false, truncated: false, sourceFile: "fixture.jsonl" }));
else { console.error("unsupported " + args); process.exit(2); }
@@ -276,12 +279,66 @@ else { console.error("unsupported " + args); process.exit(2); }
assert.equal(result.results[0].output.text, "UART OK");
assert.equal(result.results[0].output.monitorStarted, true);
assert.equal(result.results[0].output.monitorStart.success, true);
assert.equal(result.results[0].output.monitorStatus.data.isMonitoring, true);
assert.equal(result.results[0].output.monitorStatus.data.port, "COM9");
assert.equal(result.results[0].output.monitorStatus.data.baudRate, 115200);
assert.deepEqual(result.results[0].output.command.slice(0, 2), [process.execPath, fixture]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("hwpod-node blocks UART fetch when start does not establish requested binding", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-uart-mismatch-"));
try {
const fixture = path.join(root, "serial-monitor-mismatch.mjs");
await writeFile(fixture, `
const args = process.argv.slice(2).join(" ");
if (args === "monitor status") console.log(JSON.stringify({ success: true, data: { isMonitoring: false, port: null, baudRate: null } }));
else if (args === "monitor start -p COM9 -b 115200") console.log(JSON.stringify({ success: true, data: { port: "COM9", baudRate: 115200 } }));
else if (args.startsWith("fetch")) { console.error("fetch must not run"); process.exit(3); }
else { console.error("unsupported " + args); process.exit(2); }
`, "utf8");
const result = await executeHwpodNodeOpsPlan({
contractVersion: "hwpod-node-ops-v1",
planId: "hwpod_plan_uart_mismatch",
hwpodId: "hwpod-local",
nodeId: "pc-host-1",
ops: [{ opId: "op_uart", op: "io.uart.read", args: { workspacePath: root, port: "COM9", baudRate: 115200, serialMonitorDir: root, serialMonitorCommand: [process.execPath, fixture] } }]
}, { now: () => "2026-06-05T00:00:00.000Z" });
assert.equal(result.ok, false);
assert.equal(result.results[0].blocker.code, "hwpod_uart_monitor_binding_mismatch");
assert.equal(result.results[0].blocker.details.serialMonitor.confirmedStatus.data.isMonitoring, false);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("python hwpod-node returns typed blocker for non-JSON UART start response", () => {
const script = `
import importlib.util, json, logging
spec = importlib.util.spec_from_file_location("hwlab_node", "tools/hwlab-node.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class Executor(module.NodeOpsExecutor):
def _spawn_output(self, command, cwd, timeout_ms):
action = " ".join(command[-2:])
if command[-2:] == ["monitor", "status"]:
return {"ok": True, "stdout": json.dumps({"success": True, "data": {"isMonitoring": False}}), "stderr": "", "exitCode": 0}
if "start" in command:
return {"ok": True, "stdout": "not-json", "stderr": "", "exitCode": 0}
return {"ok": True, "stdout": json.dumps({"success": True, "data": {"ports": []}}), "stderr": "", "exitCode": 0}
executor = Executor({"nodeId": "node-test"}, logging.getLogger("test"))
print(json.dumps(executor._uart_read({"workspacePath": ".", "port": "COM9", "baudRate": 115200, "serialMonitorCommand": ["serial-monitor"]})))
`;
const completed = spawnSync("python3", ["-c", script], { cwd: process.cwd(), encoding: "utf8" });
assert.equal(completed.status, 0, completed.stderr);
const result = JSON.parse(completed.stdout);
assert.equal(result.ok, false);
assert.equal(result.blockerCode, "hwpod_uart_monitor_start_failed");
assert.equal(result.details.serialMonitor.start.stdout, "not-json");
});
test("hwpod-node resolves Git for Windows when service PATH omits git", async () => {
const result = await resolveHwpodNodeCommand("git", {
platform: "win32",
+5 -1
View File
@@ -503,7 +503,11 @@ export function summarizeHwpodOperationForTest(payload: any, document: any, expe
nodeId: firstTextOption(response.nodeId, result?.nodeId, output.nodeId),
workspacePath: firstTextOption(result?.workspacePath, output.workspacePath, output.cwd)
};
const mismatch = Object.entries(expected).flatMap(([field, value]) => observed[field as keyof typeof observed] && observed[field as keyof typeof observed] !== value ? [{ field, expected: value, observed: observed[field as keyof typeof observed] }] : []);
const mismatch = Object.entries(expected).flatMap(([field, value]) => {
if (!value) return [];
const observedValue = observed[field as keyof typeof observed];
return observedValue === value ? [] : [{ field, expected: value, observed: observedValue || null }];
});
return clean({
contractVersion: "hwpod-operation-result-v0.3",
authority: "case-hwpod-yaml",
+26 -5
View File
@@ -1331,8 +1331,9 @@ async function uartRead(args: any) {
const timeoutMs = numberValue(args.timeoutMs) ?? 10000;
const status = await runSerialMonitor(commandBase, ["monitor", "status"], { cwd: serialMonitorDir, timeoutMs })
.catch((error) => ({ ok: false, stdout: "", stderr: error?.message || String(error), exitCode: null, command: [...commandBase, "monitor", "status"] }));
const statusBody = parseJsonMaybe(status.stdout);
const statusData = objectValue(statusBody?.data);
let monitorStatus = status;
let statusBody = parseJsonMaybe(status.stdout);
let statusData = objectValue(statusBody?.data);
const targetPort = text(diagnostics.resolvedPort) || text(diagnostics.requestedPort);
const targetBaudRate = numberValue(diagnostics.baudRate);
const activePort = text(statusData.port);
@@ -1355,6 +1356,26 @@ async function uartRead(args: any) {
details: { ...diagnostics, serialMonitor: { dir: serialMonitorDir, command: commandBase, monitorStatus: statusBody ?? status, start: startBody ?? start, ports: parseJsonMaybe(ports.stdout) ?? ports } }
};
}
const confirmedStatus = await runSerialMonitor(commandBase, ["monitor", "status"], { cwd: serialMonitorDir, timeoutMs })
.catch((error) => ({ ok: false, stdout: "", stderr: error?.message || String(error), exitCode: null, command: [...commandBase, "monitor", "status"] }));
const confirmedBody = parseJsonMaybe(confirmedStatus.stdout);
const confirmedData = objectValue(confirmedBody?.data);
const confirmedPort = text(confirmedData.port);
const confirmedBaudRate = numberValue(confirmedData.baudRate);
const confirmedMatches = confirmedStatus.ok && confirmedBody?.success === true && confirmedData.isMonitoring === true
&& (!targetPort || confirmedPort.toLowerCase() === targetPort.toLowerCase())
&& (!targetBaudRate || confirmedBaudRate === targetBaudRate);
if (!confirmedMatches) {
return {
ok: false,
blockerCode: "hwpod_uart_monitor_binding_mismatch",
summary: "serial-monitor did not confirm the requested UART binding after start",
details: { ...diagnostics, serialMonitor: { dir: serialMonitorDir, command: commandBase, monitorStatus: statusBody ?? status, start: monitorStart, confirmedStatus: confirmedBody ?? confirmedStatus } }
};
}
monitorStatus = confirmedStatus;
statusBody = confirmedBody;
statusData = confirmedData;
}
const limit = Math.max(1, Math.min(numberValue(args.limit) ?? Math.ceil((numberValue(args.maxBytes) ?? 4096) / 80), 200));
const maxBytes = numberValue(args.maxBytes) ?? 16384;
@@ -1377,8 +1398,8 @@ async function uartRead(args: any) {
bindingSource: "serial-monitor-cli",
serialMonitorDir,
requestedPort: diagnostics.requestedPort,
resolvedPort: targetPort,
baudRate: targetBaudRate || activeBaudRate || null,
resolvedPort: text(statusData.port) || targetPort,
baudRate: numberValue(statusData.baudRate) || targetBaudRate || activeBaudRate || null,
command: [...commandBase, ...fetchArgs],
data: rows,
count: fetchBody.count ?? rows.length,
@@ -1386,7 +1407,7 @@ async function uartRead(args: any) {
hasMore: fetchBody.hasMore ?? false,
text: truncatedText,
truncated: joined.length > maxBytes || fetchBody.truncated === true,
monitorStatus: statusBody,
monitorStatus: statusBody ?? monitorStatus,
monitorStarted: !monitoringMatches,
monitorStart,
sourceFile: fetchBody.sourceFile ?? null