|
| 1 | +/* SPDX-FileCopyrightText: 2025-present Kriasoft */ |
| 2 | +/* SPDX-License-Identifier: MIT */ |
| 3 | + |
| 4 | +import { |
| 5 | + afterEach, |
| 6 | + beforeEach, |
| 7 | + describe, |
| 8 | + expect, |
| 9 | + it, |
| 10 | + mock, |
| 11 | + spyOn, |
| 12 | +} from "bun:test"; |
| 13 | +import { z } from "zod"; |
| 14 | +import { WebSocketRouter } from "../router"; |
| 15 | +import { messageSchema } from "../schema"; |
| 16 | + |
| 17 | +// Mock console methods to prevent noise during tests |
| 18 | +const originalConsoleLog = console.log; |
| 19 | +const originalConsoleWarn = console.warn; |
| 20 | +const originalConsoleError = console.error; |
| 21 | + |
| 22 | +beforeEach(() => { |
| 23 | + console.log = mock(() => {}); |
| 24 | + console.warn = mock(() => {}); |
| 25 | + console.error = mock(() => {}); |
| 26 | +}); |
| 27 | + |
| 28 | +afterEach(() => { |
| 29 | + console.log = originalConsoleLog; |
| 30 | + console.warn = originalConsoleWarn; |
| 31 | + console.error = originalConsoleError; |
| 32 | +}); |
| 33 | + |
| 34 | +// Test message schemas |
| 35 | +const Ping = messageSchema("PING", { |
| 36 | + message: z.string(), |
| 37 | +}); |
| 38 | + |
| 39 | +const Pong = messageSchema("PONG", { |
| 40 | + message: z.string(), |
| 41 | + timestamp: z.number(), |
| 42 | +}); |
| 43 | + |
| 44 | +const Error = messageSchema("ERROR", { |
| 45 | + code: z.number(), |
| 46 | + message: z.string(), |
| 47 | +}); |
| 48 | + |
| 49 | +describe("WebSocketServer E2E", () => { |
| 50 | + let server: ReturnType<typeof Bun.serve>; |
| 51 | + let ws: WebSocketRouter; |
| 52 | + let port: number; |
| 53 | + |
| 54 | + beforeEach(() => { |
| 55 | + // Use a random port for each test to avoid conflicts |
| 56 | + port = 50000 + Math.floor(Math.random() * 10000); |
| 57 | + |
| 58 | + // Create a new router for each test |
| 59 | + ws = new WebSocketRouter(); |
| 60 | + |
| 61 | + // Set up message handlers |
| 62 | + ws.onMessage(Ping, (ctx) => { |
| 63 | + // Echo back a PONG with the same message and add a timestamp |
| 64 | + ctx.send(Pong, { |
| 65 | + message: ctx.payload.message, |
| 66 | + timestamp: Date.now(), |
| 67 | + }); |
| 68 | + }); |
| 69 | + |
| 70 | + // Add an error message handler |
| 71 | + ws.onMessage(Error, (ctx) => { |
| 72 | + // Just for handling error messages in tests |
| 73 | + }); |
| 74 | + |
| 75 | + // Set up open handler |
| 76 | + const openHandlerMock = mock((ctx) => { |
| 77 | + // Optional: send a welcome message |
| 78 | + // ctx.send(...); |
| 79 | + }); |
| 80 | + ws.onOpen(openHandlerMock); |
| 81 | + |
| 82 | + // Set up close handler |
| 83 | + const closeHandlerMock = mock((ctx) => {}); |
| 84 | + ws.onClose(closeHandlerMock); |
| 85 | + |
| 86 | + // Start the server |
| 87 | + server = Bun.serve({ |
| 88 | + port, |
| 89 | + |
| 90 | + fetch(req, server) { |
| 91 | + const url = new URL(req.url); |
| 92 | + if (url.pathname === "/ws") { |
| 93 | + return ws.upgrade(req, { server }); |
| 94 | + } |
| 95 | + return new Response("Not Found", { status: 404 }); |
| 96 | + }, |
| 97 | + |
| 98 | + websocket: ws.websocket, |
| 99 | + }); |
| 100 | + }); |
| 101 | + |
| 102 | + afterEach(() => { |
| 103 | + // Shutdown the server after each test |
| 104 | + server.stop(); |
| 105 | + }); |
| 106 | + |
| 107 | + it("should establish a WebSocket connection and exchange messages", async () => { |
| 108 | + // Wait a bit to ensure server is ready |
| 109 | + await new Promise((resolve) => setTimeout(resolve, 100)); |
| 110 | + |
| 111 | + // Connect to the server |
| 112 | + const socket = new WebSocket(`ws://localhost:${port}/ws`); |
| 113 | + |
| 114 | + // Keep track of messages received by the client |
| 115 | + const receivedMessages: any[] = []; |
| 116 | + |
| 117 | + // Set up message handler |
| 118 | + socket.addEventListener("message", (event) => { |
| 119 | + receivedMessages.push(JSON.parse(event.data as string)); |
| 120 | + }); |
| 121 | + |
| 122 | + // Wait for the connection to open |
| 123 | + await new Promise<void>((resolve) => { |
| 124 | + socket.addEventListener("open", () => resolve()); |
| 125 | + }); |
| 126 | + |
| 127 | + // Send a PING message |
| 128 | + const pingMessage = { |
| 129 | + type: "PING", |
| 130 | + meta: { clientId: "test-client" }, |
| 131 | + payload: { message: "Hello Server!" }, |
| 132 | + }; |
| 133 | + |
| 134 | + socket.send(JSON.stringify(pingMessage)); |
| 135 | + |
| 136 | + // Wait for a response (PONG) |
| 137 | + await new Promise<void>((resolve) => { |
| 138 | + const checkInterval = setInterval(() => { |
| 139 | + if (receivedMessages.length > 0) { |
| 140 | + clearInterval(checkInterval); |
| 141 | + resolve(); |
| 142 | + } |
| 143 | + }, 50); |
| 144 | + }); |
| 145 | + |
| 146 | + // Check that we received the expected PONG message |
| 147 | + expect(receivedMessages.length).toBe(1); |
| 148 | + expect(receivedMessages[0].type).toBe("PONG"); |
| 149 | + expect(receivedMessages[0].payload.message).toBe("Hello Server!"); |
| 150 | + expect(receivedMessages[0].payload.timestamp).toBeGreaterThan(0); |
| 151 | + |
| 152 | + // Clean up |
| 153 | + socket.close(); |
| 154 | + }); |
| 155 | + |
| 156 | + it("should handle multiple clients simultaneously", async () => { |
| 157 | + // Wait for server to be ready |
| 158 | + await new Promise((resolve) => setTimeout(resolve, 100)); |
| 159 | + |
| 160 | + // Connect multiple clients |
| 161 | + const clients = await Promise.all( |
| 162 | + Array.from({ length: 3 }, async (_, i) => { |
| 163 | + const socket = new WebSocket(`ws://localhost:${port}/ws`); |
| 164 | + const messages: any[] = []; |
| 165 | + |
| 166 | + socket.addEventListener("message", (event) => { |
| 167 | + messages.push(JSON.parse(event.data as string)); |
| 168 | + }); |
| 169 | + |
| 170 | + // Wait for connection to open |
| 171 | + await new Promise<void>((resolve) => { |
| 172 | + socket.addEventListener("open", () => resolve()); |
| 173 | + }); |
| 174 | + |
| 175 | + return { socket, messages, id: `client-${i}` }; |
| 176 | + }) |
| 177 | + ); |
| 178 | + |
| 179 | + // Each client sends a message |
| 180 | + clients.forEach((client) => { |
| 181 | + const pingMessage = { |
| 182 | + type: "PING", |
| 183 | + meta: { clientId: client.id }, |
| 184 | + payload: { message: `Hello from ${client.id}` }, |
| 185 | + }; |
| 186 | + client.socket.send(JSON.stringify(pingMessage)); |
| 187 | + }); |
| 188 | + |
| 189 | + // Wait for all clients to receive responses |
| 190 | + await new Promise<void>((resolve) => { |
| 191 | + const checkInterval = setInterval(() => { |
| 192 | + if (clients.every((client) => client.messages.length > 0)) { |
| 193 | + clearInterval(checkInterval); |
| 194 | + resolve(); |
| 195 | + } |
| 196 | + }, 50); |
| 197 | + }); |
| 198 | + |
| 199 | + // Verify each client received the correct response |
| 200 | + clients.forEach((client) => { |
| 201 | + expect(client.messages.length).toBe(1); |
| 202 | + expect(client.messages[0].type).toBe("PONG"); |
| 203 | + expect(client.messages[0].payload.message).toBe( |
| 204 | + `Hello from ${client.id}` |
| 205 | + ); |
| 206 | + }); |
| 207 | + |
| 208 | + // Clean up |
| 209 | + clients.forEach((client) => client.socket.close()); |
| 210 | + }); |
| 211 | + |
| 212 | + it("should handle invalid message format gracefully", async () => { |
| 213 | + // Connect to the server |
| 214 | + await new Promise((resolve) => setTimeout(resolve, 100)); |
| 215 | + const socket = new WebSocket(`ws://localhost:${port}/ws`); |
| 216 | + |
| 217 | + const receivedMessages: any[] = []; |
| 218 | + socket.addEventListener("message", (event) => { |
| 219 | + receivedMessages.push(JSON.parse(event.data as string)); |
| 220 | + }); |
| 221 | + |
| 222 | + // Wait for connection to open |
| 223 | + await new Promise<void>((resolve) => { |
| 224 | + socket.addEventListener("open", () => resolve()); |
| 225 | + }); |
| 226 | + |
| 227 | + // Monitor console.error calls |
| 228 | + const errorSpy = spyOn(console, "error"); |
| 229 | + |
| 230 | + // Send an invalid message (not JSON) |
| 231 | + socket.send("This is not JSON"); |
| 232 | + |
| 233 | + // Send an invalid message (JSON but wrong format) |
| 234 | + socket.send(JSON.stringify({ notAValidMessage: true })); |
| 235 | + |
| 236 | + // Wait a bit to ensure messages are processed |
| 237 | + await new Promise((resolve) => setTimeout(resolve, 200)); |
| 238 | + |
| 239 | + // Verify error handling |
| 240 | + expect(errorSpy).toHaveBeenCalled(); |
| 241 | + |
| 242 | + // Clean up |
| 243 | + socket.close(); |
| 244 | + }); |
| 245 | + |
| 246 | + it("should handle client disconnection properly", async () => { |
| 247 | + // Create a mock for the close handler |
| 248 | + const closeHandlerMock = mock(() => {}); |
| 249 | + |
| 250 | + // Register our mock as a close handler |
| 251 | + ws.onClose(closeHandlerMock); |
| 252 | + |
| 253 | + // Connect to the server |
| 254 | + await new Promise((resolve) => setTimeout(resolve, 100)); |
| 255 | + const socket = new WebSocket(`ws://localhost:${port}/ws`); |
| 256 | + |
| 257 | + // Wait for connection to open |
| 258 | + await new Promise<void>((resolve) => { |
| 259 | + socket.addEventListener("open", () => resolve()); |
| 260 | + }); |
| 261 | + |
| 262 | + // Close the connection from the client side |
| 263 | + socket.close(1000, "Normal closure"); |
| 264 | + |
| 265 | + // Wait for close event to be processed |
| 266 | + // Need a bit more time to ensure the close handler is called |
| 267 | + await new Promise((resolve) => setTimeout(resolve, 300)); |
| 268 | + |
| 269 | + // Verify close handler was called |
| 270 | + expect(closeHandlerMock).toHaveBeenCalled(); |
| 271 | + }); |
| 272 | +}); |
0 commit comments