diff --git a/README.md b/README.md index c080374a..0995527f 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,11 @@ bun install # installs once for all workspaces bun dev # starts both client (:3000) and server (:8080) ``` +Run the following command to run tests: +```sh +bun run test +``` + | Directory | Purpose | | ----------------- | -------------------------------------------------------------- | | `apps/server` | Bun HTTP + WebSocket server | diff --git a/apps/server/README.md b/apps/server/README.md index 6dd13e7c..dcdf7fd0 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -8,4 +8,14 @@ To run: bun run dev ``` +To test: +```sh +bun run test +``` + +To get test coverage: +```sh +bun run test:coverage +``` + open http://localhost:3000 diff --git a/apps/server/package.json b/apps/server/package.json index c3f7bf0a..e1b77c96 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -2,7 +2,10 @@ "name": "server", "scripts": { "dev": "bun run --hot src/index.ts", - "start": "bun run src/index.ts" + "start": "bun run src/index.ts", + "test": "bun test", + "test:watch": "bun test --watch", + "test:coverage": "bun test --coverage" }, "dependencies": { "@beatsync/shared": "workspace:*", @@ -13,5 +16,11 @@ }, "devDependencies": { "@types/bun": "latest" + }, + "module": "src/index.ts", + "type": "module", + "private": true, + "peerDependencies": { + "typescript": "^5" } } diff --git a/apps/server/src/roomManager.ts b/apps/server/src/roomManager.ts index a60d436d..27af3341 100644 --- a/apps/server/src/roomManager.ts +++ b/apps/server/src/roomManager.ts @@ -3,6 +3,7 @@ import { epochNow, MoveClientType, WSBroadcastType, + ClientActionEnum, } from "@beatsync/shared"; import { GRID, PositionType } from "@beatsync/shared/types/basic"; import { Server, ServerWebSocket } from "bun"; @@ -324,6 +325,15 @@ class RoomManager { client.position = position; room.clients.set(clientId, client); + const roomUpdateMessage: WSBroadcastType = { + type: "ROOM_EVENT", + event: { + type: ClientActionEnum.Enum.CLIENT_CHANGE, + clients: this.getClients(roomId), + }, + }; + sendBroadcast({ server, roomId, message: roomUpdateMessage }); + // Update spatial audio config this._calculateGainsAndBroadcast({ room, server }); } diff --git a/apps/server/src/test/routes/audioRoutes.test.ts b/apps/server/src/test/routes/audioRoutes.test.ts new file mode 100644 index 00000000..a22eaae7 --- /dev/null +++ b/apps/server/src/test/routes/audioRoutes.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test, beforeAll, afterAll } from "bun:test"; +import { handleGetAudio } from "../../routes/audio"; +import { setupTestAudioDir, cleanupTestAudioDir, createTestAudioFile, createTestServer } from "../utils/testHelpers"; + +describe("Audio Routes", () => { + const server = createTestServer(); + + beforeAll(() => { + setupTestAudioDir(); + }); + + afterAll(() => { + cleanupTestAudioDir(); + server.stop(); + }); + + test("GET request should return 405 Method Not Allowed", async () => { + const request = new Request("http://localhost/audio", { + method: "GET", + }); + + const response = await handleGetAudio(request, server); + expect(response.status).toBe(405); + const text = await response.text(); + expect(text).toBe("Method not allowed"); + }); + + test("POST request without content-type should return 400", async () => { + const request = new Request("http://localhost/audio", { + method: "POST", + body: JSON.stringify({ id: "test.mp3" }), + }); + + const response = await handleGetAudio(request, server); + expect(response.status).toBe(400); + const text = await response.text(); + expect(text).toBe("Content-Type must be application/json"); + }); + + test("POST request with invalid body should return 400", async () => { + const request = new Request("http://localhost/audio", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ invalid: "data" }), + }); + + const response = await handleGetAudio(request, server); + expect(response.status).toBe(400); + const text = await response.text(); + expect(text).toContain("Invalid request data"); + }); + + test("POST request for non-existent file should return 404", async () => { + const request = new Request("http://localhost/audio", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: "nonexistent.mp3" }), + }); + + const response = await handleGetAudio(request, server); + expect(response.status).toBe(404); + const text = await response.text(); + expect(text).toBe("Audio file not found"); + }); + + test("POST request for existing file should return audio file", async () => { + const testFileName = "test-audio.mp3"; + const testContent = "test audio content"; + createTestAudioFile(testFileName, testContent); + + const request = new Request("http://localhost/audio", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: testFileName }), + }); + + const response = await handleGetAudio(request, server); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("audio/mpeg"); + expect(response.headers.get("Content-Length")).toBe(testContent.length.toString()); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + + const responseContent = await response.text(); + expect(responseContent).toBe(testContent); + }); +}); \ No newline at end of file diff --git a/apps/server/src/test/routes/websocket.test.ts b/apps/server/src/test/routes/websocket.test.ts new file mode 100644 index 00000000..754caa44 --- /dev/null +++ b/apps/server/src/test/routes/websocket.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test, beforeAll, afterAll } from "bun:test"; +import { Server } from "bun"; +import { ClientActionEnum } from "@beatsync/shared"; +import { createTestWSServer, createTestWSClient, TestWebSocketClient } from "../utils/wsTestHelpers"; +import { roomManager } from "../../roomManager"; + +describe("WebSocket Routes", () => { + let server: Server; + + beforeAll(() => { + server = createTestWSServer(); + }); + + afterAll(() => { + server.stop(); + }); + + test("should reject connection without required parameters", async () => { + // Try to connect without parameters + const response = await fetch(`http://localhost:${server.port}`); + expect(response.status).toBe(400); + const text = await response.text(); + expect(text).toBe("roomId and userId are required"); + + // Try to connect with only roomId + const response2 = await fetch(`http://localhost:${server.port}?roomId=test`); + expect(response2.status).toBe(400); + const text2 = await response2.text(); + expect(text2).toBe("roomId and userId are required"); + + // Try to connect with only username + const response3 = await fetch(`http://localhost:${server.port}?username=test`); + expect(response3.status).toBe(400); + const text3 = await response3.text(); + expect(text3).toBe("roomId and userId are required"); + }); + + test("should establish connection with valid parameters", async () => { + const client = await createTestWSClient(server, { + roomId: "test-room", + username: "test-user", + clientId: "test-client", + }); + + try { + const clientId = client.getClientId(); + expect(clientId).toBeDefined(); + } finally { + client.close(); + } + }); + + test("should handle multiple clients in a room", async () => { + const client1 = await createTestWSClient(server, { + roomId: "multi-room", + username: "user1", + clientId: "client1", + }); + + const client2 = await createTestWSClient(server, { + roomId: "multi-room", + username: "user2", + clientId: "client2", + }); + + try { + // Both clients should have valid client IDs + const client1Id = client1.getClientId(); + const client2Id = client2.getClientId(); + expect(client1Id).toBeDefined(); + expect(client2Id).toBeDefined(); + + // Both clients should receive room update with 2 clients + const roomEvent = await client1.waitForRoomEvent(); + expect(roomEvent.event.clients.length).toBe(2); + expect(roomEvent.event.clients.map((c: any) => c.username).sort()).toEqual(["user1", "user2"]); + } finally { + client1.close(); + client2.close(); + } + }); + + test("should handle client disconnection", async () => { + const client1 = await createTestWSClient(server, { + roomId: "disconnect-room", + username: "user1", + clientId: "client1", + }); + + const client2 = await createTestWSClient(server, { + roomId: "disconnect-room", + username: "user2", + clientId: "client2", + }); + + try { + // Close client1 + client1.close(); + + // Client2 should receive update about client1's disconnection + const disconnectUpdate = await client2.waitForMessage(msg => + msg.type === "ROOM_EVENT" && + msg.event?.type === ClientActionEnum.Enum.CLIENT_CHANGE && + msg.event?.clients?.length === 1 && + msg.event?.clients[0].username === "user2" + ); + + expect(disconnectUpdate.type).toBe("ROOM_EVENT"); + expect(disconnectUpdate.event.type).toBe(ClientActionEnum.Enum.CLIENT_CHANGE); + expect(disconnectUpdate.event.clients.length).toBe(1); + expect(disconnectUpdate.event.clients[0].username).toBe("user2"); + } finally { + client2.close(); + } + }); + + test("should handle NTP request/response", async () => { + const client = await createTestWSClient(server, { + roomId: "ntp-room", + username: "ntp-user", + clientId: "ntp-client", + }); + + try { + // Send NTP request + const t0 = Date.now(); + client.send({ + type: ClientActionEnum.enum.NTP_REQUEST, + t0, + }); + + // Wait for NTP response + const ntpResponse = await client.waitForMessage(msg => + msg.type === "NTP_RESPONSE" && msg.t0 === t0 + ); + + expect(ntpResponse.type).toBe("NTP_RESPONSE"); + expect(ntpResponse.t0).toBe(t0); + expect(ntpResponse.t1).toBeNumber(); + expect(ntpResponse.t2).toBeNumber(); + expect(ntpResponse.t1).toBeLessThanOrEqual(ntpResponse.t2); + } finally { + client.close(); + } + }); +}); \ No newline at end of file diff --git a/apps/server/src/test/routes/websocketHandlers.test.ts b/apps/server/src/test/routes/websocketHandlers.test.ts new file mode 100644 index 00000000..cfc2c19c --- /dev/null +++ b/apps/server/src/test/routes/websocketHandlers.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, test, beforeAll, afterAll } from "bun:test"; +import { Server } from "bun"; +import { ClientActionEnum, ClientType } from "@beatsync/shared"; +import { createTestWSServer, createTestWSClient, TestWebSocketClient, TestWSMessage } from "../utils/wsTestHelpers"; +import { roomManager } from "../../roomManager"; + +describe("WebSocket Handlers", () => { + let server: Server; + + beforeAll(() => { + server = createTestWSServer(); + }); + + afterAll(() => { + server.stop(); + }); + + test("should handle play/pause messages", async () => { + const client1 = await createTestWSClient(server, { + roomId: "playback-room", + username: "user1", + clientId: "client1", + }); + + const client2 = await createTestWSClient(server, { + roomId: "playback-room", + username: "user2", + clientId: "client2", + }); + + try { + // Send play command + client1.send({ + type: ClientActionEnum.enum.PLAY, + timestamp: 0, + trackTimeSeconds: 0, + audioId: "test-audio", + }); + + // Both clients should receive scheduled action + const playAction1 = await client1.waitForMessage(msg => + msg.type === "SCHEDULED_ACTION" && + msg.scheduledAction?.type === ClientActionEnum.enum.PLAY + ); + const playAction2 = await client2.waitForMessage(msg => + msg.type === "SCHEDULED_ACTION" && + msg.scheduledAction?.type === ClientActionEnum.enum.PLAY + ); + + expect(playAction1.type).toBe("SCHEDULED_ACTION"); + expect(playAction1.scheduledAction.type).toBe(ClientActionEnum.enum.PLAY); + expect(playAction1.serverTimeToExecute).toBeNumber(); + + expect(playAction2).toEqual(playAction1); + } finally { + client1.close(); + client2.close(); + } + }); + + test("should handle spatial audio control", async () => { + const client = await createTestWSClient(server, { + roomId: "spatial-room", + username: "spatial-user", + clientId: "spatial-client", + }); + + try { + // Start spatial audio + client.send({ + type: ClientActionEnum.enum.START_SPATIAL_AUDIO, + }); + + // Should receive gain updates with a 3 second timeout + const gainUpdate = await client.waitForMessage( + msg => + msg.type === "SCHEDULED_ACTION" && + msg.scheduledAction?.type === "SPATIAL_CONFIG" && + msg.scheduledAction?.gains && + typeof msg.scheduledAction.gains === "object", + 3000 + ).catch(error => { + throw new Error(`Timeout waiting for gain update: ${error.message}`); + }); + + expect(gainUpdate.scheduledAction.gains).toBeDefined(); + expect(typeof gainUpdate.scheduledAction.gains).toBe("object"); + + // Stop spatial audio + client.send({ + type: ClientActionEnum.enum.STOP_SPATIAL_AUDIO, + }); + + // Should receive stop action with a 3 second timeout + const stopAction = await client.waitForMessage( + msg => + msg.type === "SCHEDULED_ACTION" && + msg.scheduledAction?.type === "STOP_SPATIAL_AUDIO", + 3000 + ).catch(error => { + throw new Error(`Timeout waiting for stop action: ${error.message}`); + }); + + expect(stopAction.type).toBe("SCHEDULED_ACTION"); + expect(stopAction.scheduledAction.type).toBe("STOP_SPATIAL_AUDIO"); + } finally { + client.send({ + type: ClientActionEnum.enum.STOP_SPATIAL_AUDIO, + }); + client.close(); + } + }); + + test("should handle client movement", async () => { + const client1 = await createTestWSClient(server, { + roomId: "movement-room", + username: "user1", + clientId: "client1", + }); + + const client2 = await createTestWSClient(server, { + roomId: "movement-room", + username: "user2", + clientId: "client2", + }); + + try { + // First verify both clients are in the room + const initialRoomEvent = await client1.waitForMessage( + msg => + msg.type === "ROOM_EVENT" && + msg.event?.type === ClientActionEnum.Enum.CLIENT_CHANGE && + msg.event?.clients?.length === 2, + 3000 + ); + expect(initialRoomEvent.event.clients.length).toBe(2); + + // Store client1's ID for later comparison + const client1Id = client1.getClientId(); + expect(client1Id).toBeDefined(); + + // Move client1 + const moveMessage = { + type: ClientActionEnum.enum.MOVE_CLIENT, + clientId: client1Id, + position: { + x: 100, + y: 100, + }, + }; + + // Send the move command + client1.send(moveMessage); + + // First wait for the room update on client1 + const moveUpdate1 = await client1.waitForMessage( + msg => + msg.type === "ROOM_EVENT" && + msg.event?.type === ClientActionEnum.Enum.CLIENT_CHANGE && + msg.event?.clients?.some((c: ClientType) => + c.clientId === client1Id && + c.position?.x === 100 && + c.position?.y === 100 + ), + 3000 + ); + + // Verify the room update structure + expect(moveUpdate1.type).toBe("ROOM_EVENT"); + expect(moveUpdate1.event.type).toBe(ClientActionEnum.Enum.CLIENT_CHANGE); + const movedClient = moveUpdate1.event.clients.find((c: ClientType) => c.clientId === client1Id); + expect(movedClient).toBeDefined(); + expect(movedClient!.position).toEqual({ x: 100, y: 100 }); + + // Then verify client2 received the same room update + const moveUpdate2 = await client2.waitForMessage( + msg => + msg.type === "ROOM_EVENT" && + msg.event?.type === ClientActionEnum.Enum.CLIENT_CHANGE && + msg.event?.clients?.some((c: ClientType) => + c.clientId === client1Id && + c.position?.x === 100 && + c.position?.y === 100 + ), + 3000 + ); + expect(moveUpdate2).toEqual(moveUpdate1); + + // Now wait for spatial config updates + const spatialConfig1 = await client1.waitForMessage( + msg => + msg.type === "SCHEDULED_ACTION" && + msg.scheduledAction?.type === "SPATIAL_CONFIG" && + msg.scheduledAction?.gains !== undefined, + 3000 + ); + + // Verify spatial config structure + expect(spatialConfig1.type).toBe("SCHEDULED_ACTION"); + expect(spatialConfig1.scheduledAction.type).toBe("SPATIAL_CONFIG"); + expect(spatialConfig1.scheduledAction.gains).toBeDefined(); + + // Verify client2 gets the same spatial config + const spatialConfig2 = await client2.waitForMessage( + msg => + msg.type === "SCHEDULED_ACTION" && + msg.scheduledAction?.type === "SPATIAL_CONFIG" && + msg.scheduledAction?.gains !== undefined, + 3000 + ); + expect(spatialConfig2).toEqual(spatialConfig1); + + } catch (error) { + console.error('Test failed:', error); + throw error; + } finally { + client1.close(); + client2.close(); + } + }); + + test("should handle client reordering", async () => { + const client1 = await createTestWSClient(server, { + roomId: "reorder-room", + username: "user1", + clientId: "client1", + }); + + const client2 = await createTestWSClient(server, { + roomId: "reorder-room", + username: "user2", + clientId: "client2", + }); + + try { + // Get client IDs + const client2Id = client2.getClientId(); + expect(client2Id).toBeDefined(); + + // Reorder clients + client1.send({ + type: ClientActionEnum.enum.REORDER_CLIENT, + clientId: client2Id, + }); + + // Both clients should receive updated order + const reorderUpdate1 = await client1.waitForMessage(msg => + msg.type === "ROOM_EVENT" && + msg.event?.type === ClientActionEnum.Enum.CLIENT_CHANGE && + msg.event?.clients?.[0]?.clientId === client2Id + ); + const reorderUpdate2 = await client2.waitForMessage(msg => + msg.type === "ROOM_EVENT" && + msg.event?.type === ClientActionEnum.Enum.CLIENT_CHANGE && + msg.event?.clients?.[0]?.clientId === client2Id + ); + + expect(reorderUpdate1.type).toBe("ROOM_EVENT"); + expect(reorderUpdate1.event.type).toBe(ClientActionEnum.Enum.CLIENT_CHANGE); + expect(reorderUpdate1.event.clients[0].clientId).toBe(client2Id); + + expect(reorderUpdate2).toEqual(reorderUpdate1); + } finally { + client1.close(); + client2.close(); + } + }); + + test("should handle invalid messages", async () => { + const client = await createTestWSClient(server, { + roomId: "error-room", + username: "error-user", + clientId: "error-client", + }); + + try { + // Send invalid message + client.send({ + type: "INVALID_TYPE", + data: "invalid", + }); + + // Should receive error response + const errorResponse = await client.waitForMessage(msg => + msg.type === "ERROR" && msg.message === "Invalid message format" + ); + expect(errorResponse.type).toBe("ERROR"); + expect(errorResponse.message).toBe("Invalid message format"); + } finally { + client.close(); + } + }); +}); \ No newline at end of file diff --git a/apps/server/src/test/spatialAudio.test.ts b/apps/server/src/test/spatialAudio.test.ts new file mode 100644 index 00000000..095b818f --- /dev/null +++ b/apps/server/src/test/spatialAudio.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { + gainFromDistanceExp, + gainFromDistanceLinear, + gainFromDistanceQuadratic, +} from "../spatial"; + +describe("Spatial Audio Calculations", () => { + const source = { x: 0, y: 0 }; + + test("gainFromDistanceExp should decrease with distance", () => { + const closeClient = { x: 1, y: 1 }; + const farClient = { x: 10, y: 10 }; + + const closeGain = gainFromDistanceExp({ client: closeClient, source }); + const farGain = gainFromDistanceExp({ client: farClient, source }); + + expect(closeGain).toBeGreaterThan(farGain); + expect(closeGain).toBeLessThanOrEqual(1.0); + expect(farGain).toBeGreaterThanOrEqual(0.15); + }); + + test("gainFromDistanceLinear should decrease linearly", () => { + const client1 = { x: 5, y: 0 }; + const client2 = { x: 10, y: 0 }; + + const gain1 = gainFromDistanceLinear({ client: client1, source }); + const gain2 = gainFromDistanceLinear({ client: client2, source }); + + expect(gain1).toBeGreaterThan(gain2); + expect(gain1).toBeLessThanOrEqual(1.0); + expect(gain2).toBeGreaterThanOrEqual(0.15); + }); + + test("gainFromDistanceQuadratic should decrease quadratically", () => { + const client1 = { x: 5, y: 0 }; + const client2 = { x: 10, y: 0 }; + + const gain1 = gainFromDistanceQuadratic({ client: client1, source }); + const gain2 = gainFromDistanceQuadratic({ client: client2, source }); + + expect(gain1).toBeGreaterThan(gain2); + expect(gain1).toBeLessThanOrEqual(1.0); + expect(gain2).toBeGreaterThanOrEqual(0.15); + }); + + test("all gain functions should respect min/max bounds", () => { + const veryCloseClient = { x: 0.1, y: 0.1 }; + const veryFarClient = { x: 100, y: 100 }; + + const functions = [ + gainFromDistanceExp, + gainFromDistanceLinear, + gainFromDistanceQuadratic, + ]; + + functions.forEach(gainFn => { + const maxGain = gainFn({ client: veryCloseClient, source }); + const minGain = gainFn({ client: veryFarClient, source }); + + expect(maxGain).toBeLessThanOrEqual(1.0); + expect(minGain).toBeGreaterThanOrEqual(0.15); + }); + }); +}); \ No newline at end of file diff --git a/apps/server/src/test/utils/testHelpers.ts b/apps/server/src/test/utils/testHelpers.ts new file mode 100644 index 00000000..e8a33373 --- /dev/null +++ b/apps/server/src/test/utils/testHelpers.ts @@ -0,0 +1,29 @@ +import { AUDIO_DIR } from "../../config"; +import * as fs from "fs"; +import * as path from "path"; + +export const createTestServer = () => { + const server = Bun.serve({ + port: 0, + fetch: () => new Response("Test server"), + }); + return server; +}; + +export const setupTestAudioDir = () => { + if (!fs.existsSync(AUDIO_DIR)) { + fs.mkdirSync(AUDIO_DIR, { recursive: true }); + } +}; + +export const cleanupTestAudioDir = () => { + if (fs.existsSync(AUDIO_DIR)) { + fs.rmSync(AUDIO_DIR, { recursive: true, force: true }); + } +}; + +export const createTestAudioFile = (filename: string, content = "test audio content") => { + const filePath = path.join(AUDIO_DIR, filename); + fs.writeFileSync(filePath, content); + return filePath; +}; \ No newline at end of file diff --git a/apps/server/src/test/utils/wsHelpers.ts b/apps/server/src/test/utils/wsHelpers.ts new file mode 100644 index 00000000..e1525151 --- /dev/null +++ b/apps/server/src/test/utils/wsHelpers.ts @@ -0,0 +1,169 @@ +import { Server, ServerWebSocket } from "bun"; +import { handleWebSocketUpgrade } from "../../routes/websocket"; +import { handleOpen, handleMessage, handleClose } from "../../routes/websocketHandlers"; +import { WSData } from "../../utils/websocket"; + +export interface TestWSMessage { + type: string; + [key: string]: any; +} + +export class TestWebSocketClient { + private ws: WebSocket; + private messageQueue: TestWSMessage[] = []; + private messageHandlers: ((msg: TestWSMessage) => void)[] = []; + private clientId: string | null = null; + + constructor(ws: WebSocket) { + this.ws = ws; + this.ws.onmessage = this.handleMessage.bind(this); + } + + private handleMessage(event: MessageEvent) { + try { + const message = JSON.parse(event.data.toString()) as TestWSMessage; + + // Store client ID when received + if (message.type === "SET_CLIENT_ID") { + this.clientId = message.clientId; + } + + this.messageQueue.push(message); + this.messageHandlers.forEach(handler => handler(message)); + } catch (error) { + console.error("Error parsing message:", error); + } + } + + async waitForMessage(predicate?: (msg: TestWSMessage) => boolean, timeoutMs: number = 0): Promise { + // First check the queue for existing messages + const existingMessage = predicate + ? this.messageQueue.find(predicate) + : this.messageQueue[0]; + + if (existingMessage) { + this.messageQueue = this.messageQueue.filter(msg => msg !== existingMessage); + return existingMessage; + } + + // If no matching message found, wait for the next one with optional timeout + return new Promise((resolve, reject) => { + let timeoutId: NodeJS.Timeout | undefined; + + const handler = (message: TestWSMessage) => { + if (!predicate || predicate(message)) { + // Clear timeout if it was set + if (timeoutId) { + clearTimeout(timeoutId); + } + // Remove the handler + this.messageHandlers = this.messageHandlers.filter(h => h !== handler); + // Remove the message from queue + this.messageQueue = this.messageQueue.filter(msg => msg !== message); + resolve(message); + } + }; + + // Set timeout if specified + if (timeoutMs > 0) { + timeoutId = setTimeout(() => { + // Remove the handler on timeout + this.messageHandlers = this.messageHandlers.filter(h => h !== handler); + reject(new Error(`Timeout waiting for message after ${timeoutMs}ms`)); + }, timeoutMs); + } + + this.messageHandlers.push(handler); + }); + } + + async waitForClientId(timeoutMs: number = 0): Promise { + if (this.clientId) { + return this.clientId; + } + + const message = await this.waitForMessage(msg => msg.type === "SET_CLIENT_ID", timeoutMs); + return message.clientId; + } + + async waitForRoomEvent(timeoutMs: number = 0): Promise { + return this.waitForMessage(msg => msg.type === "ROOM_EVENT", timeoutMs); + } + + send(message: TestWSMessage) { + this.ws.send(JSON.stringify(message)); + } + + close() { + this.ws.close(); + } + + getClientId(): string | null { + return this.clientId; + } + + addMessageHandler(handler: (msg: TestWSMessage) => void) { + this.messageHandlers.push(handler); + } + + removeMessageHandler(handler: (msg: TestWSMessage) => void) { + this.messageHandlers = this.messageHandlers.filter(h => h !== handler); + } +} + +export const createTestWSServer = () => { + const server = Bun.serve({ + port: 0, // Random available port + fetch(req, server) { + return handleWebSocketUpgrade(req, server); + }, + websocket: { + open(ws: ServerWebSocket) { + handleOpen(ws, server); + }, + message(ws: ServerWebSocket, message) { + handleMessage(ws, message, server); + }, + close(ws: ServerWebSocket) { + handleClose(ws, server); + }, + }, + }); + return server; +}; + +export const createTestWSClient = async (server: Server, params: Partial): Promise => { + const { port } = server; + const searchParams = new URLSearchParams({ + roomId: params.roomId || "", + username: params.username || "", + }); + + const ws = new WebSocket( + `ws://localhost:${port}/?${searchParams.toString()}` + ); + + // Wait for connection + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = (error) => reject(error); + }); + + const client = new TestWebSocketClient(ws); + + // Wait for initial setup messages + await client.waitForClientId(); + await client.waitForRoomEvent(); + + return client; +}; + +// For backward compatibility +export const waitForMessage = async (ws: WebSocket, predicate?: (msg: any) => boolean) => { + const client = new TestWebSocketClient(ws); + return client.waitForMessage(predicate); +}; + +export const sendWSMessage = (ws: WebSocket, message: TestWSMessage) => { + ws.send(JSON.stringify(message)); +}; \ No newline at end of file diff --git a/apps/server/src/test/utils/wsTestHelpers.ts b/apps/server/src/test/utils/wsTestHelpers.ts new file mode 100644 index 00000000..e1525151 --- /dev/null +++ b/apps/server/src/test/utils/wsTestHelpers.ts @@ -0,0 +1,169 @@ +import { Server, ServerWebSocket } from "bun"; +import { handleWebSocketUpgrade } from "../../routes/websocket"; +import { handleOpen, handleMessage, handleClose } from "../../routes/websocketHandlers"; +import { WSData } from "../../utils/websocket"; + +export interface TestWSMessage { + type: string; + [key: string]: any; +} + +export class TestWebSocketClient { + private ws: WebSocket; + private messageQueue: TestWSMessage[] = []; + private messageHandlers: ((msg: TestWSMessage) => void)[] = []; + private clientId: string | null = null; + + constructor(ws: WebSocket) { + this.ws = ws; + this.ws.onmessage = this.handleMessage.bind(this); + } + + private handleMessage(event: MessageEvent) { + try { + const message = JSON.parse(event.data.toString()) as TestWSMessage; + + // Store client ID when received + if (message.type === "SET_CLIENT_ID") { + this.clientId = message.clientId; + } + + this.messageQueue.push(message); + this.messageHandlers.forEach(handler => handler(message)); + } catch (error) { + console.error("Error parsing message:", error); + } + } + + async waitForMessage(predicate?: (msg: TestWSMessage) => boolean, timeoutMs: number = 0): Promise { + // First check the queue for existing messages + const existingMessage = predicate + ? this.messageQueue.find(predicate) + : this.messageQueue[0]; + + if (existingMessage) { + this.messageQueue = this.messageQueue.filter(msg => msg !== existingMessage); + return existingMessage; + } + + // If no matching message found, wait for the next one with optional timeout + return new Promise((resolve, reject) => { + let timeoutId: NodeJS.Timeout | undefined; + + const handler = (message: TestWSMessage) => { + if (!predicate || predicate(message)) { + // Clear timeout if it was set + if (timeoutId) { + clearTimeout(timeoutId); + } + // Remove the handler + this.messageHandlers = this.messageHandlers.filter(h => h !== handler); + // Remove the message from queue + this.messageQueue = this.messageQueue.filter(msg => msg !== message); + resolve(message); + } + }; + + // Set timeout if specified + if (timeoutMs > 0) { + timeoutId = setTimeout(() => { + // Remove the handler on timeout + this.messageHandlers = this.messageHandlers.filter(h => h !== handler); + reject(new Error(`Timeout waiting for message after ${timeoutMs}ms`)); + }, timeoutMs); + } + + this.messageHandlers.push(handler); + }); + } + + async waitForClientId(timeoutMs: number = 0): Promise { + if (this.clientId) { + return this.clientId; + } + + const message = await this.waitForMessage(msg => msg.type === "SET_CLIENT_ID", timeoutMs); + return message.clientId; + } + + async waitForRoomEvent(timeoutMs: number = 0): Promise { + return this.waitForMessage(msg => msg.type === "ROOM_EVENT", timeoutMs); + } + + send(message: TestWSMessage) { + this.ws.send(JSON.stringify(message)); + } + + close() { + this.ws.close(); + } + + getClientId(): string | null { + return this.clientId; + } + + addMessageHandler(handler: (msg: TestWSMessage) => void) { + this.messageHandlers.push(handler); + } + + removeMessageHandler(handler: (msg: TestWSMessage) => void) { + this.messageHandlers = this.messageHandlers.filter(h => h !== handler); + } +} + +export const createTestWSServer = () => { + const server = Bun.serve({ + port: 0, // Random available port + fetch(req, server) { + return handleWebSocketUpgrade(req, server); + }, + websocket: { + open(ws: ServerWebSocket) { + handleOpen(ws, server); + }, + message(ws: ServerWebSocket, message) { + handleMessage(ws, message, server); + }, + close(ws: ServerWebSocket) { + handleClose(ws, server); + }, + }, + }); + return server; +}; + +export const createTestWSClient = async (server: Server, params: Partial): Promise => { + const { port } = server; + const searchParams = new URLSearchParams({ + roomId: params.roomId || "", + username: params.username || "", + }); + + const ws = new WebSocket( + `ws://localhost:${port}/?${searchParams.toString()}` + ); + + // Wait for connection + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = (error) => reject(error); + }); + + const client = new TestWebSocketClient(ws); + + // Wait for initial setup messages + await client.waitForClientId(); + await client.waitForRoomEvent(); + + return client; +}; + +// For backward compatibility +export const waitForMessage = async (ws: WebSocket, predicate?: (msg: any) => boolean) => { + const client = new TestWebSocketClient(ws); + return client.waitForMessage(predicate); +}; + +export const sendWSMessage = (ws: WebSocket, message: TestWSMessage) => { + ws.send(JSON.stringify(message)); +}; \ No newline at end of file diff --git a/package.json b/package.json index 2a179187..19967f81 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "dev": "turbo run dev", "start": "turbo run start", "server": "turbo start --filter=server", - "client": "turbo start --filter=client" + "client": "turbo start --filter=client", }, "workspaces": [ "apps/*",