sync-ws.test.ts 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  2. import { connectWs } from "../src/sync/ws.js";
  3. import type { WsSyncOptions } from "../src/sync/ws.js";
  4. import type { RenderSnapshot, ViewportHandle } from "../src/viewport.js";
  5. /** Minimal WebSocket stand-in with instance tracking for reconnect tests. */
  6. class FakeWebSocket {
  7. static instances: FakeWebSocket[] = [];
  8. static OPEN = 1;
  9. static CONNECTING = 0;
  10. readyState = FakeWebSocket.CONNECTING;
  11. onopen: (() => void) | null = null;
  12. onmessage: ((event: { data: string }) => void) | null = null;
  13. onclose: (() => void) | null = null;
  14. onerror: (() => void) | null = null;
  15. sent: string[] = [];
  16. constructor(public url: string) {
  17. FakeWebSocket.instances.push(this);
  18. }
  19. send(data: string): void {
  20. this.sent.push(data);
  21. }
  22. close(): void {
  23. this.readyState = 3; // CLOSED
  24. }
  25. open(): void {
  26. this.readyState = FakeWebSocket.OPEN;
  27. this.onopen?.();
  28. }
  29. receive(frame: unknown): void {
  30. this.onmessage?.({ data: JSON.stringify(frame) });
  31. }
  32. drop(): void {
  33. this.onclose?.();
  34. }
  35. }
  36. function fakeViewport(): ViewportHandle {
  37. return {
  38. render(_snapshot?: RenderSnapshot): void {},
  39. tileAt(): { x: number; y: number } | null {
  40. return null;
  41. },
  42. resize(): void {},
  43. destroy(): void {},
  44. };
  45. }
  46. function opts(overrides: Partial<WsSyncOptions> = {}): WsSyncOptions {
  47. return { url: "ws://127.0.0.1:1/mcp", viewport: fakeViewport(), ...overrides };
  48. }
  49. beforeEach(() => {
  50. vi.useFakeTimers();
  51. FakeWebSocket.instances = [];
  52. vi.stubGlobal("WebSocket", FakeWebSocket);
  53. });
  54. afterEach(() => {
  55. vi.unstubAllGlobals();
  56. vi.useRealTimers();
  57. });
  58. describe("connectWs sound forwarding (bug-fix round)", () => {
  59. it("forwards bgm-play and bgm-stop event frames to the sound player", () => {
  60. const calls: { event: string; payload: unknown }[] = [];
  61. const sound = { onEvent: (frame: { event: string; payload: unknown }) => calls.push(frame) } as never;
  62. connectWs(opts({ sound }));
  63. const socket = FakeWebSocket.instances[0]!;
  64. socket.open();
  65. socket.receive({ type: "event", event: "bgm-play", payload: { soundId: "bgm-spring", fadeMs: 1500 } });
  66. socket.receive({ type: "event", event: "bgm-stop", payload: { fadeMs: 800 } });
  67. expect(calls).toEqual([
  68. { type: "event", event: "bgm-play", payload: { soundId: "bgm-spring", fadeMs: 1500 } },
  69. { type: "event", event: "bgm-stop", payload: { fadeMs: 800 } },
  70. ]);
  71. });
  72. });
  73. describe("connectWs reconnect lifecycle (bug-fix round)", () => {
  74. it("destroy() cancels a queued reconnect — no socket leaks after teardown", () => {
  75. const handle = connectWs(opts());
  76. const first = FakeWebSocket.instances[0]!;
  77. first.open();
  78. first.drop(); // schedule reconnect
  79. expect(FakeWebSocket.instances).toHaveLength(1);
  80. handle.destroy();
  81. vi.advanceTimersByTime(600);
  82. expect(FakeWebSocket.instances).toHaveLength(1); // no second socket created
  83. });
  84. it("reconnects while alive and keeps working after a reconnect", () => {
  85. const handle = connectWs(opts());
  86. const first = FakeWebSocket.instances[0]!;
  87. first.open();
  88. first.drop();
  89. vi.advanceTimersByTime(500);
  90. expect(FakeWebSocket.instances).toHaveLength(2);
  91. const second = FakeWebSocket.instances[1]!;
  92. second.open();
  93. second.receive({ type: "event", event: "bgm-stop", payload: {} });
  94. handle.destroy();
  95. });
  96. });