import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import vm from 'node:vm'; import ts from 'typescript'; import { GAME_CHECKPOINT_KEY, GAME_ROOM_KEY } from '../constants/recording.js'; import { HOME_TELEPORT_APPEARANCE } from '../constants/home.js'; import { createGameApi, ApiClientError } from '../src/client/api.js'; import { emptyPlayerStats } from '../src/shared/p2p-types.js'; const source = readFileSync('src/client/main.ts', 'utf8').replace(/^import .*;\r?\n/gm, ''); const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }).outputText; const flush = async () => { for (let i = 0; i < 8; i++) await new Promise(r => setImmediate(r)); }; async function setup(kind: 'expired' | 'orphan' | 'rematch-wait') { const mem = new Map(), calls: string[] = [], polls = new Map void>(), launched: string[] = []; const active = { matchId: 'm', seq: 1, mode: 'friend', roomId: 'r' }; const session: any = { gameSessionId: 's', token: 't'.repeat(43), stats: emptyPlayerStats(), lastAppliedSeq: kind === 'orphan' ? 0 : 1, ...(kind === 'orphan' ? { activeMatch: active } : {}) }; const room: any = { id: 'r', code: 'ABCDEF', currentMatchId: 'm', round: 1, selfIndex: 0, status: 'paired', count: 2, expiresAt: Date.now() + 60000, rematch: [kind === 'rematch-wait', false] }; let open = true, pollId = 0; mem.set(GAME_ROOM_KEY, JSON.stringify({ gameSessionId: 's', room })); if (kind !== 'orphan') mem.set(GAME_CHECKPOINT_KEY, JSON.stringify({ gameSessionId: 's', activeMatch: active, checkpoint: { state: { matchId: 'm', mode: 'friend', phase: 'finished', selfIndex: 0, version: 1, finished: { saved: true, stats: session.stats, reason: 'hit' }, rematch: room.rematch, saveStatus: 'saved', } } })); const send: typeof fetch = async (input, init) => { const path = String(input); calls.push(path); let data: any = session; if (path === '/api/rooms/r' || path.endsWith('/leave')) { if (kind === 'expired') return new Response(JSON.stringify({ ok: false, code: 'ROOM_EXPIRED', message: 'expired' }), { status: 410 }); if (path.endsWith('/leave')) open = false; data = room; } else if (path === '/api/game-session/results') { delete session.activeMatch; session.lastAppliedSeq = 1; data = { status: 'applied', stats: session.stats }; } else if (path === '/api/game-session/matches') { const body = JSON.parse(String(init?.body)); data = { ...body, seq: 2 }; } else if (path.startsWith('/api/rtc-config')) data = { iceServers: [] }; return new Response(JSON.stringify({ ok: true, data })); }; const storage = { getItem: (k: string) => mem.get(k) ?? null, setItem: (k: string, v: string) => { mem.set(k,v); }, removeItem: (k: string) => { mem.delete(k); } }; const nodes = new Map(); const el = (id: string) => { if (!nodes.has(id)) nodes.set(id, { classList: { toggle() {}, add() {}, remove() {} }, addEventListener() {}, focus() {} }); return nodes.get(id); }; const ctx: any = { Map, Date, Promise, JSON, URL, URLSearchParams, Number, crypto, ApiClientError, GAME_CHECKPOINT_KEY, GAME_ROOM_KEY, GUEST_TOKEN_KEY: 'guest', ROOM_POLL_MS: 1000, ACTIONS: {}, ACTION_IDS: [], document: { getElementById: el, body: { dataset: {} }, querySelectorAll: () => [] }, location: { origin: 'http://local', search: '' }, sessionStorage: storage, localStorage: { getItem: () => null, setItem() {} }, navigator: {}, fighterArt: () => '', GAME_NAME: '쎄쎄쎄:기공대전', HOME_TELEPORT_APPEARANCE, createGameApi: () => createGameApi(storage, send), createClientAnalytics: () => ({ ready() {}, screen() {}, gameFinished() {}, gameStarted() {}, error() {}, click() {} }), createResultReporter: (api: any) => ({ destroy() {}, resumePending: async () => {}, submit: api.record }), createMatchView: () => ({ update() {}, destroy() {} }), createPeer: () => ({}), createLobbyFeatures: () => ({ initialize: async () => false, setReady() {}, active: false }), createMatchController: (config: any) => { launched.push(config.activeMatch.matchId); return { subscribe() {}, start: async () => {}, view: () => ({ matchId: config.activeMatch.matchId }), destroy() {} }; }, setTimeout(fn: () => void) { polls.set(++pollId, fn); return pollId; }, clearTimeout(id: number) { polls.delete(id); }, }; vm.createContext(ctx); vm.runInContext(compiled + '\nglobalThis.review={leave,boot,inspect:()=>({ready,currentScreen,currentRoom})};', ctx); await flush(); return { ctx, calls, polls, room, mem, launched, open: () => open }; } test('already-expired room does not trap a restored result on exit', async () => { const h = await setup('expired'); await h.ctx.review.leave(); assert.equal(h.ctx.review.inspect().currentScreen, 'home'); assert.equal(h.mem.has(GAME_ROOM_KEY), false); }); test('orphan registration recovery closes the persisted room membership before forgetting it', async () => { const h = await setup('orphan'); assert.equal(h.open(), false); assert.ok(h.calls.includes('/api/rooms/r/leave')); assert.equal(h.ctx.review.inspect().currentScreen, 'home'); }); test('refresh restores HTTP rematch consent polling and starts an advanced round', async () => { const h = await setup('rematch-wait'); assert.ok(h.calls.includes('/api/rooms/r')); assert.equal(h.polls.size, 1); h.room.round = 2; h.room.currentMatchId = 'new-match'; h.room.rematch = [false, false]; [...h.polls.values()][0](); await flush(); assert.deepEqual(h.launched, ['new-match']); });