test(e2e): exercise gateway network health client

This commit is contained in:
Vincent Koc
2026-06-06 18:13:00 +02:00
parent 539f745d12
commit 98498f2579
2 changed files with 152 additions and 69 deletions
+82 -59
View File
@@ -1,36 +1,28 @@
// WebSocket client helpers for gateway network E2E scenarios.
import { pathToFileURL } from "node:url";
import { WebSocket } from "ws";
import { PROTOCOL_VERSION } from "../../../../dist/gateway/protocol/index.js";
import { waitForWebSocketOpen } from "../websocket-open.mjs";
import { readGatewayNetworkClientConnectTimeoutMs } from "./limits.mjs";
import { onceFrame } from "./ws-frames.mjs";
const url = process.env.GW_URL;
const token = process.env.GW_TOKEN;
if (!url || !token) {
throw new Error("missing GW_URL/GW_TOKEN");
}
const deadline = Date.now() + readGatewayNetworkClientConnectTimeoutMs();
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function openSocket(timeoutMs = 10_000) {
async function openSocket(url, timeoutMs = 10_000) {
const ws = new WebSocket(url);
await waitForWebSocketOpen(ws, timeoutMs, "ws open timeout");
return ws;
}
function responseError(method, response) {
export function responseError(method, response) {
const message = response.error?.message ?? "unknown";
return new Error(`${method} failed: ${message}`);
}
function isRetryableStartupError(message) {
export function isRetryableStartupError(message) {
return (
message.includes("gateway starting") ||
message.includes("closed before frame") ||
@@ -42,59 +34,90 @@ function isRetryableStartupError(message) {
);
}
let lastError;
while (Date.now() < deadline) {
let ws;
try {
ws = await openSocket();
ws.send(
JSON.stringify({
type: "req",
id: "c1",
method: "connect",
params: {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: "test",
displayName: "docker-net-e2e",
version: "dev",
platform: process.platform,
mode: "test",
},
caps: [],
auth: { token },
},
}),
);
async function readProtocolVersion() {
const protocol = await import("../../../../dist/gateway/protocol/index.js");
return protocol.PROTOCOL_VERSION;
}
const connectRes = await onceFrame(ws, (frame) => frame?.type === "res" && frame?.id === "c1");
if (!connectRes.ok) {
lastError = responseError("connect", connectRes);
export async function runGatewayNetworkClient(
{ token, url, timeoutMs = readGatewayNetworkClientConnectTimeoutMs() },
deps = {},
) {
const deadline = Date.now() + timeoutMs;
const delayImpl = deps.delay ?? delay;
const onceFrameImpl = deps.onceFrame ?? onceFrame;
const openSocketImpl = deps.openSocket ?? ((targetUrl) => openSocket(targetUrl));
const protocolVersion = deps.protocolVersion ?? (await readProtocolVersion());
const stdout = deps.stdout ?? console.log;
let lastError;
while (Date.now() < deadline) {
let ws;
try {
ws = await openSocketImpl(url);
ws.send(
JSON.stringify({
type: "req",
id: "c1",
method: "connect",
params: {
minProtocol: protocolVersion,
maxProtocol: protocolVersion,
client: {
id: "test",
displayName: "docker-net-e2e",
version: "dev",
platform: process.platform,
mode: "test",
},
caps: [],
auth: { token },
},
}),
);
const connectRes = await onceFrameImpl(
ws,
(frame) => frame?.type === "res" && frame?.id === "c1",
);
if (!connectRes.ok) {
lastError = responseError("connect", connectRes);
if (!isRetryableStartupError(lastError.message)) {
throw lastError;
}
} else {
ws.send(JSON.stringify({ type: "req", id: "h1", method: "health" }));
const healthRes = await onceFrameImpl(
ws,
(frame) => frame?.type === "res" && frame?.id === "h1",
);
if (healthRes.ok) {
stdout("ok");
return;
}
throw responseError("health", healthRes);
}
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (!isRetryableStartupError(lastError.message)) {
throw lastError;
}
} else {
ws.send(JSON.stringify({ type: "req", id: "h1", method: "health" }));
const healthRes = await onceFrame(ws, (frame) => frame?.type === "res" && frame?.id === "h1");
if (healthRes.ok) {
ws.close();
console.log("ok");
process.exit(0);
}
} finally {
ws?.close();
}
throw responseError("health", healthRes);
}
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (!isRetryableStartupError(lastError.message)) {
throw lastError;
}
} finally {
ws?.close();
await delayImpl(500);
}
await delay(500);
throw lastError ?? new Error("connect failed: timeout");
}
throw lastError ?? new Error("connect failed: timeout");
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const url = process.env.GW_URL;
const token = process.env.GW_TOKEN;
if (!url || !token) {
throw new Error("missing GW_URL/GW_TOKEN");
}
await runGatewayNetworkClient({ token, url });
}
+70 -10
View File
@@ -1,7 +1,7 @@
// Gateway Network Client tests cover gateway network client script behavior.
import { EventEmitter } from "node:events";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { runGatewayNetworkClient } from "../../scripts/e2e/lib/gateway-network/client.mjs";
import { readGatewayNetworkClientConnectTimeoutMs } from "../../scripts/e2e/lib/gateway-network/limits.mjs";
import { onceFrame } from "../../scripts/e2e/lib/gateway-network/ws-frames.mjs";
@@ -86,15 +86,75 @@ describe("gateway network WebSocket open guard", () => {
await expect(frame).rejects.toThrow();
});
it("proves health after the authenticated connect handshake", () => {
const client = readFileSync("scripts/e2e/lib/gateway-network/client.mjs", "utf8");
const connectIndex = client.indexOf('method: "connect"');
const healthIndex = client.indexOf('method: "health"');
function createNetworkClientHarness(
responses: Array<{ error?: { message?: string }; ok: boolean }>,
) {
const frames = [...responses];
const sentMethods: string[] = [];
const stdout: string[] = [];
let closeCount = 0;
const socket = {
close: () => {
closeCount += 1;
},
send: (payload: string) => {
sentMethods.push(JSON.parse(payload).method);
},
};
expect(connectIndex).toBeGreaterThanOrEqual(0);
expect(healthIndex).toBeGreaterThan(connectIndex);
expect(client).toContain('responseError("health", healthRes)');
expect(client).toContain('message.includes("closed before open")');
expect(client).toContain('message.includes("closed before frame")');
return {
get closeCount() {
return closeCount;
},
sentMethods,
stdout,
deps: {
delay: async () => {},
onceFrame: async (_ws: unknown, predicate: (frame: unknown) => boolean) => {
const frame = {
type: "res",
id: sentMethods.at(-1) === "connect" ? "c1" : "h1",
...frames.shift(),
};
expect(predicate(frame)).toBe(true);
return frame;
},
openSocket: async () => socket,
protocolVersion: 1,
stdout: (message: string) => {
stdout.push(message);
},
},
};
}
it("proves health after the authenticated connect handshake", async () => {
const harness = createNetworkClientHarness([{ ok: true }, { ok: true }]);
await runGatewayNetworkClient(
{ token: "secret-token", url: "ws://127.0.0.1:12345", timeoutMs: 1000 },
harness.deps,
);
expect(harness.sentMethods).toEqual(["connect", "health"]);
expect(harness.stdout).toEqual(["ok"]);
expect(harness.closeCount).toBe(1);
});
it("fails a connected socket whose health probe fails", async () => {
const harness = createNetworkClientHarness([
{ ok: true },
{ ok: false, error: { message: "health unavailable" } },
]);
await expect(
runGatewayNetworkClient(
{ token: "secret-token", url: "ws://127.0.0.1:12345", timeoutMs: 1000 },
harness.deps,
),
).rejects.toThrow("health failed: health unavailable");
expect(harness.sentMethods).toEqual(["connect", "health"]);
expect(harness.closeCount).toBe(1);
});
});