// SPDX-License-Identifier: MIT // // Proves the connection manager runs over the standard built-in WebSocket // (Cloudflare Workers / browser / Bun) with no `ws` and no ping frames — keepalive uses an // app message and verify falls back to readyState. import { describe, expect, it } from "vitest"; import type { RetryConfig } from "@kuralle-syrinx/core"; import { WebSocketConnection, type SocketData } from "./index.js"; import { wrapWebSocket, type WebSocketEventLike, type WebSocketLike } from "./web-socket.js"; const FAST_RETRY: RetryConfig = { maxAttempts: 3, baseDelayMs: 10, maxDelayMs: 40 }; /** A controllable standard-WebSocket-shaped socket — no ping, addEventListener-based. */ class FakeWebSocket implements WebSocketLike { readyState = 0; binaryType = "blob"; readonly sent: SocketData[] = []; private readonly listeners = new Map void>>(); send(data: string | ArrayBufferView | ArrayBuffer): void { this.sent.push(typeof data === "string" ? data : new Uint8Array(data as ArrayBuffer)); } close(): void { this.readyState = 3; this.emit("close", { code: 1000, reason: "" }); } addEventListener(type: string, listener: (event: WebSocketEventLike) => void): void { const list = this.listeners.get(type) ?? []; list.push(listener); this.listeners.set(type, list); } private emit(type: string, event: WebSocketEventLike): void { for (const l of this.listeners.get(type) ?? []) l(event); } fireOpen(): void { this.readyState = 1; this.emit("open", {}); } fireMessage(data: string | ArrayBuffer): void { this.emit("message", { data }); } } describe("WebSocketConnection over the standard WebSocket (Workers/browser)", () => { it("connects, delivers text + binary, and keepalives with an app message (no ping)", async () => { const fake = new FakeWebSocket(); const received: Array<{ data: SocketData; isBinary: boolean }> = []; const conn = new WebSocketConnection({ url: () => "wss://example/v1", socketFactory: () => wrapWebSocket(fake), retry: FAST_RETRY, keepAliveIntervalMs: 20, keepAliveMessage: () => JSON.stringify({ type: "KeepAlive" }), onMessage: (data, isBinary) => received.push({ data, isBinary }), }); const connected = conn.connect(); fake.fireOpen(); await connected; expect(conn.isReady).toBe(true); fake.fireMessage("hello-text"); fake.fireMessage(new Uint8Array([1, 2, 3]).buffer); await new Promise((resolve) => setTimeout(resolve, 60)); expect(received[0]).toEqual({ data: "hello-text", isBinary: false }); const binary = received.find((r) => r.isBinary); expect(binary?.data).toEqual(new Uint8Array([1, 2, 3])); // KeepAlive went out as an app message, since the built-in WebSocket has no ping frame. expect(fake.sent.filter((m) => typeof m === "string" && m.includes("KeepAlive")).length).toBeGreaterThanOrEqual(1); await conn.close(); expect(fake.readyState).toBe(3); }); it("verify fails when livenessProbe gets no reply and reconnect continues", async () => { let activeFake: FakeWebSocket | null = null; let probeCalls = 0; let reconnecting = 0; const conn = new WebSocketConnection({ url: () => "wss://example/v1", socketFactory: () => { activeFake = new FakeWebSocket(); queueMicrotask(() => activeFake!.fireOpen()); return wrapWebSocket(activeFake); }, retry: FAST_RETRY, minStableMs: 0, connectTimeoutMs: 500, livenessProbe: async () => { probeCalls += 1; await new Promise((resolve) => setTimeout(resolve, 20)); return false; }, onMessage: () => undefined, onReconnecting: () => { reconnecting += 1; }, }); await conn.connect(); expect(conn.isReady).toBe(true); activeFake!.close(); await new Promise((resolve) => setTimeout(resolve, 500)); expect(probeCalls).toBeGreaterThanOrEqual(1); expect(reconnecting).toBeGreaterThanOrEqual(1); await conn.close(); }); }); describe("wrapWebSocket skip-open (workerd fetch-upgrade)", () => { it("invokes onOpen via microtask when the socket is already open", async () => { const fake = new FakeWebSocket(); fake.readyState = 1; let opened = false; wrapWebSocket(fake).onOpen(() => { opened = true; }); expect(opened).toBe(false); await new Promise((resolve) => queueMicrotask(resolve)); expect(opened).toBe(true); }); });