refactor(web): type the wire instead of passing any across it

Use the generated ServerMessage/ClientMessage union everywhere a decoded
message crosses a function boundary, so a typo in a payload field is a build
error instead of undefined at runtime. Clears every jsdoc/reject-any-type
warning in src/ and all but one in tests/ — room-code.test.js keeps one to
deliberately call normalizeRoomCode(undefined) against its documented
string-only signature, which is the point of that test.

Also fixes the flaky e2e helper: playingPair now waits for the guest's seat
(joinRoomSeated) before readyAndStart, and readyAndStart asserts the guest's
own ready row before touching Start, since a SetReady send can silently drop
while the socket is not open and nothing retries it.
This commit is contained in:
tiennm99 committed 2026-09-21 16:07:02 +07:00
1 parent c4502f2793
commit 70ae09d16b
9 files changed
+47 -26

No files matched your search

+8 -1
View File
@@ -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();
+3 -1
View File
@@ -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,
+2 -2
View File
@@ -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;
+3 -3
View File
@@ -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. */
+11 -6
View File
@@ -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<typeof setTimeout>) => 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<typeof setTimeout> | null} */
let reconnectTimer = null;
/** @type {any} */
/** @type {ReturnType<typeof setTimeout> | 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));
+3 -1
View File
@@ -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) {
+3 -3
View File
@@ -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', () => {
+8 -3
View File
@@ -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);
});
+6 -6
View File
@@ -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<typeof setup>} 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<typeof setup>} 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<typeof setup>} h
* @param {number} ticks
* @param {number} start
*/