import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { WebSocket } from 'ws'; import Fastify from 'fastify'; import websocketPlugin from '@fastify/websocket'; import { activeGameCount, chooseSide, createGame, } from '../../src/games.js'; import { attachSocket } from '../../src/ws.js'; import { createGameSchema } from '../../src/validation.js'; import type { ServerMessage } from '@blind-chess/shared'; let app: ReturnType; let baseUrl = ''; beforeAll(async () => { app = Fastify({ logger: false }); await app.register(websocketPlugin); app.get('/api/health', async () => ({ ok: true, activeGames: activeGameCount() })); app.post('/api/games', async (req, reply) => { const parsed = createGameSchema.safeParse(req.body); if (!parsed.success) { reply.code(400); return { error: 'malformed' }; } const creatorSide = chooseSide(parsed.data.side); const { game, creatorToken } = createGame({ mode: parsed.data.mode, creatorSide, highlightingEnabled: parsed.data.highlightingEnabled, vsAi: parsed.data.vsAi, }); return { gameId: game.id, creatorToken, creatorColor: creatorSide }; }); app.get('/ws', { websocket: true }, (socket) => { const raw = (socket as unknown as { socket?: unknown }).socket ?? socket; attachSocket(raw as never); }); await app.listen({ port: 0, host: '127.0.0.1' }); const addr = app.server.address(); if (typeof addr !== 'object' || !addr) throw new Error('no address'); baseUrl = `http://127.0.0.1:${addr.port}`; }); afterAll(async () => { await app.close(); }); interface Client { ws: WebSocket; msgs: ServerMessage[]; waitFor: (pred: (m: ServerMessage) => boolean, timeoutMs?: number) => Promise; send: (m: unknown) => void; close: () => void; } function makeClient(gameId: string): Promise { return new Promise((resolve, reject) => { const ws = new WebSocket(baseUrl.replace('http', 'ws') + `/ws?game=${gameId}`); const msgs: ServerMessage[] = []; const waiters: Array<{ pred: (m: ServerMessage) => boolean; resolve: (m: ServerMessage) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }> = []; ws.on('message', (data) => { const m = JSON.parse(data.toString()) as ServerMessage; msgs.push(m); for (const w of [...waiters]) { if (w.pred(m)) { clearTimeout(w.timer); waiters.splice(waiters.indexOf(w), 1); w.resolve(m); } } }); ws.on('open', () => resolve({ ws, msgs, waitFor: (pred, timeoutMs = 1500) => new Promise((res, rej) => { const existing = msgs.find(pred); if (existing) return res(existing); const timer = setTimeout(() => rej(new Error('waitFor timeout')), timeoutMs); waiters.push({ pred, resolve: res, reject: rej, timer }); }), send: (m) => ws.send(JSON.stringify(m)), close: () => ws.close(), })); ws.on('error', reject); }); } async function createTestGame(mode: 'blind' | 'vanilla' = 'blind'): Promise<{ gameId: string; creatorToken: string; creatorColor: 'w' | 'b' }> { const res = await fetch(`${baseUrl}/api/games`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode, side: 'w', highlightingEnabled: false }), }); return await res.json() as { gameId: string; creatorToken: string; creatorColor: 'w' | 'b' }; } describe('scripted game end-to-end', () => { it('two clients connect, opening exchange, blind view filtering', async () => { const { gameId, creatorToken } = await createTestGame('blind'); const w = await makeClient(gameId); const b = await makeClient(gameId); // White connects with token, black auto-claims. w.send({ type: 'hello', gameId, token: creatorToken }); b.send({ type: 'hello', gameId, joinAs: 'auto' }); const wJoined = await w.waitFor((m) => m.type === 'joined'); const bJoined = await b.waitFor((m) => m.type === 'joined'); expect(wJoined.type === 'joined' && wJoined.you).toBe('w'); expect(bJoined.type === 'joined' && bJoined.you).toBe('b'); // Blind view: white sees only its 16 pieces. if (wJoined.type !== 'joined') throw new Error('expected joined'); expect(Object.keys(wJoined.view.pieces).length).toBe(16); for (const piece of Object.values(wJoined.view.pieces)) { expect(piece?.color).toBe('w'); } // White plays e2e4 in two messages: arm + commit. w.send({ type: 'commit', from: 'e2' }); await w.waitFor((m) => m.type === 'update' && m.touchedPiece === 'e2'); w.send({ type: 'commit', from: 'e2', to: 'e4' }); // Black should see a moderator announcement white_moved. const bMoved = await b.waitFor((m) => m.type === 'update' && m.newAnnouncements.some((a) => a.text === 'white_moved'), ); expect(bMoved.type).toBe('update'); w.close(); b.close(); }); it('not_your_turn error when black tries to move first', async () => { const { gameId, creatorToken } = await createTestGame('vanilla'); const w = await makeClient(gameId); const b = await makeClient(gameId); w.send({ type: 'hello', gameId, token: creatorToken }); b.send({ type: 'hello', gameId, joinAs: 'auto' }); await b.waitFor((m) => m.type === 'joined'); b.send({ type: 'commit', from: 'e7', to: 'e5' }); const err = await b.waitFor((m) => m.type === 'error'); expect(err.type === 'error' && err.code).toBe('not_your_turn'); w.close(); b.close(); }); it('rejects malformed messages', async () => { const { gameId, creatorToken } = await createTestGame('blind'); const w = await makeClient(gameId); w.send({ type: 'hello', gameId, token: creatorToken }); await w.waitFor((m) => m.type === 'joined'); w.send({ type: 'commit', from: 'zz', to: 'e4' }); const err = await w.waitFor((m) => m.type === 'error'); expect(err.type === 'error' && err.code).toBe('malformed'); w.close(); }); it('resign: opponent gets game_finished update', async () => { const { gameId, creatorToken } = await createTestGame('vanilla'); const w = await makeClient(gameId); const b = await makeClient(gameId); w.send({ type: 'hello', gameId, token: creatorToken }); b.send({ type: 'hello', gameId, joinAs: 'auto' }); await b.waitFor((m) => m.type === 'joined'); w.send({ type: 'resign' }); const upd = await b.waitFor((m) => m.type === 'update' && m.gameStatus === 'finished', ); expect(upd.type === 'update' && upd.endReason).toBe('resign'); expect(upd.type === 'update' && upd.winner).toBe('b'); w.close(); b.close(); }); });