From a9570ceedf84af2e4b697b16c9d27d426cdb1b85 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jul 2026 19:49:17 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=20CaseRun=20UART=20?= =?UTF-8?q?=E6=9D=83=E5=A8=81=E8=A7=82=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../caserun-runtime-observation.test.ts | 68 +++++++++++++++---- tools/hwlab-node.py | 9 ++- tools/src/hwpod-node-lib.ts | 20 +++++- 3 files changed, 81 insertions(+), 16 deletions(-) diff --git a/tools/hwlab-cli/caserun-runtime-observation.test.ts b/tools/hwlab-cli/caserun-runtime-observation.test.ts index 5b954c58..f1116587 100644 --- a/tools/hwlab-cli/caserun-runtime-observation.test.ts +++ b/tools/hwlab-cli/caserun-runtime-observation.test.ts @@ -25,6 +25,21 @@ const DOCUMENT = { } }; +function executePythonNodePlan(plan: unknown) { + const pythonScript = ` +import importlib.util, json, logging, sys +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) +plan = json.loads(sys.argv[1]) +executor = module.NodeOpsExecutor({"nodeId": plan["nodeId"]}, logging.getLogger("test")) +print(json.dumps(executor.execute(plan))) +`; + const python = spawnSync("python3", ["-c", pythonScript, JSON.stringify(plan)], { cwd: process.cwd(), encoding: "utf8" }); + assert.equal(python.status, 0, python.stderr); + return JSON.parse(python.stdout); +} + test("CaseRun derives compile-only and runtime validation plans from case definition", () => { const compile = caseValidationPlanFromDefinition({}); assert.equal(compile.mode, "compile-only"); @@ -64,18 +79,7 @@ test("HWPOD node owns ioProbe command output normalization before CaseRun projec document.spec.ioProbe.endpoints.main41.command = `${process.execPath} ${fakeBoardComm}`; const plan = compileHwpodNodeOpsPlan({ document, intent: "io.probe.read", args: { probeId: "main41.ai0.current", count: 3 } }); const nodeResult = await executeHwpodNodeOpsPlan(plan, { now: () => "2026-07-16T00:00:00.000Z" }); - const pythonScript = ` -import importlib.util, json, logging, sys -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) -plan = json.loads(sys.argv[1]) -executor = module.NodeOpsExecutor({"nodeId": plan["nodeId"]}, logging.getLogger("test")) -print(json.dumps(executor.execute(plan))) -`; - const python = spawnSync("python3", ["-c", pythonScript, JSON.stringify(plan)], { cwd: process.cwd(), encoding: "utf8" }); - assert.equal(python.status, 0, python.stderr); - const pythonResult = JSON.parse(python.stdout); + const pythonResult = executePythonNodePlan(plan); assert.equal(nodeResult.ok, true); assert.equal(nodeResult.results[0].output.observation.probeId, "main41.ai0.current"); @@ -96,6 +100,46 @@ print(json.dumps(executor.execute(plan))) } }); +test("HWPOD TS and Python nodes project identical UART observations before CaseRun normalization", async () => { + const workspacePath = await mkdtemp(path.join(os.tmpdir(), "hwlab-caserun-uart-observation-")); + try { + const serialMonitor = path.join(workspacePath, "serial-monitor-fixture.mjs"); + await writeFile(serialMonitor, ` +const args = process.argv.slice(2).join(" "); +if (args === "monitor status") console.log(JSON.stringify({ success: true, data: { isMonitoring: true, port: "COM9", baudRate: 115200 } })); +else if (args.startsWith("fetch")) console.log(JSON.stringify({ success: true, data: [{ timestamp: "2026-07-16T00:00:00Z", port: "COM9", data: "UART READY" }], count: 1, totalCount: 1, hasMore: false, truncated: false, sourceFile: "artifacts/uart/session.jsonl" })); +else { console.error("unsupported " + args); process.exit(2); } +`, "utf8"); + const document = structuredClone(DOCUMENT); + document.spec.workspace.path = workspacePath; + document.spec.ioProbe.uart = { id: "uart1", port: "COM9", baudrate: 115200 }; + document.spec.tooling = { serialMonitorDir: workspacePath, serialMonitorCommand: `${process.execPath} ${serialMonitor}` }; + const plan = compileHwpodNodeOpsPlan({ document, intent: "io.uart.read", args: { port: "uart1", maxBytes: 128 } }); + const nodeResult = await executeHwpodNodeOpsPlan(plan, { now: () => "2026-07-16T00:00:00.000Z" }); + const pythonResult = executePythonNodePlan(plan); + + assert.equal(nodeResult.ok, true); + assert.equal(pythonResult.ok, true); + assert.deepEqual(pythonResult.results[0].output.observation, nodeResult.results[0].output.observation); + assert.equal(nodeResult.results[0].output.observation.text, "UART READY"); + assert.equal(nodeResult.results[0].output.observation.rawArtifactRef, "artifacts/uart/session.jsonl"); + + const validationPlan = caseValidationPlanFromDefinition({ validation: { mode: "download-uart", uart: { port: "uart1" } } }); + const normalized = normalizeValidationStepResult({ step: validationPlan.steps[2], document, exitCode: 0, payload: { body: nodeResult } }); + assert.equal(normalized.blocker, undefined); + assert.equal(normalized.observation.evidenceLevel, "serial-log"); + assert.equal(normalized.observation.rawArtifactRef, "artifacts/uart/session.jsonl"); + + const stdoutOnly = structuredClone(nodeResult); + stdoutOnly.results[0].output = { workspacePath, stdout: JSON.stringify(nodeResult.results[0].output.observation) }; + const rejected = normalizeValidationStepResult({ step: validationPlan.steps[2], document, exitCode: 0, payload: { body: stdoutOnly } }); + assert.equal(rejected.observation, undefined); + assert.equal(rejected.blocker.code, "hwpod_validation_observation_missing"); + } finally { + await rm(workspacePath, { recursive: true, force: true }); + } +}); + test("CaseRun normalizes authoritative ioProbe statistics and replay relationships", () => { const plan = caseValidationPlanFromDefinition({ validation: { mode: "io-probe", ioProbe: { probeId: "main41.ai0.current", quantity: "current", unit: "mA" } } }); const step = plan.steps[2]; diff --git a/tools/hwlab-node.py b/tools/hwlab-node.py index 0b32b99f..b23a7dcc 100644 --- a/tools/hwlab-node.py +++ b/tools/hwlab-node.py @@ -803,7 +803,14 @@ class NodeOpsExecutor: rows = fetch_body.get("data") if isinstance(fetch_body.get("data"), list) else [] text = "\n".join(str(row.get("data")) for row in rows if isinstance(row, dict) and row.get("data")) max_bytes = safe_int(args.get("maxBytes"), 16384) - return {"ok": True, "bindingSource": "serial-monitor-cli", "serialMonitorDir": str(serial_dir), "requestedPort": port or None, "resolvedPort": active_port or port or None, "baudRate": baud or active_baud, "command": [*command_base, *fetch_args], "data": rows, "count": fetch_body.get("count", len(rows)), "totalCount": fetch_body.get("totalCount"), "hasMore": fetch_body.get("hasMore", False), "text": text[:max_bytes], "truncated": len(text) > max_bytes or fetch_body.get("truncated") is True, "monitorStatus": status_body, "monitorStarted": not matches, "monitorStart": monitor_start, "sourceFile": fetch_body.get("sourceFile")} + truncated_text = text[:max_bytes] + resolved_port = active_port or port or None + resolved_baud = baud or active_baud + source_file = str(fetch_body.get("sourceFile") or "") + observation = {"observationId": f"uart:{resolved_port or 'unknown'}:{source_file or 'session'}", "port": resolved_port, "baudRate": resolved_baud, "data": rows, "text": truncated_text, "truncated": len(text) > max_bytes or fetch_body.get("truncated") is True} + if source_file: + observation["rawArtifactRef"] = source_file + return {"ok": True, "workspacePath": str(self._workspace_root(args)), "bindingSource": "serial-monitor-cli", "serialMonitorDir": str(serial_dir), "requestedPort": port or None, "resolvedPort": resolved_port, "baudRate": resolved_baud, "command": [*command_base, *fetch_args], "data": rows, "count": fetch_body.get("count", len(rows)), "totalCount": fetch_body.get("totalCount"), "hasMore": fetch_body.get("hasMore", False), "text": truncated_text, "truncated": len(text) > max_bytes or fetch_body.get("truncated") is True, "monitorStatus": status_body, "monitorStarted": not matches, "monitorStart": monitor_start, "sourceFile": source_file or None, "observation": observation} def _parse_json(self, text: object) -> dict: try: diff --git a/tools/src/hwpod-node-lib.ts b/tools/src/hwpod-node-lib.ts index 55673095..b6d5063d 100644 --- a/tools/src/hwpod-node-lib.ts +++ b/tools/src/hwpod-node-lib.ts @@ -1408,13 +1408,26 @@ async function uartRead(args: any) { const rows = Array.isArray(fetchBody.data) ? fetchBody.data : []; const joined = rows.map((row: any) => text(row?.data)).filter(Boolean).join("\n"); const truncatedText = joined.length > maxBytes ? joined.slice(0, maxBytes) : joined; + const resolvedPort = text(statusData.port) || targetPort; + const baudRate = numberValue(statusData.baudRate) || targetBaudRate || activeBaudRate || null; + const sourceFile = text(fetchBody.sourceFile); + const observation = { + observationId: `uart:${resolvedPort || "unknown"}:${sourceFile || "session"}`, + port: resolvedPort, + baudRate, + data: rows, + text: truncatedText, + truncated: joined.length > maxBytes || fetchBody.truncated === true, + ...(sourceFile ? { rawArtifactRef: sourceFile } : {}) + }; return { ok: true, + workspacePath: workspaceRoot(args), bindingSource: "serial-monitor-cli", serialMonitorDir, requestedPort: diagnostics.requestedPort, - resolvedPort: text(statusData.port) || targetPort, - baudRate: numberValue(statusData.baudRate) || targetBaudRate || activeBaudRate || null, + resolvedPort, + baudRate, command: [...commandBase, ...fetchArgs], data: rows, count: fetchBody.count ?? rows.length, @@ -1425,7 +1438,8 @@ async function uartRead(args: any) { monitorStatus: statusBody ?? monitorStatus, monitorStarted: !monitoringMatches, monitorStart, - sourceFile: fetchBody.sourceFile ?? null + sourceFile: sourceFile || null, + observation }; }