import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { connectWs } from "../src/sync/ws.js"; import type { WsSyncOptions } from "../src/sync/ws.js"; import type { RenderSnapshot, ViewportHandle } from "../src/viewport.js"; /** Minimal WebSocket stand-in with instance tracking for reconnect tests. */ class FakeWebSocket { static instances: FakeWebSocket[] = []; static OPEN = 1; static CONNECTING = 0; readyState = FakeWebSocket.CONNECTING; onopen: (() => void) | null = null; onmessage: ((event: { data: string }) => void) | null = null; onclose: (() => void) | null = null; onerror: (() => void) | null = null; sent: string[] = []; constructor(public url: string) { FakeWebSocket.instances.push(this); } send(data: string): void { this.sent.push(data); } close(): void { this.readyState = 3; // CLOSED } open(): void { this.readyState = FakeWebSocket.OPEN; this.onopen?.(); } receive(frame: unknown): void { this.onmessage?.({ data: JSON.stringify(frame) }); } drop(): void { this.onclose?.(); } } function fakeViewport(): ViewportHandle { return { render(_snapshot?: RenderSnapshot): void {}, tileAt(): { x: number; y: number } | null { return null; }, resize(): void {}, destroy(): void {}, }; } function opts(overrides: Partial = {}): WsSyncOptions { return { url: "ws://127.0.0.1:1/mcp", viewport: fakeViewport(), ...overrides }; } beforeEach(() => { vi.useFakeTimers(); FakeWebSocket.instances = []; vi.stubGlobal("WebSocket", FakeWebSocket); }); afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); describe("connectWs sound forwarding (bug-fix round)", () => { it("forwards bgm-play and bgm-stop event frames to the sound player", () => { const calls: { event: string; payload: unknown }[] = []; const sound = { onEvent: (frame: { event: string; payload: unknown }) => calls.push(frame) } as never; connectWs(opts({ sound })); const socket = FakeWebSocket.instances[0]!; socket.open(); socket.receive({ type: "event", event: "bgm-play", payload: { soundId: "bgm-spring", fadeMs: 1500 } }); socket.receive({ type: "event", event: "bgm-stop", payload: { fadeMs: 800 } }); expect(calls).toEqual([ { type: "event", event: "bgm-play", payload: { soundId: "bgm-spring", fadeMs: 1500 } }, { type: "event", event: "bgm-stop", payload: { fadeMs: 800 } }, ]); }); }); describe("connectWs reconnect lifecycle (bug-fix round)", () => { it("destroy() cancels a queued reconnect — no socket leaks after teardown", () => { const handle = connectWs(opts()); const first = FakeWebSocket.instances[0]!; first.open(); first.drop(); // schedule reconnect expect(FakeWebSocket.instances).toHaveLength(1); handle.destroy(); vi.advanceTimersByTime(600); expect(FakeWebSocket.instances).toHaveLength(1); // no second socket created }); it("reconnects while alive and keeps working after a reconnect", () => { const handle = connectWs(opts()); const first = FakeWebSocket.instances[0]!; first.open(); first.drop(); vi.advanceTimersByTime(500); expect(FakeWebSocket.instances).toHaveLength(2); const second = FakeWebSocket.instances[1]!; second.open(); second.receive({ type: "event", event: "bgm-stop", payload: {} }); handle.destroy(); }); });