From 7e7ea0fed17cd9afcdcbf9f7d5e241b8f461a1db Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 7 Jun 2026 07:09:01 +0200 Subject: [PATCH] fix(qa): validate rpc rtt smoke payloads --- scripts/measure-rpc-rtt.mjs | 90 +++++++++++++++++++++++++++- test/scripts/measure-rpc-rtt.test.ts | 54 +++++++++++++++++ 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/scripts/measure-rpc-rtt.mjs b/scripts/measure-rpc-rtt.mjs index 7475f3639f9..1e6ed2a9b3c 100644 --- a/scripts/measure-rpc-rtt.mjs +++ b/scripts/measure-rpc-rtt.mjs @@ -474,6 +474,92 @@ export function summarizeRttSamples(samples) { }; } +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function assertPayloadObject(method, payload) { + if (!isRecord(payload)) { + throw new Error(`${method} returned invalid payload: expected object.`); + } + return payload; +} + +function assertHealthSmokePayload(payload) { + const summary = assertPayloadObject("health", payload); + if (summary.ok !== true) { + throw new Error("health returned invalid payload: expected ok=true."); + } + if (!Number.isFinite(summary.ts)) { + throw new Error("health returned invalid payload: expected numeric ts."); + } + if (!Number.isFinite(summary.durationMs)) { + throw new Error("health returned invalid payload: expected numeric durationMs."); + } + if (typeof summary.defaultAgentId !== "string" || summary.defaultAgentId.trim() === "") { + throw new Error("health returned invalid payload: expected defaultAgentId."); + } + if (!Array.isArray(summary.agents)) { + throw new Error("health returned invalid payload: expected agents array."); + } + if (!isRecord(summary.channels)) { + throw new Error("health returned invalid payload: expected channels object."); + } + if (!Array.isArray(summary.channelOrder)) { + throw new Error("health returned invalid payload: expected channelOrder array."); + } + if (!isRecord(summary.sessions)) { + throw new Error("health returned invalid payload: expected sessions object."); + } +} + +function assertConfigGetSmokePayload(payload) { + const snapshot = assertPayloadObject("config.get", payload); + if (typeof snapshot.path !== "string" || snapshot.path.trim() === "") { + throw new Error("config.get returned invalid payload: expected config path."); + } + if (typeof snapshot.exists !== "boolean") { + throw new Error("config.get returned invalid payload: expected exists boolean."); + } + if (typeof snapshot.valid !== "boolean") { + throw new Error("config.get returned invalid payload: expected valid boolean."); + } + if (!isRecord(snapshot.sourceConfig)) { + throw new Error("config.get returned invalid payload: expected sourceConfig object."); + } + if (!isRecord(snapshot.resolved)) { + throw new Error("config.get returned invalid payload: expected resolved object."); + } + if (!isRecord(snapshot.runtimeConfig)) { + throw new Error("config.get returned invalid payload: expected runtimeConfig object."); + } + if (!isRecord(snapshot.config)) { + throw new Error("config.get returned invalid payload: expected config object."); + } + if (!Array.isArray(snapshot.issues)) { + throw new Error("config.get returned invalid payload: expected issues array."); + } + if (!Array.isArray(snapshot.warnings)) { + throw new Error("config.get returned invalid payload: expected warnings array."); + } + if (!Array.isArray(snapshot.legacyIssues)) { + throw new Error("config.get returned invalid payload: expected legacyIssues array."); + } +} + +export function assertRpcSmokeResponse(method, response) { + if (!response?.ok) { + throw new Error(`${method} failed: ${JSON.stringify(response?.error)}`); + } + if (method === "health") { + assertHealthSmokePayload(response.payload); + return; + } + if (method === "config.get") { + assertConfigGetSmokePayload(response.payload); + } +} + function toText(data) { if (typeof data === "string") { return data; @@ -720,9 +806,7 @@ async function main() { const response = await client.request(method, {}, 10_000); const durationMs = performance.now() - requestStartedAtMs; const roundedDurationMs = roundMeasuredMs(durationMs, `${method} durationMs`); - if (!response.ok) { - throw new Error(`${method} failed: ${JSON.stringify(response.error)}`); - } + assertRpcSmokeResponse(method, response); samples.push({ method, durationMs }); events.push({ event: "gateway-rpc", diff --git a/test/scripts/measure-rpc-rtt.test.ts b/test/scripts/measure-rpc-rtt.test.ts index cbc61015b1c..3f4898ba2a7 100644 --- a/test/scripts/measure-rpc-rtt.test.ts +++ b/test/scripts/measure-rpc-rtt.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from "node:events"; import { describe, expect, it, vi } from "vitest"; import { + assertRpcSmokeResponse, cleanupTempRoot, createGatewayClient, installGatewayParentCleanup, @@ -134,6 +135,59 @@ describe("scripts/measure-rpc-rtt.mjs", () => { ); }); + it("validates default RPC RTT smoke payloads", () => { + expect(() => + assertRpcSmokeResponse("health", { + ok: true, + payload: { + agents: [], + channelOrder: [], + channels: {}, + defaultAgentId: "codex", + durationMs: 3, + ok: true, + sessions: { count: 0, path: "/state/sessions", recent: [] }, + ts: Date.now(), + }, + }), + ).not.toThrow(); + + expect(() => + assertRpcSmokeResponse("config.get", { + ok: true, + payload: { + config: {}, + exists: true, + issues: [], + legacyIssues: [], + path: "/tmp/openclaw.json", + resolved: {}, + runtimeConfig: {}, + sourceConfig: {}, + valid: true, + warnings: [], + }, + }), + ).not.toThrow(); + + expect(() => assertRpcSmokeResponse("health", { ok: true, payload: {} })).toThrow( + "health returned invalid payload: expected ok=true.", + ); + expect(() => assertRpcSmokeResponse("config.get", { ok: true, payload: {} })).toThrow( + "config.get returned invalid payload: expected config path.", + ); + }); + + it("keeps custom RPC RTT methods on the generic ok/error contract", () => { + expect(() => assertRpcSmokeResponse("custom.method", { ok: true })).not.toThrow(); + expect(() => + assertRpcSmokeResponse("custom.method", { + error: { code: "bad_request" }, + ok: false, + }), + ).toThrow('custom.method failed: {"code":"bad_request"}'); + }); + it("closes parent gateway log handles after spawning", async () => { const child = Object.assign(new EventEmitter(), { exitCode: null,