From 31e9a1c6c56e878efd75e63acf2f787ff8957cd9 Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Sun, 26 Jul 2026 22:04:02 -0700 Subject: [PATCH] test(transport): bring the WebSocket transport to 100% line coverage Adds the missing offline tests for src/transport/websocket: - ReconnectingWebSocket WebSocket-API surface: bufferedAmount/extensions/ protocol/binaryType getters (with and without an underlying socket) and the on* attribute handlers (assign, read back, reassign, clear, shared handler across two attributes). - The local CloseEvent fallback class: the test process now runs without a global CloseEvent (bunfig [test] preload, tests/_noCloseEvent.ts), so every module instance takes the older-runtime branch and every dispatched close event exercises the fallback; test fakes dispatch the stashed real class. - WebSocketTransport resubscribe/timeout accessor passthrough. Running the suite under the CloseEvent-less preload also works around a Bun coverage misattribution: with the default branch taken, Bun's lcov emits phantom 0-hit DA records for the module prelude (lines 12-120, incl. erased type declarations) that no execution can cover, and mixing one fallback-branch module instance into the process makes it shadow every other instance's counters. The uniform fallback profile reports the file cleanly. HL_OFFLINE=1 bun test tests/ --coverage: src/transport/websocket 970/1175 (82.6%) -> 1019/1019 (100%) DA lines, 0 unhit branches; 1262 tests, 0 fail; bun run check passes. --- bunfig.toml | 3 + tests/_noCloseEvent.ts | 27 ++++ tests/perf/_helpers.ts | 3 +- tests/transport/websocket/_mock.ts | 5 +- .../websocket/_reconnectingSocket.test.ts | 122 +++++++++++++++++- tests/transport/websocket/mod.test.ts | 16 +++ 6 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 tests/_noCloseEvent.ts diff --git a/bunfig.toml b/bunfig.toml index bbc87db1..c22046d7 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -5,5 +5,8 @@ minimumReleaseAge = 259200 [test] +# Run the whole suite without a global `CloseEvent`, so the WebSocket transport's local +# fallback class (its older-runtime path) is what every close event exercises. +preload = ["./tests/_noCloseEvent.ts"] # Test and development tooling files should not count toward the library's coverage numbers. coveragePathIgnorePatterns = ["tests/**", ".dev/**"] diff --git a/tests/_noCloseEvent.ts b/tests/_noCloseEvent.ts new file mode 100644 index 00000000..3ee440fa --- /dev/null +++ b/tests/_noCloseEvent.ts @@ -0,0 +1,27 @@ +/** + * Preload for `bun test` (bunfig `[test] preload`): removes the global `CloseEvent` + * before any test file loads. + * + * `src/transport/websocket/_reconnectingSocket.ts` picks its close-event class once, + * at module evaluation: the runtime's `CloseEvent` when present, a local fallback + * otherwise. Deleting the global up front makes every module instance in the test + * process take the fallback branch — the path runtimes without a global `CloseEvent` + * execute in production — so the whole suite exercises it, instead of a single test + * re-importing the module into a divergent second instance. The runtime's original + * class stays available to test fakes as {@linkcode RealCloseEvent}. + * @module + */ + +const stash = globalThis as { + CloseEvent?: typeof CloseEvent; + __realCloseEvent?: typeof CloseEvent; +}; + +stash.__realCloseEvent = stash.CloseEvent; +delete stash.CloseEvent; + +const real = stash.__realCloseEvent; +if (real === undefined) throw new Error("CloseEvent was already missing before the preload ran"); + +/** The runtime's original `CloseEvent`, stashed before removal; for test fakes that dispatch close events. */ +export const RealCloseEvent: typeof CloseEvent = real; diff --git a/tests/perf/_helpers.ts b/tests/perf/_helpers.ts index 0a465908..ce1be098 100644 --- a/tests/perf/_helpers.ts +++ b/tests/perf/_helpers.ts @@ -10,6 +10,7 @@ */ import type { IRequestTransport } from "@bloxwap/hyperliquid"; +import { RealCloseEvent } from "../_noCloseEvent.ts"; /** * Well-known test private key (the same one used by `tests/signing/mod.test.ts`). @@ -155,7 +156,7 @@ export class MockWebSocket extends EventTarget { close(): void { if (this.readyState >= 2) return; this.readyState = 3; // CLOSED - this.dispatchEvent(new CloseEvent("close", { code: 1000, wasClean: true })); + this.dispatchEvent(new RealCloseEvent("close", { code: 1000, wasClean: true })); } /** Injects a server-to-client frame (a raw payload object, JSON-encoded here). */ diff --git a/tests/transport/websocket/_mock.ts b/tests/transport/websocket/_mock.ts index 30ca65d2..6a26cbbb 100644 --- a/tests/transport/websocket/_mock.ts +++ b/tests/transport/websocket/_mock.ts @@ -8,6 +8,7 @@ import { ReconnectingWebSocket, ReconnectingWebSocketError, } from "../../../src/transport/websocket/_reconnectingSocket.ts"; +import { RealCloseEvent } from "../../_noCloseEvent.ts"; /** In-memory ReconnectingWebSocket stand-in: records frames out, replays frames in. */ // @ts-expect-error: Mocking WebSocket for testing purposes @@ -30,7 +31,7 @@ export class MockWebSocket extends EventTarget implements ReconnectingWebSocket /** Network drop: rews reports CONNECTING for any reconnection phase. */ disconnect(): void { this.readyState = ReconnectingWebSocket.CONNECTING; - this.dispatchEvent(new CloseEvent("close", { code: 1006 })); + this.dispatchEvent(new RealCloseEvent("close", { code: 1006 })); } /** Failed connection attempt: rews reports CONNECTING when `error` fires. */ @@ -52,7 +53,7 @@ export class MockWebSocket extends EventTarget implements ReconnectingWebSocket if (this.terminationSignal.aborted) return; this.terminationController.abort(new ReconnectingWebSocketError("TERMINATED_BY_USER", cause)); this.readyState = ReconnectingWebSocket.CLOSED; - this.dispatchEvent(new CloseEvent("close", { code: 1006 })); + this.dispatchEvent(new RealCloseEvent("close", { code: 1006 })); } /** Replays a server frame to every listener. */ diff --git a/tests/transport/websocket/_reconnectingSocket.test.ts b/tests/transport/websocket/_reconnectingSocket.test.ts index e6d45b8d..c0cab3a2 100644 --- a/tests/transport/websocket/_reconnectingSocket.test.ts +++ b/tests/transport/websocket/_reconnectingSocket.test.ts @@ -1,7 +1,9 @@ /** * Tests for the reconnecting WebSocket: retry policy, backoff scheduling, * offline buffering, and termination semantics, driven by a fake global - * `WebSocket` the tests steer by hand. + * `WebSocket` the tests steer by hand. The test process runs without a global + * `CloseEvent` (see `tests/_noCloseEvent.ts`), so every close event the wrapper + * dispatches also exercises the module's local `CloseEvent` fallback class. * @module */ @@ -15,6 +17,7 @@ import { } from "../../../src/transport/websocket/_reconnectingSocket.ts"; import { WebSocketDispatcher, WebSocketRequestError } from "../../../src/transport/websocket/_dispatcher.ts"; import { HyperliquidEventTarget } from "../../../src/transport/websocket/_events.ts"; +import { RealCloseEvent } from "../../_noCloseEvent.ts"; import { drain } from "./_mock.ts"; // ============================================================================= @@ -57,7 +60,7 @@ class FakeWebSocket extends EventTarget { if (this.readyState >= 2) return; this.readyState = 3; // Stale by the time it fires (the wrapper detaches before calling close()), so timing is irrelevant. - this.dispatchEvent(new CloseEvent("close", { code: code ?? 1000, reason: reason ?? "" })); + this.dispatchEvent(new RealCloseEvent("close", { code: code ?? 1000, reason: reason ?? "" })); } /** Simulates the handshake completing. */ @@ -79,7 +82,7 @@ class FakeWebSocket extends EventTarget { /** Simulates the connection dropping. */ serverClose(code = 1006, reason = ""): void { this.readyState = 3; - this.dispatchEvent(new CloseEvent("close", { code, reason })); + this.dispatchEvent(new RealCloseEvent("close", { code, reason })); } } @@ -578,6 +581,25 @@ describe("ReconnectingWebSocket", () => { assertEquals(closes.length, 1); }); + test("close events come from the local fallback class, with the full CloseEvent shape", () => { + const ws = createSocket(); + lastSocket().serverOpen(); + + const closes: CloseEvent[] = []; + ws.addEventListener("close", (event) => closes.push(event)); + + ws.close(3000, "bye"); + + assertEquals(closes.length, 1); + assertEquals(closes[0].code, 3000); + assertEquals(closes[0].reason, "bye"); + assertEquals(closes[0].wasClean, true); + // The test process has no global CloseEvent (see tests/_noCloseEvent.ts), so the + // wrapper's events are built by its local fallback class: the standard shape + // holds, but they are not instances of the runtime's CloseEvent. + assertFalse(closes[0] instanceof RealCloseEvent); + }); + test("close() from within a close listener dispatches no second close", async () => { const ws = createSocket(); @@ -688,6 +710,100 @@ describe("ReconnectingWebSocket", () => { }); }); + describe("WebSocket API surface", () => { + test("exposes the underlying socket's metadata through its getters", () => { + const ws = createSocket(); + assertEquals(ws.retryCount, 0); // no failed attempt yet + assertEquals(ws.binaryType, "blob"); // default before any assignment + + // A string url connects synchronously: the underlying socket already exists. + const socket = lastSocket(); + socket.bufferedAmount = 1234; + socket.extensions = "permessage-deflate"; + socket.protocol = "chat"; + + assertEquals(ws.bufferedAmount, 1234); + assertEquals(ws.extensions, "permessage-deflate"); + assertEquals(ws.protocol, "chat"); + + // The setter propagates to the underlying socket. + ws.binaryType = "arraybuffer"; + assertEquals(ws.binaryType, "arraybuffer"); + assertEquals(socket.binaryType, "arraybuffer"); + ws.close(); + }); + + test("metadata getters fall back while no underlying socket exists", async () => { + // A factory that never resolves keeps `_socket` unset for the whole test. + const ws = createSocket(() => new Promise(() => {})); + await drain(); + + assertEquals(ws.bufferedAmount, 0); + assertEquals(ws.extensions, ""); + assertEquals(ws.protocol, ""); + + // The binaryType setter touches only the stored value; it applies once a socket exists. + ws.binaryType = "arraybuffer"; + assertEquals(ws.binaryType, "arraybuffer"); + ws.close(); + }); + + test("on* attributes register, replace, and clear event handlers", () => { + const ws = createSocket(); + + const fired: string[] = []; + const onopen = (): void => void fired.push("open"); + const firstMessage = (event: MessageEvent): void => void fired.push(`message:${event.data}`); + const secondMessage = (): void => void fired.push("message:second"); + const onerror = (): void => void fired.push("error"); + const onclose = (): void => void fired.push("close"); + + ws.onopen = onopen; + ws.onmessage = firstMessage; + ws.onerror = onerror; + ws.onclose = onclose; + // The getters return the originally assigned handlers, not the registered wrappers. + assertEquals(ws.onopen, onopen); + assertEquals(ws.onmessage, firstMessage); + assertEquals(ws.onerror, onerror); + assertEquals(ws.onclose, onclose); + + lastSocket().serverOpen(); + lastSocket().serverMessage("hello"); + lastSocket().serverError(); + assertEquals(fired, ["open", "message:hello", "error"]); + + // Reassigning detaches the previous handler; assigning null detaches entirely. + ws.onmessage = secondMessage; + assertEquals(ws.onmessage, secondMessage); + ws.onerror = null; + assertEquals(ws.onerror, null); + + lastSocket().serverMessage("again"); + lastSocket().serverError(); + assertEquals(fired, ["open", "message:hello", "error", "message:second"]); + + ws.close(); // fires the final close + assertEquals(fired, ["open", "message:hello", "error", "message:second", "close"]); + }); + + test("the same function assigned to two on* attributes fires for both", () => { + const ws = createSocket(); + + let calls = 0; + const handler = (): void => void calls++; + ws.onopen = handler; + ws.onerror = handler; + + lastSocket().serverOpen(); + lastSocket().serverError(); + + // One wrapper per attribute: the shared handler is not deduplicated away. + assertEquals(calls, 2); + ws.close(); + }); + }); + describe("dispatcher integration", () => { /** Wraps `ws` in the real dispatcher stack; returns the dispatcher. */ function dispatcherOn(ws: ReconnectingWebSocket): WebSocketDispatcher { diff --git a/tests/transport/websocket/mod.test.ts b/tests/transport/websocket/mod.test.ts index f97b09a1..15414d52 100644 --- a/tests/transport/websocket/mod.test.ts +++ b/tests/transport/websocket/mod.test.ts @@ -164,6 +164,22 @@ describe("WebSocketTransport", () => { await subscription.unsubscribe(); }); + test("resubscribe and timeout accessors proxy to the subscription manager and dispatcher", async () => { + await using transport = createTransport(url); + + assertEquals(transport.resubscribe, true); // default + transport.resubscribe = false; + assertEquals(transport.resubscribe, false); + transport.resubscribe = true; + assertEquals(transport.resubscribe, true); + + assertEquals(transport.timeout, 10_000); // default + transport.timeout = 5_000; + assertEquals(transport.timeout, 5_000); + transport.timeout = null; // disabled + assertEquals(transport.timeout, null); + }); + describe("ready()", () => { test("resolves immediately if already open", async () => { await using transport = createTransport(url);