mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
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:
1 parent
c4502f2793
commit
70ae09d16b
9 files changed
+47
-26
No files matched your search
+8
-1
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user