From b10ebc541e874306ec05575ce100c53f017634e1 Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 03:33:09 +0000 Subject: [PATCH] test: cover client reconnect failures --- tests/transport-reconnect.test.ts | 163 ++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 tests/transport-reconnect.test.ts diff --git a/tests/transport-reconnect.test.ts b/tests/transport-reconnect.test.ts new file mode 100644 index 0000000..e03f3d0 --- /dev/null +++ b/tests/transport-reconnect.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type EventName = "open" | "close" | "error" | "message"; +type EventHandler = (...args: any[]) => void; + +const socketInstances: MockWebSocket[] = []; +const pendingBehaviors: Array<"open" | "error"> = []; + +class MockWebSocket { + static readonly OPEN = 1; + static readonly CLOSED = 3; + + readyState = 0; + sent: string[] = []; + private readonly handlers = new Map(); + + constructor(public readonly url: string) { + socketInstances.push(this); + const behavior = pendingBehaviors.shift() ?? "open"; + + queueMicrotask(() => { + if (behavior === "open") { + this.readyState = MockWebSocket.OPEN; + this.emit("open"); + return; + } + + const error = new Error(`mock connect failure for ${url}`); + this.emit("error", error); + this.emit("close", 1006, Buffer.from("connect failed")); + this.readyState = MockWebSocket.CLOSED; + }); + } + + once(event: EventName, handler: EventHandler): this { + const onceHandler: EventHandler = (...args: any[]) => { + this.off(event, onceHandler); + handler(...args); + }; + return this.on(event, onceHandler); + } + + on(event: EventName, handler: EventHandler): this { + const existing = this.handlers.get(event) ?? []; + existing.push(handler); + this.handlers.set(event, existing); + return this; + } + + off(event: EventName, handler: EventHandler): this { + const existing = this.handlers.get(event) ?? []; + this.handlers.set( + event, + existing.filter((candidate) => candidate !== handler) + ); + return this; + } + + send(message: string): void { + this.sent.push(message); + } + + close(code = 1000, reason = ""): + void { + this.readyState = MockWebSocket.CLOSED; + this.emit("close", code, Buffer.from(reason)); + } + + emit(event: EventName, ...args: any[]): void { + for (const handler of [...(this.handlers.get(event) ?? [])]) { + handler(...args); + } + } +} + +vi.mock("ws", () => ({ + WebSocket: MockWebSocket +})); + +describe("Yonexus.Client transport reconnect behavior", () => { + beforeEach(() => { + vi.useFakeTimers(); + socketInstances.length = 0; + pendingBehaviors.length = 0; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("CF-02: retries initial connect with exponential backoff when server is unreachable", async () => { + const { createClientTransport } = await import("../plugin/core/transport.js"); + const onStateChange = vi.fn(); + const onError = vi.fn(); + + pendingBehaviors.push("error", "error", "open"); + + const transport = createClientTransport({ + config: { + mainHost: "ws://localhost:8787", + identifier: "client-a", + notifyBotToken: "stub-token", + adminUserId: "admin-user" + }, + onMessage: vi.fn(), + onStateChange, + onError + }); + + await expect(transport.connect()).rejects.toThrow("mock connect failure"); + expect(socketInstances).toHaveLength(1); + expect(transport.state).toBe("disconnected"); + + await vi.advanceTimersByTimeAsync(999); + expect(socketInstances).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(1); + expect(socketInstances).toHaveLength(2); + + await vi.advanceTimersByTimeAsync(1_999); + expect(socketInstances).toHaveLength(2); + + await vi.advanceTimersByTimeAsync(1); + expect(socketInstances).toHaveLength(3); + expect(transport.state).toBe("connected"); + expect(onError.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(onStateChange).toHaveBeenCalledWith("connecting"); + expect(onStateChange).toHaveBeenCalledWith("connected"); + }); + + it("CF-01: reconnects with backoff after network partition closes an established connection", async () => { + const { createClientTransport } = await import("../plugin/core/transport.js"); + + pendingBehaviors.push("open", "open"); + + const transport = createClientTransport({ + config: { + mainHost: "ws://localhost:8787", + identifier: "client-a", + notifyBotToken: "stub-token", + adminUserId: "admin-user" + }, + onMessage: vi.fn(), + onStateChange: vi.fn(), + onError: vi.fn() + }); + + await transport.connect(); + expect(socketInstances).toHaveLength(1); + expect(transport.state).toBe("connected"); + + socketInstances[0].emit("close", 1006, Buffer.from("network partition")); + expect(transport.state).toBe("disconnected"); + + await vi.advanceTimersByTimeAsync(999); + expect(socketInstances).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(1); + expect(socketInstances).toHaveLength(2); + expect(transport.state).toBe("connected"); + }); +});