diff --git a/web/e2e/helpers.js b/web/e2e/helpers.js index 30f8aed..3e7450e 100644 --- a/web/e2e/helpers.js +++ b/web/e2e/helpers.js @@ -120,12 +120,19 @@ export async function say(page, text) { * Takes a seated pair from their lobby into a game: the guest readies, the * owner starts. Nothing begins on its own now, so every online test that is * about a game goes through here. + * + * Waits for each guest's own row to show ready before touching Start: the + * click only asks the server, and asserting the owner's button next proves + * nothing about whether the guest's SetReady actually left the client — + * `send()` returns false while the socket is not open and nothing retries + * it, which is exactly the gap that made this assertion flake. * @param {import('@playwright/test').Page} owner - * @param {import('@playwright/test').Page} guest + * @param {import('@playwright/test').Page[]} guests */ export async function readyAndStart(owner, ...guests) { for (const guest of guests) { await guest.getByTestId('ready').click(); + await expect(guest.getByTestId('my-ready')).toHaveText('Đã sẵn sàng'); } const start = owner.getByTestId('start-game'); await expect(start).toBeEnabled(); diff --git a/web/e2e/pvp-game.spec.js b/web/e2e/pvp-game.spec.js index b4ebfd6..2a13fa6 100644 --- a/web/e2e/pvp-game.spec.js +++ b/web/e2e/pvp-game.spec.js @@ -74,7 +74,9 @@ async function joinRoomSeated(page, nickname, code) { async function playingPair(browser) { const pair = await twoPlayers(browser); const code = await createRoom(pair.host, 'Minh'); - await joinRoom(pair.guest, 'Lan', code); + // Seated, not merely joined: readyAndStart clicks the guest's own ready + // row next, which does not exist until the seat does. + await joinRoomSeated(pair.guest, 'Lan', code); await readyAndStart(pair.host, pair.guest); const { lead, diff --git a/web/e2e/socket-cut.js b/web/e2e/socket-cut.js index 707493f..ae899f4 100644 --- a/web/e2e/socket-cut.js +++ b/web/e2e/socket-cut.js @@ -13,9 +13,9 @@ * @param {import('@playwright/test').Page} page */ export async function cuttableSocket(page) { - /** @type {any} */ + /** @type {import('@playwright/test').WebSocketRoute | null} */ let live = null; - /** @type {any} */ + /** @type {import('@playwright/test').WebSocketRoute | null} */ let upstream = null; let blocked = false; diff --git a/web/src/lib/stores/bot-session.svelte.js b/web/src/lib/stores/bot-session.svelte.js index 730cf37..8642271 100644 --- a/web/src/lib/stores/bot-session.svelte.js +++ b/web/src/lib/stores/bot-session.svelte.js @@ -20,7 +20,7 @@ export function createBotSession({ start }) { const state = $state({ /** @type {number | null} The difficulty waiting to be requested. */ pending: null, - /** @type {object | null} The result already counted towards a record. */ + /** @type {{ myScore: number } | null} The result already counted towards a record. */ scored: null }); @@ -63,7 +63,7 @@ export function createBotSession({ start }) { * Identity of the result object is the guard rather than a boolean, so * re-entering the screen with the same result cannot score it twice and * a genuinely new result is never mistaken for the old one. - * @param {object | null} result - GameOver as the store holds it + * @param {{ myScore: number } | null} result - GameOver as the store holds it * @param {number} difficulty * @param {{ recordScore: (difficulty: number, score: number) => boolean }} settings * @returns {boolean} whether this game set a new record @@ -71,7 +71,7 @@ export function createBotSession({ start }) { score(result, difficulty, settings) { if (!result || state.scored === result) return false; state.scored = result; - return settings.recordScore(difficulty, /** @type {any} */ (result).myScore); + return settings.recordScore(difficulty, result.myScore); }, /** Forgets the scored result so a new game can set a record again. */ diff --git a/web/src/lib/ws/client.js b/web/src/lib/ws/client.js index e807e0c..aa0a7ed 100644 --- a/web/src/lib/ws/client.js +++ b/web/src/lib/ws/client.js @@ -2,6 +2,11 @@ import { fromBinary, toBinary } from '@bufbuild/protobuf'; import { ClientMessageSchema, ServerMessageSchema } from '$lib/proto/noitu/v1/game_pb.js'; import { hello, ping } from './messages.js'; +/** + * @typedef {import('$lib/proto/noitu/v1/game_pb.js').ClientMessage} ClientMessage + * @typedef {import('$lib/proto/noitu/v1/game_pb.js').ServerMessage} ServerMessage + */ + /** Connection states surfaced to the UI. */ export const Status = { CONNECTING: 'connecting', @@ -97,14 +102,14 @@ export function hasStoredSession() { * @param {object} options * @param {() => string} options.nickname - read at each connect, so a name * changed between attempts is the one the server is told about - * @param {(msg: any) => void} options.onMessage + * @param {(msg: ServerMessage) => void} options.onMessage * @param {(status: string) => void} [options.onStatus] * @param {string} [options.url] * @param {(url: string) => WebSocket} [options.socketFactory] * @param {() => number} [options.now] * @param {() => number} [options.random] - jitter source * @param {typeof setTimeout} [options.schedule] - * @param {(id: any) => void} [options.cancel] + * @param {(id: ReturnType) => void} [options.cancel] */ export function createClient({ nickname, @@ -123,9 +128,9 @@ export function createClient({ // Set by a deliberate close and by a server error that reconnecting cannot // fix. Both mean the same thing to onclose: do not come back. let stopReconnecting = false; - /** @type {any} */ + /** @type {ReturnType | null} */ let reconnectTimer = null; - /** @type {any} */ + /** @type {ReturnType | null} */ let pingTimer = null; let clockOffsetMs = 0; let status = Status.CLOSED; @@ -266,7 +271,7 @@ export function createClient({ * Two messages are the transport's own business before the UI sees them: * Welcome carries the token a reconnect needs, and Pong is the clock probe. * Both are still forwarded, because the UI shows the accepted nickname. - * @param {any} msg + * @param {ServerMessage} msg */ function intercept(msg) { const payload = msg.payload; @@ -294,7 +299,7 @@ export function createClient({ } } - /** @param {any} msg */ + /** @param {ClientMessage} msg */ function send(msg) { if (!socket || socket.readyState !== 1) return false; socket.send(toBinary(ClientMessageSchema, msg)); diff --git a/web/src/lib/ws/connection.svelte.js b/web/src/lib/ws/connection.svelte.js index b7519db..aae6d2d 100644 --- a/web/src/lib/ws/connection.svelte.js +++ b/web/src/lib/ws/connection.svelte.js @@ -2,6 +2,8 @@ import { Status, createClient, hasStoredSession } from './client.js'; import { game } from '$lib/stores/game.svelte.js'; import { settings } from '$lib/stores/settings.svelte.js'; +/** @typedef {import('$lib/proto/noitu/v1/game_pb.js').ClientMessage} ClientMessage */ + /** * One socket for the whole app. * @@ -33,7 +35,7 @@ export function connect() { * Deliberately does not open the socket: a caller that has not connected yet * has nothing queued to resume, and auto-connecting here would reopen the * connection during teardown. - * @param {any} msg - a ClientMessage + * @param {ClientMessage} msg * @returns {boolean} */ export function send(msg) { diff --git a/web/tests/game-store.test.js b/web/tests/game-store.test.js index 5f8ea7c..74228a5 100644 --- a/web/tests/game-store.test.js +++ b/web/tests/game-store.test.js @@ -621,7 +621,7 @@ describe('chat', () => { store.apply(line({ text: 'một' })); store.apply(line({ text: 'hai', fromMe: true })); - expect(store.state.chat.map((/** @type {any} */ m) => m.text)).toEqual(['một', 'hai']); + expect(store.state.chat.map((m) => m.text)).toEqual(['một', 'hai']); expect(typeof store.state.chat[0].atMs).toBe('number'); expect(store.state.chat[0].atMs).toBe(1756998000123); }); @@ -632,7 +632,7 @@ describe('chat', () => { // An author the server cleared: the seat goes with the name. store.apply(line({ text: 'của ai', playerId: '', author: '' })); - expect(store.state.chat.map((/** @type {any} */ m) => m.playerId)).toEqual(['p1', '']); + expect(store.state.chat.map((m) => m.playerId)).toEqual(['p1', '']); }); it('stops at the window the server keeps, so the two cannot disagree', () => { @@ -658,7 +658,7 @@ describe('chat', () => { store.apply(history); store.apply(history); - expect(store.state.chat.map((/** @type {any} */ m) => m.text)).toEqual(['a', 'b']); + expect(store.state.chat.map((m) => m.text)).toEqual(['a', 'b']); }); it('survives a game starting: the conversation belongs to the room', () => { diff --git a/web/tests/game-wire.test.js b/web/tests/game-wire.test.js index 8262a47..97b0e3f 100644 --- a/web/tests/game-wire.test.js +++ b/web/tests/game-wire.test.js @@ -24,7 +24,12 @@ const fixtureDir = fileURLToPath(new URL('../../proto/testdata', import.meta.url const fixtures = readdirSync(fixtureDir).filter((f) => f.endsWith('.bin')); -/** Decode a fixture by name, choosing the schema from its client_/server_ prefix. */ +/** + * Decode a fixture by name, choosing the schema from its client_/server_ prefix. + * @param {string} name + * @returns {import('../src/lib/proto/noitu/v1/game_pb.js').ClientMessage + * | import('../src/lib/proto/noitu/v1/game_pb.js').ServerMessage} + */ function decode(name) { const bytes = readFileSync(join(fixtureDir, `${name}.bin`)); const schema = name.startsWith('client_') ? ClientMessageSchema : ServerMessageSchema; @@ -73,7 +78,7 @@ describe('generated wire types', () => { expect(over.payload.value.reason).toBe(GameEndReason.NO_LEGAL_MOVE); expect(over.payload.value.iWon).toBe(false); // The final table, in the order the server ranked it. - expect(over.payload.value.standings.map((/** @type {any} */ p) => p.rank)).toEqual([1, 2, 3]); + expect(over.payload.value.standings.map((p) => p.rank)).toEqual([1, 2, 3]); const out = decode('server_player_eliminated'); // The only repeated string in the contract, and the one the losing @@ -139,7 +144,7 @@ describe('generated wire types', () => { const turn = decode('server_turn_update'); const parts = turn.payload.value.played.parts; expect(parts.length).toBeGreaterThan(0); - const sum = parts.reduce((total, /** @type {any} */ p) => total + p.value, 0); + const sum = parts.reduce((total, p) => total + p.value, 0); expect(sum).toBe(turn.payload.value.played.points); }); diff --git a/web/tests/ws-client.test.js b/web/tests/ws-client.test.js index c874b8f..22ea461 100644 --- a/web/tests/ws-client.test.js +++ b/web/tests/ws-client.test.js @@ -51,7 +51,7 @@ class FakeSocket { this.onopen(); } - /** @param {any} serverMessage */ + /** @param {import('../src/lib/proto/noitu/v1/game_pb.js').ServerMessage} serverMessage */ deliver(serverMessage) { this.onmessage({ data: toBinary(ServerMessageSchema, serverMessage).buffer }); } @@ -104,7 +104,7 @@ function serverMsg(kind, value) { function setup(options = {}) { /** @type {FakeSocket[]} */ const sockets = []; - /** @type {any[]} */ + /** @type {import('../src/lib/proto/noitu/v1/game_pb.js').ServerMessage[]} */ const received = []; /** @type {string[]} */ const statuses = []; @@ -380,7 +380,7 @@ describe('handshake ordering', () => { // ahead of it. /** @type {string[]} */ const order = []; - /** @type {any} */ + /** @type {FakeSocket} */ let socket; const client = createClient({ nickname: () => 'Minh', @@ -408,7 +408,7 @@ describe('handshake ordering', () => { }); describe('a handshake the server refuses', () => { - /** @param {any} h */ + /** @param {ReturnType} h */ function refuseVersion(h) { h.client.connect(); h.last().open(); @@ -452,7 +452,7 @@ describe('reconnecting on demand', () => { // an attempt now. What it must not do is open a second socket, or retry a // handshake the server has already refused outright. - /** @param {any} h */ + /** @param {ReturnType} h */ function dropAfterOpen(h) { h.client.connect(); h.last().open(); @@ -568,7 +568,7 @@ describe('a socket that dies without closing', () => { /** * Fires the ping timer `ticks` times, advancing the clock by one interval * each time — a healthy tab whose timers are running on schedule. - * @param {any} h + * @param {ReturnType} h * @param {number} ticks * @param {number} start */