Bun workspace monorepo with shared, server, client packages. Server: Hono + @hono/node-ws, Drizzle + PostgreSQL, in-memory room manager with WebSocket broadcasting, HTTP room creation, DB persistence layer. Client: React 19 + Vite + Tailwind v4 + shadcn/ui, TanStack Router with landing/display/host/player routes, Zustand store, WebSocket connection hook. 20 tests passing (room manager unit + WS integration). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
109 lines
3.3 KiB
TypeScript
109 lines
3.3 KiB
TypeScript
import { describe, expect, it, afterEach, beforeEach } from "vitest"
|
|
import { serve } from "@hono/node-server"
|
|
import { app, injectWebSocket } from "../src/app"
|
|
import { registerWebSocketRoutes } from "../src/ws/handler"
|
|
import { roomManager } from "../src/rooms/index"
|
|
|
|
// Register WS routes once
|
|
registerWebSocketRoutes()
|
|
|
|
let server: ReturnType<typeof serve>
|
|
|
|
function waitForMessage(ws: WebSocket): Promise<unknown> {
|
|
return new Promise((resolve) => {
|
|
ws.addEventListener(
|
|
"message",
|
|
(event) => {
|
|
resolve(JSON.parse(event.data as string))
|
|
},
|
|
{ once: true },
|
|
)
|
|
})
|
|
}
|
|
|
|
function waitForOpen(ws: WebSocket): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
resolve()
|
|
} else {
|
|
ws.addEventListener("open", () => resolve(), { once: true })
|
|
}
|
|
})
|
|
}
|
|
|
|
describe("WebSocket handler", () => {
|
|
let port: number
|
|
|
|
beforeEach(async () => {
|
|
roomManager.reset()
|
|
port = 3100 + Math.floor(Math.random() * 900)
|
|
server = serve({ fetch: app.fetch, port })
|
|
injectWebSocket(server)
|
|
})
|
|
|
|
afterEach(() => {
|
|
server.close()
|
|
})
|
|
|
|
it("creates a room via HTTP and connects via WebSocket", async () => {
|
|
// Create room via HTTP
|
|
const res = await fetch(`http://localhost:${port}/rooms`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ displayName: "Host" }),
|
|
})
|
|
const { data } = (await res.json()) as { data: { code: string; sessionId: string } }
|
|
expect(data.code).toMatch(/^[A-Z0-9]{4}$/)
|
|
|
|
// Connect as host via WebSocket
|
|
const ws = new WebSocket(`ws://localhost:${port}/ws/${data.code}?sessionId=${data.sessionId}`)
|
|
await waitForOpen(ws)
|
|
|
|
const msg = (await waitForMessage(ws)) as { type: string; room: { code: string } }
|
|
expect(msg.type).toBe("room_state")
|
|
expect(msg.room.code).toBe(data.code)
|
|
|
|
ws.close()
|
|
})
|
|
|
|
it("player joins room via WebSocket", async () => {
|
|
// Create room
|
|
const res = await fetch(`http://localhost:${port}/rooms`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ displayName: "Host" }),
|
|
})
|
|
const { data } = (await res.json()) as { data: { code: string; sessionId: string } }
|
|
|
|
// Connect host
|
|
const hostWs = new WebSocket(`ws://localhost:${port}/ws/${data.code}?sessionId=${data.sessionId}`)
|
|
await waitForOpen(hostWs)
|
|
await waitForMessage(hostWs) // room_state
|
|
|
|
// Connect player (no sessionId)
|
|
const playerWs = new WebSocket(`ws://localhost:${port}/ws/${data.code}`)
|
|
await waitForOpen(playerWs)
|
|
await waitForMessage(playerWs) // initial room_state
|
|
|
|
// Set up listeners BEFORE sending to avoid race conditions
|
|
const playerMsgPromise = waitForMessage(playerWs)
|
|
const hostMsgPromise = waitForMessage(hostWs)
|
|
|
|
// Player sends join_room
|
|
playerWs.send(JSON.stringify({ type: "join_room", displayName: "Player 1" }))
|
|
|
|
// Player receives room_state with sessionId
|
|
const playerMsg = (await playerMsgPromise) as { type: string; sessionId?: string }
|
|
expect(playerMsg.type).toBe("room_state")
|
|
expect(playerMsg.sessionId).toBeDefined()
|
|
|
|
// Host receives player_joined broadcast
|
|
const hostMsg = (await hostMsgPromise) as { type: string; player: { displayName: string } }
|
|
expect(hostMsg.type).toBe("player_joined")
|
|
expect(hostMsg.player.displayName).toBe("Player 1")
|
|
|
|
hostWs.close()
|
|
playerWs.close()
|
|
})
|
|
})
|