Files
blind_chess/packages/server/test/integration/scripted-game.test.ts
T
claude (blind_chess) a6de43edc1 feat: implement and deploy blind_chess MVP
- pnpm workspace: shared/server/client packages
- Server: Fastify+ws, chess.js, FSM (touch-move + hierarchy),
  per-player view filter, zod validation, rate limiting, grace-window
  disconnect handling
- Client: Svelte 5 + Vite, click-to-move board, moderator panel,
  promotion/draw dialogs
- Shared: protocol types, ModeratorText enum, geometricMoves helper
  (provably zero opponent-info leak)
- 43 tests pass (21 shared, 22 server incl. 4 real-WS integration)
- Deploy: CT 690 on node-241 (192.168.0.245), systemd-managed,
  Caddy block for chess.sethpc.xyz
- Live at https://chess.sethpc.xyz

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:20:18 -04:00

173 lines
6.5 KiB
TypeScript

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<typeof Fastify>;
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,
});
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<ServerMessage>;
send: (m: unknown) => void;
close: () => void;
}
function makeClient(gameId: string): Promise<Client> {
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<ServerMessage>((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();
});
});