fix: 修正 CaseRun HWPOD 合同复核
This commit is contained in:
@@ -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
@@ -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"):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user