diff --git a/apps/server/src/rooms/room.test.ts b/apps/server/src/rooms/room.test.ts new file mode 100644 index 0000000..a48a8bc --- /dev/null +++ b/apps/server/src/rooms/room.test.ts @@ -0,0 +1,182 @@ +/** Room-level tests with a controllable clock: timers, grace period, restore, error isolation. */ +import { describe, expect, test } from 'bun:test'; +import { z } from 'zod'; +import type { AnyGameDefinition } from '@bg/engine'; +import type { ServerMessage } from '@bg/shared'; +import { GAMES } from '@bg/games'; +import { openDb } from '../db'; +import { Room, type Conn, type RoomConfig, type RoomSnapshot } from './room'; +import { RoomStore } from './store'; + +function harness(games: Record = GAMES) { + const db = openDb(':memory:'); + const store = new RoomStore(db); + let now = 1_000_000; + const clock = { now: () => now, advance: (ms: number) => (now += ms) }; + const deps = { + store, + games, + users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, + now: clock.now, + }; + const config: RoomConfig = { + gameId: Object.keys(games)[0]!, + options: Object.values(games)[0]!.defaultOptions, + maxPlayers: 2, + visibility: 'private', + allowSpectators: true, + chatEnabled: true, + chatFilter: true, + graceSec: 60, + }; + const room = new Room('room1', '482913', config, 'A', deps); + store.insertRoom({ id: 'room1', code: '482913', hostId: 'A', gameId: config.gameId, status: 'lobby', visibility: 'private', config, stateJson: '{}', now: now }); + const conn = (userId: string) => { + const msgs: ServerMessage[] = []; + const c: Conn & { msgs: ServerMessage[] } = { userId, msgs, send: (m) => msgs.push(m), close: () => {} }; + return c; + }; + return { room, store, clock, deps, conn, db }; +} + +describe('Room timers', () => { + test('8. turn timeout applies onTimeout (omok perMove autoMove)', () => { + const { room, clock, conn } = harness(); + const a = conn('A'); + const b = conn('B'); + room.join(a, 'player'); + room.join(b, 'player'); + room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'perMove', perMoveSec: 10 } } }); + expect(room.start('A')).toBeNull(); + room.clearTimer(); + expect(room.effectiveDeadline()).toBe(clock.now() + 10_000); + clock.advance(9_999); + room.onTimer(); + room.clearTimer(); + expect((room.game!.state as any).moves).toHaveLength(0); + clock.advance(1); + room.onTimer(); + room.clearTimer(); + expect((room.game!.state as any).moves).toHaveLength(1); + expect((room.game!.state as any).moves[0].auto).toBe(true); + }); + + test('disconnected player is auto-acted after the grace period, not before the turn deadline rules', () => { + const { room, clock, conn } = harness(); + const a = conn('A'); + const b = conn('B'); + room.join(a, 'player'); + room.join(b, 'player'); + room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'none' } } }); + room.start('A'); + room.clearTimer(); + // No time control: idle limit 300s. Black disconnects → grace 60s wins. + room.disconnect(a); + room.clearTimer(); + expect(room.effectiveDeadline()).toBe(clock.now() + 60_000); + expect(b.msgs.some((m) => m.t === 'notice' && m.code === 'disconnected')).toBe(true); + // Reconnect before the grace period ends → deadline goes back to the idle limit. + clock.advance(30_000); + room.join(conn('A'), 'player'); + room.clearTimer(); + expect(room.effectiveDeadline()).toBe(1_000_000 + 300_000); + }); + + test('restore postpones overdue deadlines by 30s and keeps game state', () => { + const { room, clock, conn, deps, store } = harness(); + room.join(conn('A'), 'player'); + room.join(conn('B'), 'player'); + room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'perMove', perMoveSec: 10 } } }); + room.start('A'); + room.clearTimer(); + room.act('A', 1, { type: 'place', x: 7, y: 7 }); + room.clearTimer(); + const { seq, stateJson } = store.openRooms()[0]!; + clock.advance(600_000); // server was down for 10 minutes + const restored = Room.restore('room1', '482913', seq, JSON.parse(stateJson) as RoomSnapshot, deps); + restored.clearTimer(); + expect((restored.game!.state as any).moves).toHaveLength(1); + expect(restored.effectiveDeadline()).toBe(clock.now() + 30_000); + expect(restored.roomView().seats.every((s) => s && !s.connected)).toBe(true); + }); +}); + +describe('Room isolation and lobby', () => { + const boom: AnyGameDefinition = { + id: 'boom', + nameKo: '폭탄', + minPlayers: 2, + maxPlayers: 2, + stateVersion: 1, + defaultOptions: {}, + optionsSchema: z.object({}), + actionSchema: z.object({ type: z.literal('go') }), + setup: ({ players }) => ({ players, n: 0 }), + validate: () => ({ ok: true }), + apply: () => { + throw new Error('bug'); + }, + view: (s) => s, + activePlayers: (s: any) => s.players, + onTimeout: () => ({ type: 'go' }), + result: () => null, + }; + + test('a throwing game rejects the action, keeps state, pauses after 3 errors', () => { + const { room, conn } = harness({ boom }); + const a = conn('A'); + room.join(a, 'player'); + room.join(conn('B'), 'player'); + room.start('A'); + for (let i = 1; i <= 3; i++) room.act('A', i, { type: 'go' }); + expect(a.msgs.filter((m) => m.t === 'reject')).toHaveLength(3); + expect(room.status).toBe('paused'); + expect((room.game!.state as any).n).toBe(0); + expect(room.voidGame('A')).toBeNull(); + expect(room.status).toBe('finished'); + }); + + test('seat rules: start needs min players, only host starts, kicked users cannot rejoin', () => { + const { room, conn } = harness(); + room.join(conn('A'), 'player'); + expect(room.start('A')).toBe('1명 더 필요해요.'); + room.join(conn('B'), 'player'); + expect(room.start('B')).toBe('방장만 시작할 수 있어요.'); + room.join(conn('C'), 'player'); + expect(room.spectators.has('C')).toBe(true); + expect(room.kick('A', 'C')).toBeNull(); + expect(room.join(conn('C'), 'player')).toBe('방장이 내보낸 방에는 다시 들어갈 수 없어요.'); + }); + + test('rematch starts when all seated players vote; colours alternate with gameNo', () => { + const { room, conn } = harness(); + room.join(conn('A'), 'player'); + room.join(conn('B'), 'player'); + room.configure('A', { options: { colorAssignment: 'alternate' } }); + room.start('A'); + room.clearTimer(); + expect((room.game!.state as any).players.black).toBe('A'); + room.act('B', 1, { type: 'resign' }); + expect(room.status).toBe('finished'); + expect(room.sessionStats).toEqual({ A: { played: 1, wins: 1 }, B: { played: 1, wins: 0 } }); + room.rematch('A'); + expect(room.status).toBe('finished'); + room.rematch('B'); + room.clearTimer(); + expect(room.status).toBe('playing'); + expect(room.gameNo).toBe(2); + expect((room.game!.state as any).players.black).toBe('B'); + }); + + test('leaving mid-game resigns via onLeave', () => { + const { room, conn } = harness(); + room.join(conn('A'), 'player'); + room.join(conn('B'), 'player'); + room.start('A'); + room.clearTimer(); + room.leave('B'); + expect(room.status).toBe('finished'); + expect(room.lastResult?.ranking).toEqual([['A'], ['B']]); + expect(room.seatOf('B')).toBe(-1); + }); +}); diff --git a/apps/server/src/rooms/room.ts b/apps/server/src/rooms/room.ts index 79bdbab..338ab6b 100644 --- a/apps/server/src/rooms/room.ts +++ b/apps/server/src/rooms/room.ts @@ -90,6 +90,8 @@ export class Room { presence = new Map(); conns = new Map(); kicked = new Set(); + /** Players who left during a game; their seat is freed when the game ends. */ + leftDuringGame = new Set(); gameNo = 0; sessionStats: Record = {}; rematchVotes = new Set(); @@ -314,7 +316,8 @@ export class Room { } } const prev = this.presence.get(u); - this.presence.set(u, { connected: true, since: now, joinedAt: prev?.joinedAt ?? now }); + this.leftDuringGame.delete(u); + this.presence.set(u, { connected: true, since: now, joinedAt: prev?.joinedAt || now }); this.conns.set(u, conn); if (wasSeated && prev && !prev.connected && this.inGame(u)) { this.notice('reconnected', `${this.nick(u)}님이 다시 연결됐어요.`); @@ -358,10 +361,11 @@ export class Room { const seat = this.seatOf(userId); if (seat >= 0) { if (this.inGame(userId)) { + // Stay in the seat as "away" until the game ends so the game state stays consistent. + this.leftDuringGame.add(userId); + this.presence.set(userId, { connected: false, since: 0, joinedAt: 0 }); const action = this.def.onLeave?.(this.game!.state, userId); if (action) this.runAction(userId, action, undefined, true); - // Stay in the seat as "away" until the game ends so the game state stays consistent. - this.presence.set(userId, { connected: false, since: 0, joinedAt: 0 }); } else { this.seats[seat] = null; this.rematchVotes.delete(userId); @@ -708,12 +712,13 @@ export class Room { // Players who left during the game lose their seat now. for (let i = 0; i < this.seats.length; i++) { const s = this.seats[i]; - if (s && !this.presence.get(s.userId)?.connected && this.presence.get(s.userId)?.joinedAt === 0) { + if (s && this.leftDuringGame.has(s.userId)) { this.seats[i] = null; this.presence.delete(s.userId); this.deps.onSeatChange?.(this, s.userId, false); } } + this.leftDuringGame.clear(); this.persist({ actorId: null, kind: 'finish', data: { result, voided } }); this.broadcastState([{ type: 'gameOver' }]); for (const c of this.conns.values()) c.send({ t: 'result', seq: this.seq, result }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts new file mode 100644 index 0000000..c9e5970 --- /dev/null +++ b/apps/server/src/server.test.ts @@ -0,0 +1,300 @@ +/** Integration scenarios from docs/10-testing.md §3. */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { Client, api, boot, guest, sleep } from './test-utils'; + +type Booted = ReturnType; +const running: Booted[] = []; +const clients: Client[] = []; + +afterEach(async () => { + for (const c of clients.splice(0)) { + try { + c.close(); + } catch {} + } + for (const s of running.splice(0)) { + try { + await s.stop(); + } catch {} + } +}); + +function start(dbPath?: string): Booted { + const s = boot(dbPath); + running.push(s); + return s; +} + +async function client(s: Booted, nick: string, cookie?: { cookie: string; id: string }): Promise { + const g = cookie ?? (await guest(s.base, nick)); + const c = await new Client(s.base, g.cookie, g.id).connect(); + clients.push(c); + return c; +} + +async function createRoom(s: Booted, c: Client, gameId = 'omok'): Promise { + const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: c.cookie, body: JSON.stringify({ gameId }) }); + expect(r.res.status).toBe(200); + return r.body.code; +} + +async function join(c: Client, code: string, as: 'player' | 'spectator' = 'player') { + c.send({ t: 'join', code, as }); + return c.next('room'); +} + +/** A hosts (black), B joins; game started. */ +async function twoPlayerGame(s: Booted) { + const a = await client(s, '호랑이'); + const b = await client(s, '고양이'); + const code = await createRoom(s, a); + await join(a, code); + await join(b, code); + a.send({ t: 'config', options: { colorAssignment: 'hostBlack', ruleSet: 'free' } }); + await a.next('room', (m) => (m.room.options as any).colorAssignment === 'hostBlack'); + const sa = a.next('state'); + const sb = b.next('state'); + a.send({ t: 'start' }); + await Promise.all([sa, sb]); + return { a, b, code }; +} + +const place = (c: Client, x: number, y: number) => c.act({ type: 'place', x, y }); + +describe('M1 integration scenarios', () => { + test('1. create room, join by code, auto-seat, play omok to the end, result stored', async () => { + const s = start(); + const { a, b, code } = await twoPlayerGame(s); + expect(code).toMatch(/^\d{6}$/); + const room = a.lastOf('room')!.room; + expect(room.seats.filter(Boolean).map((x) => x!.user.nickname)).toEqual(['호랑이', '고양이']); + // Black (A) plays a horizontal five, White (B) answers elsewhere. + for (let i = 0; i < 4; i++) { + let p = b.next('state', (m) => (m.view as any).turn === 'white'); + place(a, 3 + i, 7); + await p; + p = a.next('state', (m) => (m.view as any).turn === 'black'); + place(b, 3 + i, 0); + await p; + } + const res = a.next('result'); + place(a, 7, 7); + const r = await res; + expect(r.result.ranking).toEqual([[a.id], [b.id]]); + const stats = await api(s.base, '/api/me/stats', { cookie: a.cookie }); + expect(stats.body.stats).toEqual([{ gameId: 'omok', played: 1, wins: 1, draws: 0 }]); + const room2 = (await a.next('room', (m) => m.room.status === 'finished').catch(() => a.lastOf('room')!)).room; + expect(room2.lastSeed).toMatch(/^[0-9a-f]{32}$/); + expect(room2.sessionStats[a.id]).toEqual({ played: 1, wins: 1 }); + }); + + test('2. joining via link preview without login, then guest + auto seat', async () => { + const s = start(); + const a = await client(s, '방장님'); + const code = await createRoom(s, a); + await join(a, code); + const preview = await api(s.base, `/api/rooms/${code}`); + expect(preview.body).toMatchObject({ code, gameId: 'omok', host: '방장님', seated: 1, maxPlayers: 2 }); + const b = await client(s, '손님'); + const r = await join(b, code); + expect(r.room.seats[1]?.user.id).toBe(b.id); + // A third person becomes a spectator. + const c = await client(s, '구경꾼'); + const r3 = await join(c, code); + expect(r3.room.spectators.map((u) => u.id)).toContain(c.id); + }); + + test('3. disconnect and reconnect keeps seat and state', async () => { + const s = start(); + const { a, b, code } = await twoPlayerGame(s); + const p = b.next('state', (m) => (m.view as any).moveCount === 1); + place(a, 7, 7); + await p; + const notice = a.next('notice', (m) => m.code === 'disconnected'); + b.close(); + await notice; + const roomMsg = await a.next('room', (m) => m.room.seats[1]?.connected === false).catch(() => a.lastOf('room')!); + expect(roomMsg.room.seats[1]?.connected).toBe(false); + const b2 = await client(s, '', { cookie: b.cookie, id: b.id }); + expect(b2.lastOf('welcome')!.activeRoom).toBe(code); + b2.send({ t: 'join', code, as: 'player' }); + const st = await b2.next('state'); + expect((st.view as any).board[7 * 15 + 7]).toBe(1); + expect((st.view as any).turn).toBe('white'); + const after = b2.lastOf('room') ?? (await b2.next('room')); + expect(after.room.seats[1]?.user.id).toBe(b.id); + }); + + test('4. server crash (no graceful stop) → restart restores the game', async () => { + const s1 = boot(); + const { a, b, code } = await twoPlayerGame(s1); + let p = b.next('state', (m) => (m.view as any).moveCount === 1); + place(a, 7, 7); + await p; + p = a.next('state', (m) => (m.view as any).moveCount === 2); + place(b, 0, 0); + await p; + // Simulate a crash: drop the listener and timers without the graceful bye/cleanup. + for (const r of s1.rooms.all()) r.clearTimer(); + await Promise.race([s1.server.stop(true), sleep(300)]); + s1.db.close(); + const s2 = start(s1.dbPath); + const a2 = await client(s2, '', { cookie: a.cookie, id: a.id }); + const b2 = await client(s2, '', { cookie: b.cookie, id: b.id }); + expect(a2.lastOf('welcome')!.activeRoom).toBe(code); + a2.send({ t: 'join', code, as: 'player' }); + b2.send({ t: 'join', code, as: 'player' }); + const st = await a2.next('state'); + const v = st.view as any; + expect(v.moveCount).toBe(2); + expect(v.turn).toBe('black'); + const p2 = b2.next('state', (m) => (m.view as any).moveCount === 3); + place(a2, 8, 8); + await p2; + }); + + test('5. acting out of turn is rejected without state change', async () => { + const s = start(); + const { a, b } = await twoPlayerGame(s); + const before = b.lastOf('state')!.seq; + const rej = b.next('reject'); + const cs = place(b, 1, 1); + const r = await rej; + expect(r.cs).toBe(cs); + expect(r.reason).toBe('지금은 내 차례가 아니에요.'); + await sleep(50); + expect(a.lastOf('state')!.seq).toBe(before); + }); + + test('6. duplicate cs is processed once', async () => { + const s = start(); + const { a, b } = await twoPlayerGame(s); + const cs = place(a, 7, 7); + a.send({ t: 'act', cs, a: { type: 'place', x: 8, y: 8 } }); + await b.next('state', (m) => (m.view as any).moveCount >= 1); + await sleep(100); + const states = b.messages.filter((m) => m.t === 'state'); + expect((states[states.length - 1]!.view as any).moveCount).toBe(1); + const mine = a.messages.filter((m) => m.t === 'state' && m.cs === cs); + expect(mine).toHaveLength(1); + }); + + test('7. second tab replaces the first', async () => { + const s = start(); + const a = await client(s, '탭주인'); + const code = await createRoom(s, a); + await join(a, code); + const a2 = await client(s, '', { cookie: a.cookie, id: a.id }); + const bye = a.next('bye'); + await join(a2, code); + expect((await bye).reason).toBe('replaced'); + }); + + test('9. host leaves → host transfers', async () => { + const s = start(); + const a = await client(s, '첫방장'); + const b = await client(s, '다음방장'); + const code = await createRoom(s, a); + await join(a, code); + await join(b, code); + const r = b.next('room', (m) => m.room.hostId === b.id); + a.send({ t: 'leave' }); + expect((await r).room.seats.filter(Boolean)).toHaveLength(1); + }); + + test('10. flooding is rate limited and the server stays healthy', async () => { + const s = start(); + const a = await client(s, '도배왕'); + for (let i = 0; i < 100; i++) a.send({ t: 'ping', ts: i }); + await sleep(200); + const pongs = a.messages.filter((m) => m.t === 'pong').length; + expect(pongs).toBeLessThanOrEqual(45); + expect(pongs).toBeGreaterThan(0); + const h = await api(s.base, '/healthz'); + expect(h.res.status).toBe(200); + }); + + test('11. malformed / oversized / schema-invalid messages', async () => { + const s = start(); + const a = await client(s, '이상한값'); + a.ws.send('{not json'); + await a.next('error', (m) => m.code === 'bad-json'); + a.send({ t: 'act', cs: 'x' }); + await a.next('error', (m) => m.code === 'bad-message'); + a.ws.send('x'.repeat(20 * 1024)); + for (let i = 0; i < 20 && !a.closed; i++) await sleep(25); + expect(a.closed?.code).toBe(1009); + const h = await api(s.base, '/healthz'); + expect(h.res.status).toBe(200); + }); + + test('12a. discord login is hidden when not configured', async () => { + const s = boot(undefined, { + discordExchange: async () => ({ id: '123', username: 'disco', global_name: '디코친구', avatar: null }), + }); + running.push(s); + // Discord disabled in this config → 404. + const off = await api(s.base, '/api/auth/discord/start'); + expect(off.res.status).toBe(404); + }); + + test('origin check: foreign origin cannot open WS or POST', async () => { + const s = start(); + const g = await guest(s.base, '보안확인'); + const res = await fetch(s.base + '/ws', { headers: { origin: 'https://evil.example', cookie: g.cookie, upgrade: 'websocket', connection: 'Upgrade', 'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==', 'sec-websocket-version': '13' } }); + expect(res.status).toBe(403); + const post = await fetch(s.base + '/api/rooms', { method: 'POST', headers: { origin: 'https://evil.example', cookie: g.cookie, 'content-type': 'application/json' }, body: '{"gameId":"omok"}' }); + expect(post.status).toBe(403); + }); + + test('nickname rules and change', async () => { + const s = start(); + const bad = await api(s.base, '/api/auth/guest', { method: 'POST', body: JSON.stringify({ nickname: 'a' }) }); + expect(bad.res.status).toBe(400); + const g = await guest(s.base, ' 처음 이름 '); + const me = await api(s.base, '/api/me', { cookie: g.cookie }); + expect(me.body.me.nickname).toBe('처음 이름'); + const ch = await api(s.base, '/api/me', { method: 'PATCH', cookie: g.cookie, body: JSON.stringify({ nickname: '바뀐이름' }) }); + expect(ch.body.me.nickname).toBe('바뀐이름'); + }); + + test('chat is filtered and spectators receive public view only', async () => { + const s = start(); + const { a, code } = await twoPlayerGame(s); + const c = await client(s, '관전자'); + c.send({ t: 'join', code, as: 'spectator' }); + const st = await c.next('state'); + expect((st.view as any).myColor).toBeNull(); + const chat = c.next('chat'); + a.send({ t: 'chat', text: '안녕 씨발' }); + expect((await chat).text).toBe('안녕 **'); + }); +}); + +describe('Discord login flow', () => { + test('state mismatch rejected, valid flow merges guest into new member', async () => { + const s = boot(undefined, { + discordExchange: async () => ({ id: '42', username: 'disco', global_name: '디코친구', avatar: 'abc' }), + }); + running.push(s); + s.config.discord = { clientId: 'cid', clientSecret: 'sec' }; + const g = await guest(s.base, '게스트였음'); + const startRes = await api(s.base, '/api/auth/discord/start?next=/r/123456', { cookie: g.cookie }); + expect(startRes.res.status).toBe(302); + const loc = new URL(startRes.res.headers.get('location')!); + expect(loc.host).toBe('discord.com'); + const state = loc.searchParams.get('state')!; + const oauthCookie = startRes.setCookie!.split(';')[0]!; + const bad = await api(s.base, `/api/auth/discord/callback?code=x&state=wrong`, { cookie: `${g.cookie}; ${oauthCookie}` }); + expect(bad.res.headers.get('location')).toBe('/?login=failed'); + const good = await api(s.base, `/api/auth/discord/callback?code=x&state=${state}`, { cookie: `${g.cookie}; ${oauthCookie}` }); + expect(good.res.headers.get('location')).toBe('/r/123456?linked=1'); + const sid = good.setCookie!.split(/,(?=\s*\w+=)/).find((c) => c.trim().startsWith('sid='))!.split(';')[0]!.trim(); + const me = await api(s.base, '/api/me', { cookie: sid }); + expect(me.body.me).toMatchObject({ id: g.id, nickname: '게스트였음', kind: 'member' }); + expect(me.body.me.avatar).toBe('https://cdn.discordapp.com/avatars/42/abc.png?size=128'); + // Old guest session no longer valid. + const old = await api(s.base, '/api/me', { cookie: g.cookie }); + expect(old.body.me).toBeNull(); + }); +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e3c3f14..f6d82b8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -106,7 +106,9 @@ export function startServer(opts: StartOptions) { clearInterval(sweeper); gateway.shutdown(); rooms.shutdown(); - await server.stop(true); + // Bun's stop() promise can stay pending after server-initiated WS closes even though the + // listener is already closed, so bound the wait. + await Promise.race([server.stop(true), Bun.sleep(1000)]); db.close(); }; diff --git a/apps/server/src/test-utils.ts b/apps/server/src/test-utils.ts new file mode 100644 index 0000000..a479476 --- /dev/null +++ b/apps/server/src/test-utils.ts @@ -0,0 +1,128 @@ +/** Helpers for integration tests: boot a real server, act as browser clients. */ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ServerMessage } from '@bg/shared'; +import { loadConfig } from './config'; +import { startServer, type StartOptions } from './server'; + +export const ORIGIN = 'http://localhost:5173'; + +export function tempDbPath(): string { + const dir = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), 'bg-test-')); + return join(dir, 'app.db'); +} + +export function boot(dbPath = tempDbPath(), extra: Partial = {}) { + const config = loadConfig({ PORT: '0', DB_PATH: dbPath, PUBLIC_ORIGIN: ORIGIN }); + const app = startServer({ config, log: () => {}, staticDir: null, ...extra }); + const base = `http://localhost:${app.server.port}`; + return { ...app, base, dbPath, config }; +} + +export async function api(base: string, path: string, init: RequestInit & { cookie?: string } = {}) { + const headers = new Headers(init.headers); + headers.set('origin', ORIGIN); + if (init.cookie) headers.set('cookie', init.cookie); + if (init.body && !headers.has('content-type')) headers.set('content-type', 'application/json'); + const res = await fetch(base + path, { ...init, headers, redirect: 'manual' }); + const setCookie = res.headers.get('set-cookie'); + let body: any = null; + try { + body = await res.clone().json(); + } catch { + body = await res.text(); + } + return { res, body, setCookie }; +} + +export async function guest(base: string, nickname: string): Promise<{ cookie: string; id: string }> { + const r = await api(base, '/api/auth/guest', { method: 'POST', body: JSON.stringify({ nickname }) }); + if (r.res.status !== 200) throw new Error(`guest failed: ${JSON.stringify(r.body)}`); + const cookie = r.setCookie!.split(';')[0]!; + return { cookie, id: r.body.me.id }; +} + +export class Client { + ws!: WebSocket; + messages: ServerMessage[] = []; + private waiters: { pred: (m: ServerMessage) => boolean; resolve: (m: ServerMessage) => void }[] = []; + closed: { code: number; reason: string } | null = null; + cs = 0; + + constructor( + private base: string, + public cookie: string, + public id: string, + ) {} + + async connect(): Promise { + this.closed = null; + const url = this.base.replace('http', 'ws') + '/ws'; + // Bun's WebSocket client accepts custom headers. + this.ws = new WebSocket(url, { headers: { cookie: this.cookie, origin: ORIGIN } } as unknown as string[]); + this.ws.onmessage = (e) => { + const m = JSON.parse(String(e.data)) as ServerMessage; + this.messages.push(m); + for (const w of [...this.waiters]) { + if (w.pred(m)) { + this.waiters.splice(this.waiters.indexOf(w), 1); + w.resolve(m); + } + } + }; + this.ws.onclose = (e) => { + this.closed = { code: e.code, reason: e.reason }; + }; + await this.waitFor((m) => m.t === 'welcome'); + return this; + } + + send(m: unknown): void { + this.ws.send(JSON.stringify(m)); + } + + /** Resolves with the first message (already received after `since`, or future) matching pred. */ + waitFor( + pred: (m: ServerMessage) => boolean, + timeout = 3000, + since = 0, + ): Promise> { + const existing = this.messages.slice(since).find(pred); + if (existing) return Promise.resolve(existing as any); + return new Promise((resolve, reject) => { + const w = { pred, resolve: resolve as (m: ServerMessage) => void }; + this.waiters.push(w); + setTimeout(() => { + const i = this.waiters.indexOf(w); + if (i >= 0) { + this.waiters.splice(i, 1); + reject(new Error(`timeout waiting; last: ${JSON.stringify(this.messages.slice(-3))}`)); + } + }, timeout); + }); + } + + /** Waits for the next message of type t received after now. */ + next(t: T, pred: (m: Extract) => boolean = () => true, timeout = 3000) { + const since = this.messages.length; + return this.waitFor((m) => m.t === t && pred(m as any), timeout, since); + } + + lastOf(t: T): Extract | undefined { + for (let i = this.messages.length - 1; i >= 0; i--) if (this.messages[i]!.t === t) return this.messages[i] as any; + return undefined; + } + + act(a: unknown): number { + const cs = ++this.cs; + this.send({ t: 'act', cs, a }); + return cs; + } + + close(): void { + this.ws.close(); + } +} + +export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));