Skip to content

Commit 6270d53

Browse files
committed
fix(e2e): prove gateway health after websocket connect
1 parent 6561bdc commit 6270d53

8 files changed

Lines changed: 130 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai
5757
- CI/tooling: route script edits through conventional owner tests when matching `test/scripts` or `src/scripts` coverage already exists.
5858
- CI/tooling: honor option terminators in the memory FD repro script so follow-on arguments are not reparsed.
5959
- Release/CI/E2E: assert plugin lifecycle runtime inspect output instead of only capturing it.
60+
- Release/CI/E2E: make gateway-network prove the advertised health RPC and retry early WebSocket closes without burning full open timeouts.
6061
- Release/CI/E2E: honor option terminators across release, Parallels smoke, plugin gauntlet, and extension-memory scripts.
6162
- Release/CI/E2E: fail plugin gateway gauntlet QA chunks when the requested suite summary is missing or invalid.
6263
- Performance: prebuild QA runtime probes with generated plugin assets but without CLI startup metadata.

scripts/e2e/lib/gateway-network/client.mjs

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,22 @@ function onceFrame(ws, filter, timeoutMs = 10_000) {
4040
});
4141
}
4242

43+
function responseError(method, response) {
44+
const message = response.error?.message ?? "unknown";
45+
return new Error(`${method} failed: ${message}`);
46+
}
47+
48+
function isRetryableStartupError(message) {
49+
return (
50+
message.includes("gateway starting") ||
51+
message.includes("closed before open") ||
52+
message.includes("ws open timeout") ||
53+
message.includes("ECONNREFUSED") ||
54+
message.includes("ECONNRESET") ||
55+
message.includes("timeout")
56+
);
57+
}
58+
4359
let lastError;
4460
while (Date.now() < deadline) {
4561
let ws;
@@ -67,33 +83,28 @@ while (Date.now() < deadline) {
6783
);
6884

6985
const connectRes = await onceFrame(ws, (frame) => frame?.type === "res" && frame?.id === "c1");
70-
if (connectRes.ok) {
71-
ws.close();
72-
console.log("ok");
73-
process.exit(0);
74-
}
86+
if (!connectRes.ok) {
87+
lastError = responseError("connect", connectRes);
88+
if (!isRetryableStartupError(lastError.message)) {
89+
throw lastError;
90+
}
91+
} else {
92+
ws.send(JSON.stringify({ type: "req", id: "h1", method: "health" }));
93+
const healthRes = await onceFrame(
94+
ws,
95+
(frame) => frame?.type === "res" && frame?.id === "h1",
96+
);
97+
if (healthRes.ok) {
98+
ws.close();
99+
console.log("ok");
100+
process.exit(0);
101+
}
75102

76-
const message = connectRes.error?.message ?? "unknown";
77-
lastError = new Error(`connect failed: ${message}`);
78-
if (
79-
!message.includes("gateway starting") &&
80-
!message.includes("ws open timeout") &&
81-
!message.includes("ECONNREFUSED") &&
82-
!message.includes("ECONNRESET") &&
83-
!message.includes("timeout")
84-
) {
85-
throw lastError;
103+
throw responseError("health", healthRes);
86104
}
87105
} catch (error) {
88106
lastError = error instanceof Error ? error : new Error(String(error));
89-
const message = lastError.message;
90-
if (
91-
!message.includes("gateway starting") &&
92-
!message.includes("ws open timeout") &&
93-
!message.includes("ECONNREFUSED") &&
94-
!message.includes("ECONNRESET") &&
95-
!message.includes("timeout")
96-
) {
107+
if (!isRetryableStartupError(lastError.message)) {
97108
throw lastError;
98109
}
99110
} finally {

scripts/e2e/lib/websocket-open.mjs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
function formatCloseValue(value) {
2+
if (value === undefined || value === null) {
3+
return "";
4+
}
5+
if (typeof value === "string") {
6+
return value;
7+
}
8+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
9+
return value.toString();
10+
}
11+
if (value instanceof Uint8Array) {
12+
return Buffer.from(value).toString();
13+
}
14+
return JSON.stringify(value) ?? "";
15+
}
16+
117
export function waitForWebSocketOpen(ws, timeoutMs, message = "ws open timeout") {
218
return new Promise((resolve, reject) => {
319
let settled = false;
@@ -10,11 +26,19 @@ export function waitForWebSocketOpen(ws, timeoutMs, message = "ws open timeout")
1026
clearTimeout(timer);
1127
ws.off?.("open", onOpen);
1228
ws.off?.("error", onError);
29+
ws.off?.("close", onClose);
1330
fn(value);
1431
};
1532
const onOpen = () => settle(resolve);
1633
const onError = (error) =>
1734
settle(reject, error instanceof Error ? error : new Error(String(error)));
35+
const onClose = (code, reason) => {
36+
const closeDetails = [formatCloseValue(code), formatCloseValue(reason)]
37+
.filter(Boolean)
38+
.join(" ");
39+
const suffix = closeDetails ? `: ${closeDetails}` : "";
40+
settle(reject, new Error(`closed before open${suffix}`));
41+
};
1842
const timer = setTimeout(() => {
1943
const consumeAbortError = () => {};
2044
const removeAbortErrorConsumer = () => {
@@ -23,6 +47,7 @@ export function waitForWebSocketOpen(ws, timeoutMs, message = "ws open timeout")
2347
};
2448
try {
2549
ws.off?.("error", onError);
50+
ws.off?.("close", onClose);
2651
ws.on?.("error", consumeAbortError);
2752
ws.once?.("close", removeAbortErrorConsumer);
2853
ws.terminate?.();
@@ -37,5 +62,6 @@ export function waitForWebSocketOpen(ws, timeoutMs, message = "ws open timeout")
3762
timer.unref?.();
3863
ws.once("open", onOpen);
3964
ws.once("error", onError);
65+
ws.once("close", onClose);
4066
});
4167
}

scripts/e2e/mcp-channels-harness.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ function isRetryableGatewayConnectError(error: Error): boolean {
300300
return (
301301
message.includes("gateway ws open timeout") ||
302302
message.includes("gateway connect timeout") ||
303+
message.includes("closed before open") ||
303304
message.includes("gateway closed") ||
304305
message.includes("econnrefused") ||
305306
message.includes("socket hang up")

scripts/e2e/mcp-websocket-open.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,22 @@ type WebSocketOpenHandle = {
66
terminate?: () => void;
77
};
88

9+
function formatCloseValue(value: unknown): string {
10+
if (value === undefined || value === null) {
11+
return "";
12+
}
13+
if (typeof value === "string") {
14+
return value;
15+
}
16+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
17+
return value.toString();
18+
}
19+
if (value instanceof Uint8Array) {
20+
return Buffer.from(value).toString();
21+
}
22+
return JSON.stringify(value) ?? "";
23+
}
24+
925
export function waitForWebSocketOpen(
1026
ws: WebSocketOpenHandle,
1127
timeoutMs: number,
@@ -19,6 +35,7 @@ export function waitForWebSocketOpen(
1935
clearTimeout(timer);
2036
ws.off?.("open", onOpen);
2137
ws.off?.("error", onError);
38+
ws.off?.("close", onClose);
2239
};
2340
const resolveOpen = () => {
2441
if (settled) {
@@ -38,6 +55,13 @@ export function waitForWebSocketOpen(
3855
};
3956
const onOpen = () => resolveOpen();
4057
const onError = (error: unknown) => rejectOpen(error);
58+
const onClose = (code?: unknown, reason?: unknown) => {
59+
const closeDetails = [formatCloseValue(code), formatCloseValue(reason)]
60+
.filter(Boolean)
61+
.join(" ");
62+
const suffix = closeDetails ? `: ${closeDetails}` : "";
63+
rejectOpen(new Error(`closed before open${suffix}`));
64+
};
4165
timer = setTimeout(() => {
4266
const consumeAbortError = () => {};
4367
const removeAbortErrorConsumer = () => {
@@ -46,6 +70,7 @@ export function waitForWebSocketOpen(
4670
};
4771
try {
4872
ws.off?.("error", onError);
73+
ws.off?.("close", onClose);
4974
ws.on?.("error", consumeAbortError);
5075
ws.once?.("close", removeAbortErrorConsumer);
5176
ws.terminate?.();
@@ -60,5 +85,6 @@ export function waitForWebSocketOpen(
6085
timer.unref?.();
6186
ws.once("open", onOpen);
6287
ws.once("error", onError);
88+
ws.once("close", onClose);
6389
});
6490
}

test/scripts/e2e-websocket-open.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ describe("E2E WebSocket open guard", () => {
3333
expect(ws.terminated).toBe(true);
3434
expect(ws.listenerCount("open")).toBe(0);
3535
expect(ws.listenerCount("error")).toBe(0);
36+
expect(ws.listenerCount("close")).toBe(0);
3637
});
3738

3839
it("uses caller-specific timeout messages", async () => {
@@ -58,5 +59,19 @@ describe("E2E WebSocket open guard", () => {
5859
expect(ws.terminated).toBe(false);
5960
expect(ws.listenerCount("open")).toBe(0);
6061
expect(ws.listenerCount("error")).toBe(0);
62+
expect(ws.listenerCount("close")).toBe(0);
63+
});
64+
65+
it("rejects immediately when the socket closes before opening", async () => {
66+
const ws = new FakeWebSocket();
67+
const opened = waitForWebSocketOpen(ws, 1000);
68+
69+
ws.emit("close", 1006, Buffer.from("bye"));
70+
71+
await expect(opened).rejects.toThrow("closed before open: 1006 bye");
72+
expect(ws.terminated).toBe(false);
73+
expect(ws.listenerCount("open")).toBe(0);
74+
expect(ws.listenerCount("error")).toBe(0);
75+
expect(ws.listenerCount("close")).toBe(0);
6176
});
6277
});

test/scripts/gateway-network-client.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { readFileSync } from "node:fs";
12
import { describe, expect, it } from "vitest";
23
import { readGatewayNetworkClientConnectTimeoutMs } from "../../scripts/e2e/lib/gateway-network/limits.mjs";
34

@@ -33,4 +34,15 @@ describe("gateway network WebSocket open guard", () => {
3334
}),
3435
).toBe(3000);
3536
});
37+
38+
it("proves health after the authenticated connect handshake", () => {
39+
const client = readFileSync("scripts/e2e/lib/gateway-network/client.mjs", "utf8");
40+
const connectIndex = client.indexOf('method: "connect"');
41+
const healthIndex = client.indexOf('method: "health"');
42+
43+
expect(connectIndex).toBeGreaterThanOrEqual(0);
44+
expect(healthIndex).toBeGreaterThan(connectIndex);
45+
expect(client).toContain('responseError("health", healthRes)');
46+
expect(client).toContain('message.includes("closed before open")');
47+
});
3648
});

test/scripts/mcp-websocket-open.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ describe("mcp channel WebSocket open guard", () => {
3838
expect(ws.terminated).toBe(true);
3939
expect(ws.listenerCount("open")).toBe(0);
4040
expect(ws.listenerCount("error")).toBe(0);
41+
expect(ws.listenerCount("close")).toBe(0);
4142
});
4243

4344
it("cleans listeners after successful opens", async () => {
@@ -50,5 +51,19 @@ describe("mcp channel WebSocket open guard", () => {
5051
expect(ws.terminated).toBe(false);
5152
expect(ws.listenerCount("open")).toBe(0);
5253
expect(ws.listenerCount("error")).toBe(0);
54+
expect(ws.listenerCount("close")).toBe(0);
55+
});
56+
57+
it("rejects immediately when the socket closes before opening", async () => {
58+
const ws = new FakeWebSocket();
59+
const opened = waitForWebSocketOpen(ws, 1000);
60+
61+
ws.emit("close", 1006, Buffer.from("bye"));
62+
63+
await expect(opened).rejects.toThrow("closed before open: 1006 bye");
64+
expect(ws.terminated).toBe(false);
65+
expect(ws.listenerCount("open")).toBe(0);
66+
expect(ws.listenerCount("error")).toBe(0);
67+
expect(ws.listenerCount("close")).toBe(0);
5368
});
5469
});

0 commit comments

Comments
 (0)