Files
openclaw/test/scripts/gateway-ws-client.test.ts
Peter Steinberger fe261b0f59 chore(tooling): typecheck root test/** with a dedicated tsgo lane (#104475)
* chore(types): add declaration files for scripts/lib and scripts/e2e modules

* chore(types): add declaration files for top-level script modules (a-m)

* chore(types): add declaration files for top-level script modules (n-z)

* test: use a non-secret-shaped gateway token fixture

* test: type ci workflow guard helpers for the root test lane

* chore(tooling): typecheck root test/** with a dedicated tsgo lane

- test/tsconfig/tsconfig.test.root.json: root-test program (strict unused checks,
  fixtures excluded; two Docker E2E clients that import built dist/** stay out,
  same rationale as the scripts/e2e exclusion in tsconfig.scripts.json)
- tsgo:test:root wired into tsgo:test, check:test-types, scripts/check.mjs, and
  the ci.yml test-types shard, mirroring the tsgo:scripts lane (#104348)
- changed-lane routing: test/**/*.ts (excluding fixtures) and the lane tsconfig
  now trigger 'typecheck test root' in check:changed; previously test/ paths ran
  lint only, so harness type errors surfaced first in CI (#104287 envDir case)
- burn down all 1071 latent type errors in the program: precise param/local
  types across test/scripts, test/vitest, test/e2e, and transitive scripts/e2e
  program members; 205 sibling .d.mts declaration files for imported .mjs
  modules (committed separately); zero any, zero ts-expect-error
- resolve the pre-existing testing star-export ambiguity in
  scripts/e2e/parallels/common.ts with an explicit re-export

Closes #104388

* chore(types): correct declaration fidelity per structured review

- re-derive 51 .d.mts files from implementation data flow instead of
  initializers: fix a wrong never return (runTestProjectsDelegation returns
  the child), add encoding-sensitive exec/spawn overloads (plain-gh), restore
  the full release profile union, make parsed paths string | null, add missing
  parseArgs fields via help/non-help unions, add a missing sibling declaration
  (budget-number-args), drop 15 unused lint directives
- precise install-record/tuple typing removes the type-aware oxlint
  regressions the first declarations caused in scripts/e2e implementations
- route .mts declaration edits under test/ to the testRoot lane and reference
  the test-root project from tsconfig.projects.json so tsgo:all covers it
  (closes both review findings against the lane wiring)

* chore(scripts): keep telegram runner dist typing structural for the boundary guard

* chore(types): declare runtime pack and gateway readiness exports added on main

* test: pin the importTargetPlan form of the plugin-contract plan import

The guard expectation still referenced the raw await import( form that
7ae5996bb3 (#103975) replaced with the importTargetPlan fallback helper;
the assertion fails on current main.
2026-07-11 06:15:41 -07:00

183 lines
5.3 KiB
TypeScript

// Gateway Ws Client tests cover gateway ws client script behavior.
import { createServer, type Server } from "node:http";
import type { Duplex } from "node:stream";
import { afterEach, describe, expect, it } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
import { createGatewayWsClient } from "../../scripts/dev/gateway-ws-client.js";
let server: Server | undefined;
let wss: WebSocketServer | undefined;
afterEach(async () => {
await new Promise<void>((resolve) => {
wss?.close(() => resolve());
if (!wss) {
resolve();
}
});
wss = undefined;
await new Promise<void>((resolve, reject) => {
server?.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
if (!server) {
resolve();
}
});
server = undefined;
});
async function listen(handler: (ws: WebSocket) => void): Promise<string> {
server = createServer();
wss = new WebSocketServer({ server });
wss.on("connection", handler);
await new Promise<void>((resolve) => {
server?.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("test websocket server did not get a TCP address");
}
return `ws://127.0.0.1:${address.port}`;
}
async function listenStalledUpgrade(): Promise<{ close: () => Promise<void>; url: string }> {
const stalledServer = createServer();
const sockets = new Set<Duplex>();
let closing = false;
stalledServer.on("connection", (socket) => {
sockets.add(socket);
socket.once("close", () => {
sockets.delete(socket);
});
if (closing) {
socket.destroy();
}
});
stalledServer.on("upgrade", (_req, socket) => {
// Keep the socket open without completing the websocket handshake.
sockets.add(socket);
if (closing) {
socket.destroy();
}
});
await new Promise<void>((resolve) => {
stalledServer.listen(0, "127.0.0.1", resolve);
});
const address = stalledServer.address();
if (!address || typeof address === "string") {
throw new Error("test websocket server did not get a TCP address");
}
return {
close: async () => {
closing = true;
for (const socket of sockets) {
socket.destroy();
}
await new Promise<void>((resolve, reject) => {
stalledServer.close((error) => (error ? reject(error) : resolve()));
});
},
url: `ws://127.0.0.1:${address.port}`,
};
}
describe("createGatewayWsClient", () => {
it("rejects pending RPC requests when the client closes", async () => {
const url = await listen(() => {});
const client = createGatewayWsClient({ url });
await client.waitOpen();
const pending = client.request("health", {}, 1000);
client.close();
await expect(pending).rejects.toThrow("gateway websocket client closed");
});
it("rejects pending RPC requests when the gateway closes the socket", async () => {
const url = await listen((ws) => {
ws.on("message", () => {
ws.close(1011, "boom");
});
});
const client = createGatewayWsClient({ url });
await client.waitOpen();
await expect(client.request("health", {}, 1000)).rejects.toThrow(
"gateway websocket closed (1011): boom",
);
client.close();
});
it("rejects pending RPC requests when the socket errors", async () => {
const url = await listen(() => {});
const client = createGatewayWsClient({ url });
await client.waitOpen();
const pending = client.request("health", {}, 1000);
client.ws.emit("error", new Error("socket exploded"));
await expect(pending).rejects.toThrow("socket exploded");
client.close();
});
it("rejects websocket closes before opening", async () => {
const stalled = await listenStalledUpgrade();
const client = createGatewayWsClient({ openTimeoutMs: 1000, url: stalled.url });
const opened = client.waitOpen();
client.ws.emit("close", 1006, Buffer.from("bye"));
try {
await expect(opened).rejects.toThrow("closed before open (1006): bye");
} finally {
client.close();
await stalled.close();
}
});
it("terminates stalled websocket handshakes after the open timeout", async () => {
const stalled = await listenStalledUpgrade();
const client = createGatewayWsClient({ openTimeoutMs: 5, url: stalled.url });
try {
await expect(client.waitOpen()).rejects.toThrow("ws open timeout");
await waitFor(() => client.ws.readyState === WebSocket.CLOSED);
} finally {
client.close();
await stalled.close();
}
});
it("uses caller-specific websocket open timeout messages", async () => {
const stalled = await listenStalledUpgrade();
const client = createGatewayWsClient({
openTimeoutMessage: "gateway ws open timeout",
openTimeoutMs: 5,
url: stalled.url,
});
try {
await expect(client.waitOpen()).rejects.toThrow("gateway ws open timeout");
} finally {
client.close();
await stalled.close();
}
});
});
async function waitFor(condition: () => boolean, timeoutMs = 1_000) {
const startedAt = Date.now();
while (!condition()) {
if (Date.now() - startedAt > timeoutMs) {
throw new Error("timed out waiting for condition");
}
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
}
}