Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"]
27 changes: 27 additions & 0 deletions tests/_noCloseEvent.ts
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 2 additions & 1 deletion tests/perf/_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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). */
Expand Down
5 changes: 3 additions & 2 deletions tests/transport/websocket/_mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. */
Expand All @@ -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. */
Expand Down
122 changes: 119 additions & 3 deletions tests/transport/websocket/_reconnectingSocket.test.ts
Original file line number Diff line number Diff line change
@@ -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
*/

Expand All @@ -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";

// =============================================================================
Expand Down Expand Up @@ -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. */
Expand All @@ -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 }));
}
}

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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<string>(() => {}));
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 {
Expand Down
16 changes: 16 additions & 0 deletions tests/transport/websocket/mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading