Files
noitu/web/tests/connection.test.js
T
tiennm99 1f2624c0e3 fix(web): clear stale rooms on refused resume, harden reconnect paths
A refused resume now leaves the room instead of showing a banner over a
dead board, and /play offers a fresh game there. Entering /play forgets
any stored token so it no longer races StartBotGame. An eliminated
player who reconnects stays a spectator. Held lobby actions wait for the
resume's RoomState; leaving while offline is not held. Chat clears its
draft only when the line was sent and cannot send while offline.

Also: away banners no longer announce every second; game over does not
steal focus from the chat field; the clock offset uses the lowest-RTT
pong; the history export numbers a resumed chain from its real length;
chat replay keeps existing ordinals; one .primary button class replaces
five copies; the footer links the data change list; the online heading
joins the type ramp. New component, connection and storage-guard tests.
2026-09-29 20:33:16 +07:00

135 lines
3.7 KiB
JavaScript

// @vitest-environment jsdom
// The module-level connection is what a screen actually calls. These tests
// drive it through a stubbed WebSocket, because what they pin is which resume
// token a screen's socket carries and what happens to the board when the
// server refuses one.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
import {
ClientMessageSchema,
ServerMessageSchema
} from '../src/lib/proto/noitu/v1/game_pb.js';
import { game } from '../src/lib/stores/game.svelte.js';
import { connect, disconnect } from '../src/lib/ws/connection.svelte.js';
import { receive } from './component-support.js';
const RESUME_KEY = 'noitu.resumeToken';
/** @type {FakeWebSocket[]} */
let sockets = [];
class FakeWebSocket {
/** @param {string} url */
constructor(url) {
this.url = url;
this.readyState = 0;
this.binaryType = '';
/** @type {Uint8Array[]} */
this.sent = [];
this.onopen = () => {};
this.onmessage = (/** @type {{ data: ArrayBuffer }} */ _event) => {};
this.onclose = () => {};
this.onerror = () => {};
sockets.push(this);
}
send(/** @type {Uint8Array} */ bytes) {
this.sent.push(bytes);
}
close() {}
open() {
this.readyState = 1;
this.onopen();
}
/**
* @param {string} kind
* @param {object} value
*/
deliver(kind, value) {
const msg = create(ServerMessageSchema, { payload: { case: kind, value } });
this.onmessage({ data: toBinary(ServerMessageSchema, msg).buffer });
}
helloToken() {
const [first] = this.sent.map((b) => fromBinary(ClientMessageSchema, new Uint8Array(b)));
return first.payload.case === 'hello' ? first.payload.value.resumeToken : null;
}
}
const greeting = { sessionId: 's1', resumeToken: 'fresh-token', protocolVersion: 1, acceptedNickname: 'Minh' };
beforeEach(() => {
sockets = [];
sessionStorage.clear();
vi.stubGlobal('WebSocket', FakeWebSocket);
game.leave();
});
afterEach(() => {
disconnect();
vi.unstubAllGlobals();
});
describe('opening a socket for a game of its own', () => {
it('does not replay a token left by an earlier game or tab', () => {
sessionStorage.setItem(RESUME_KEY, 'left-behind');
connect({ freshSession: true });
sockets[0].open();
expect(sockets[0].helloToken()).toBe('');
});
it('replays the stored token when nothing asked for a fresh session', () => {
sessionStorage.setItem(RESUME_KEY, 'left-behind');
connect();
sockets[0].open();
expect(sockets[0].helloToken()).toBe('left-behind');
});
it('keeps the token this game was given, so a dropped socket still resumes', () => {
sessionStorage.setItem(RESUME_KEY, 'left-behind');
connect({ freshSession: true });
sockets[0].open();
sockets[0].deliver('welcome', greeting);
// A rematch asks again on the socket that already exists. That must
// not throw away the token the game in progress is resumable by.
connect({ freshSession: true });
expect(sessionStorage.getItem(RESUME_KEY)).toBe('fresh-token');
expect(sockets).toHaveLength(1);
});
});
describe('a resume the server refuses', () => {
it('clears the room the board still shows, and still says why', () => {
receive('roomState', {
roomCode: 'ABCD',
canStart: false,
maxPlayers: 4,
minPlayers: 2,
graceMs: 30_000,
players: [{ playerId: 'p1', name: 'Minh', isMe: true, connected: true }]
});
expect(game.state.phase).toBe('lobby');
sessionStorage.setItem(RESUME_KEY, 'old-token');
connect();
sockets[0].open();
sockets[0].deliver('welcome', greeting);
sockets[0].deliver('error', { code: 'game_already_over', message: '' });
expect(game.state.phase).toBe('idle');
expect(game.state.roomCode).toBe('');
expect(game.state.error).toBe('Ván đấu đã kết thúc.');
});
});