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"); }); });