diff --git a/tools/hwlab-cli/caserun.test.ts b/tools/hwlab-cli/caserun.test.ts index 680ca66b..f461cf37 100644 --- a/tools/hwlab-cli/caserun.test.ts +++ b/tools/hwlab-cli/caserun.test.ts @@ -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 () => { diff --git a/tools/hwlab-node.py b/tools/hwlab-node.py index cd28065b..173d8d47 100644 --- a/tools/hwlab-node.py +++ b/tools/hwlab-node.py @@ -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"): diff --git a/tools/hwpod-node.test.ts b/tools/hwpod-node.test.ts index cdb3dfd7..48a25092 100644 --- a/tools/hwpod-node.test.ts +++ b/tools/hwpod-node.test.ts @@ -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", diff --git a/tools/src/hwlab-caserun-runtime.ts b/tools/src/hwlab-caserun-runtime.ts index d87c9bca..f9214764 100644 --- a/tools/src/hwlab-caserun-runtime.ts +++ b/tools/src/hwlab-caserun-runtime.ts @@ -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", diff --git a/tools/src/hwpod-node-lib.ts b/tools/src/hwpod-node-lib.ts index df1c2ed0..63b09146 100644 --- a/tools/src/hwpod-node-lib.ts +++ b/tools/src/hwpod-node-lib.ts @@ -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