mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 12:28:54 +00:00
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.
853 lines
23 KiB
JavaScript
853 lines
23 KiB
JavaScript
// @vitest-environment jsdom
|
|
|
|
// The socket client owns three things the UI cannot see going wrong: the
|
|
// handshake, the reconnect schedule, and the clock offset the countdown is
|
|
// drawn from. All three are driven here through injected fakes, so the
|
|
// assertions are about the client rather than about a real network.
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { create, toBinary } from '@bufbuild/protobuf';
|
|
import { PROTOCOL_VERSION, cancelQuickMatch, quickMatch } from '../src/lib/ws/messages.js';
|
|
import {
|
|
ClientMessageSchema,
|
|
ServerMessageSchema
|
|
} from '../src/lib/proto/noitu/v1/game_pb.js';
|
|
import {
|
|
BACKOFF_MS,
|
|
LIVENESS_TIMEOUT_MS,
|
|
PING_INTERVAL_MS,
|
|
Status,
|
|
createClient,
|
|
forgetStoredSession,
|
|
hasStoredSession,
|
|
socketUrl
|
|
} from '../src/lib/ws/client.js';
|
|
|
|
class FakeSocket {
|
|
/** @param {string} url */
|
|
constructor(url) {
|
|
this.url = url;
|
|
this.readyState = 0;
|
|
this.binaryType = '';
|
|
/** @type {Uint8Array[]} */
|
|
this.sent = [];
|
|
this.closed = false;
|
|
this.onopen = () => {};
|
|
this.onmessage = () => {};
|
|
this.onclose = () => {};
|
|
this.onerror = () => {};
|
|
}
|
|
|
|
send(bytes) {
|
|
this.sent.push(bytes);
|
|
}
|
|
|
|
close() {
|
|
this.closed = true;
|
|
}
|
|
|
|
// --- test drivers ---
|
|
|
|
open() {
|
|
this.readyState = 1;
|
|
this.onopen();
|
|
}
|
|
|
|
/** @param {import('../src/lib/proto/noitu/v1/game_pb.js').ServerMessage} serverMessage */
|
|
deliver(serverMessage) {
|
|
this.onmessage({ data: toBinary(ServerMessageSchema, serverMessage).buffer });
|
|
}
|
|
|
|
deliverRaw(bytes) {
|
|
this.onmessage({ data: bytes.buffer ?? bytes });
|
|
}
|
|
|
|
drop() {
|
|
this.readyState = 3;
|
|
this.onclose();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A controllable clock. Timers are stored rather than run, so a test decides
|
|
* when time passes and can read the delay each one was scheduled with.
|
|
*/
|
|
function fakeTimers() {
|
|
let nextId = 1;
|
|
const pending = new Map();
|
|
return {
|
|
delays: /** @type {number[]} */ ([]),
|
|
schedule(fn, delay) {
|
|
this.delays.push(delay);
|
|
const id = nextId++;
|
|
pending.set(id, fn);
|
|
return id;
|
|
},
|
|
cancel(id) {
|
|
pending.delete(id);
|
|
},
|
|
/** Runs every timer queued so far, once. */
|
|
flush() {
|
|
const due = [...pending.entries()];
|
|
pending.clear();
|
|
for (const [, fn] of due) fn();
|
|
},
|
|
size: () => pending.size
|
|
};
|
|
}
|
|
|
|
function serverMsg(kind, value) {
|
|
return create(ServerMessageSchema, { payload: { case: kind, value } });
|
|
}
|
|
|
|
/**
|
|
* @param {object} [options]
|
|
*/
|
|
function setup(options = {}) {
|
|
/** @type {FakeSocket[]} */
|
|
const sockets = [];
|
|
/** @type {import('../src/lib/proto/noitu/v1/game_pb.js').ServerMessage[]} */
|
|
const received = [];
|
|
/** @type {string[]} */
|
|
const statuses = [];
|
|
/** @type {true[]} */
|
|
const refusals = [];
|
|
const timers = fakeTimers();
|
|
let clock = 1_000;
|
|
|
|
const client = createClient({
|
|
nickname: () => options.nickname ?? 'Minh',
|
|
onMessage: (m) => received.push(m),
|
|
onStatus: (s) => statuses.push(s),
|
|
onResumeRefused: () => refusals.push(true),
|
|
url: 'ws://localhost/ws',
|
|
socketFactory: (url) => {
|
|
const s = new FakeSocket(url);
|
|
sockets.push(s);
|
|
return s;
|
|
},
|
|
now: () => clock,
|
|
random: options.random ?? (() => 1),
|
|
schedule: (fn, delay) => timers.schedule(fn, delay),
|
|
cancel: (id) => timers.cancel(id)
|
|
});
|
|
|
|
return {
|
|
client,
|
|
sockets,
|
|
received,
|
|
statuses,
|
|
refusals,
|
|
timers,
|
|
// The ping interval uses the same timer double, so the reconnect
|
|
// schedule has to be read out from under it.
|
|
reconnectDelays: () => timers.delays.filter((d) => d !== PING_INTERVAL_MS),
|
|
setClock: (v) => {
|
|
clock = v;
|
|
},
|
|
last: () => sockets[sockets.length - 1]
|
|
};
|
|
}
|
|
|
|
/** @param {FakeSocket} socket */
|
|
async function sentMessages(socket) {
|
|
const { fromBinary } = await import('@bufbuild/protobuf');
|
|
return socket.sent.map((b) => fromBinary(ClientMessageSchema, new Uint8Array(b)));
|
|
}
|
|
|
|
beforeEach(() => {
|
|
sessionStorage.clear();
|
|
});
|
|
|
|
describe('socketUrl', () => {
|
|
it('follows the page scheme so a TLS page never opens a plaintext socket', () => {
|
|
expect(socketUrl({ protocol: 'https:', host: 'noitu.example' })).toBe(
|
|
'wss://noitu.example/ws'
|
|
);
|
|
expect(socketUrl({ protocol: 'http:', host: 'localhost:5173' })).toBe(
|
|
'ws://localhost:5173/ws'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('handshake', () => {
|
|
it('sends Hello with the protocol version as the first message', async () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
|
|
const [first] = await sentMessages(h.last());
|
|
expect(first.payload.case).toBe('hello');
|
|
expect(first.payload.value.protocolVersion).toBe(PROTOCOL_VERSION);
|
|
expect(first.payload.value.nickname).toBe('Minh');
|
|
expect(first.payload.value.resumeToken).toBe('');
|
|
});
|
|
|
|
it('replays the resume token from the previous Welcome on the next connect', async () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
h.last().deliver(
|
|
serverMsg('welcome', {
|
|
sessionId: 's1',
|
|
resumeToken: 'token-1',
|
|
protocolVersion: 1,
|
|
acceptedNickname: 'Minh'
|
|
})
|
|
);
|
|
|
|
h.last().drop();
|
|
h.timers.flush();
|
|
h.last().open();
|
|
|
|
const [hello] = await sentMessages(h.last());
|
|
expect(hello.payload.value.resumeToken).toBe('token-1');
|
|
});
|
|
|
|
it('forgets the token on request, so the next Hello starts a fresh session', async () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
h.last().deliver(
|
|
serverMsg('welcome', {
|
|
sessionId: 's1',
|
|
resumeToken: 'token-1',
|
|
protocolVersion: 1,
|
|
acceptedNickname: 'Minh'
|
|
})
|
|
);
|
|
h.client.forgetSession();
|
|
|
|
h.last().drop();
|
|
h.timers.flush();
|
|
h.last().open();
|
|
|
|
const [hello] = await sentMessages(h.last());
|
|
expect(hello.payload.value.resumeToken).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('reconnect', () => {
|
|
it('backs off along the documented schedule and then holds at the cap', () => {
|
|
const h = setup({ random: () => 1 });
|
|
h.client.connect();
|
|
|
|
// Each attempt fails before it opens, so the delay grows every time.
|
|
for (let i = 0; i < BACKOFF_MS.length + 2; i += 1) {
|
|
h.last().drop();
|
|
h.timers.flush();
|
|
}
|
|
|
|
const expected = [...BACKOFF_MS, BACKOFF_MS.at(-1), BACKOFF_MS.at(-1)];
|
|
expect(h.reconnectDelays()).toEqual(expected);
|
|
});
|
|
|
|
it('jitters below the base delay, never above it', () => {
|
|
const h = setup({ random: () => 0 });
|
|
h.client.connect();
|
|
h.last().drop();
|
|
|
|
expect(h.reconnectDelays()[0]).toBe(BACKOFF_MS[0] / 2);
|
|
});
|
|
|
|
it('does not reconnect after a deliberate close', () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
|
|
const socketsBefore = h.sockets.length;
|
|
h.client.close();
|
|
h.timers.flush();
|
|
|
|
expect(h.sockets.length).toBe(socketsBefore);
|
|
expect(h.client.status()).toBe(Status.CLOSED);
|
|
});
|
|
|
|
it('reports reconnecting while it waits, and open once it lands', () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
h.last().drop();
|
|
h.timers.flush();
|
|
h.last().open();
|
|
|
|
expect(h.statuses).toEqual([
|
|
Status.CONNECTING,
|
|
Status.OPEN,
|
|
Status.RECONNECTING,
|
|
Status.OPEN
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('clock offset', () => {
|
|
it('estimates the server clock from the midpoint of the round trip', () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
|
|
// Ping left at 1000, the reply is read at 1100, and the server stamped
|
|
// it 5050. The one-way delay is half of 100, so the server clock is
|
|
// 5050 + 50 - 1100 = 4000 ahead.
|
|
h.setClock(1100);
|
|
h.last().deliver(serverMsg('pong', { clientTimeMs: 1000n, serverTimeMs: 5050n }));
|
|
|
|
expect(h.client.clockOffset()).toBe(4000);
|
|
expect(h.client.serverNow()).toBe(5100);
|
|
});
|
|
|
|
it('starts at zero offset, so the countdown works before the first Pong', () => {
|
|
const h = setup();
|
|
expect(h.client.clockOffset()).toBe(0);
|
|
});
|
|
|
|
it('probes the clock on an interval while the socket is open', async () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
|
|
expect(h.timers.delays).toContain(PING_INTERVAL_MS);
|
|
h.timers.flush();
|
|
|
|
const sent = await sentMessages(h.last());
|
|
expect(sent.map((m) => m.payload.case)).toEqual(['hello', 'ping', 'ping']);
|
|
});
|
|
|
|
it('probes immediately on connect, so the opening turn is already corrected', async () => {
|
|
// GameStarted arrives about one round trip after Hello. Waiting out the
|
|
// first interval would draw that whole turn against the device clock.
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
|
|
const sent = await sentMessages(h.last());
|
|
expect(sent.map((m) => m.payload.case)).toEqual(['hello', 'ping']);
|
|
});
|
|
});
|
|
|
|
describe('frames', () => {
|
|
it('forwards a decoded server message to the caller', () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
h.last().deliver(serverMsg('roomState', { roomCode: 'ABCD' }));
|
|
|
|
expect(h.received).toHaveLength(1);
|
|
expect(h.received[0].payload.case).toBe('roomState');
|
|
});
|
|
|
|
it('drops an undecodable frame instead of ending the session', () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
|
|
// Field 1 declared as a varint but truncated: valid framing, invalid body.
|
|
expect(() => h.last().deliverRaw(new Uint8Array([0x08]))).not.toThrow();
|
|
expect(h.received).toHaveLength(0);
|
|
expect(h.client.status()).toBe(Status.OPEN);
|
|
});
|
|
|
|
it('refuses to send while the socket is not open', () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
const before = h.last().sent.length;
|
|
|
|
expect(h.client.send(create(ClientMessageSchema, {}))).toBe(false);
|
|
expect(h.last().sent.length).toBe(before);
|
|
});
|
|
});
|
|
|
|
describe('quick match', () => {
|
|
it('encodes a QuickMatch frame the transport will carry as-is', async () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
h.client.send(quickMatch());
|
|
|
|
const sent = await sentMessages(h.last());
|
|
expect(sent.at(-1).payload.case).toBe('quickMatch');
|
|
});
|
|
|
|
it('encodes a CancelQuickMatch frame the transport will carry as-is', async () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
h.client.send(cancelQuickMatch());
|
|
|
|
const sent = await sentMessages(h.last());
|
|
expect(sent.at(-1).payload.case).toBe('cancelQuickMatch');
|
|
});
|
|
});
|
|
|
|
describe('handshake ordering', () => {
|
|
it('sends Hello before announcing the connection is open', async () => {
|
|
// The server refuses everything until the handshake lands, so a listener
|
|
// that reacts to "open" by sending a message must not be able to get
|
|
// ahead of it.
|
|
/** @type {string[]} */
|
|
const order = [];
|
|
/** @type {FakeSocket} */
|
|
let socket;
|
|
const client = createClient({
|
|
nickname: () => 'Minh',
|
|
onMessage: () => {},
|
|
onStatus: (s) => order.push(`status:${s}`),
|
|
url: 'ws://localhost/ws',
|
|
socketFactory: () => {
|
|
socket = new FakeSocket('ws://localhost/ws');
|
|
const realSend = socket.send.bind(socket);
|
|
socket.send = (bytes) => {
|
|
order.push('send');
|
|
realSend(bytes);
|
|
};
|
|
return socket;
|
|
},
|
|
schedule: () => 1,
|
|
cancel: () => {}
|
|
});
|
|
|
|
client.connect();
|
|
socket.open();
|
|
|
|
expect(order.indexOf('send')).toBeLessThan(order.indexOf(`status:${Status.OPEN}`));
|
|
});
|
|
});
|
|
|
|
describe('a handshake the server refuses', () => {
|
|
/** @param {ReturnType<typeof setup>} h */
|
|
function refuseVersion(h) {
|
|
h.client.connect();
|
|
h.last().open();
|
|
h.last().deliver(serverMsg('error', { code: 'protocol_version_mismatch', message: '' }));
|
|
h.last().drop();
|
|
}
|
|
|
|
it('stops reconnecting, instead of retrying a handshake that cannot succeed', () => {
|
|
const h = setup();
|
|
const before = h.sockets.length;
|
|
|
|
refuseVersion(h);
|
|
h.timers.flush();
|
|
|
|
expect(h.sockets.length).toBe(before + 1);
|
|
expect(h.client.status()).toBe(Status.CLOSED);
|
|
});
|
|
|
|
it('still shows the player why, by forwarding the error', () => {
|
|
const h = setup();
|
|
refuseVersion(h);
|
|
|
|
expect(h.received.map((m) => m.payload.case)).toContain('error');
|
|
});
|
|
|
|
it('keeps retrying an error that a fresh connection could clear', () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
h.last().deliver(serverMsg('error', { code: 'room_not_found', message: '' }));
|
|
h.last().drop();
|
|
h.timers.flush();
|
|
|
|
expect(h.client.status()).not.toBe(Status.CLOSED);
|
|
});
|
|
});
|
|
|
|
describe('reconnecting on demand', () => {
|
|
// The backoff is right for a client nobody is watching and wrong for a
|
|
// player staring at a turn timer over a dead socket, so the UI can ask for
|
|
// an attempt now. What it must not do is open a second socket, or retry a
|
|
// handshake the server has already refused outright.
|
|
|
|
/** @param {ReturnType<typeof setup>} h */
|
|
function dropAfterOpen(h) {
|
|
h.client.connect();
|
|
h.last().open();
|
|
h.last().drop();
|
|
}
|
|
|
|
it('opens a socket immediately instead of waiting out the delay', () => {
|
|
const h = setup();
|
|
dropAfterOpen(h);
|
|
const waiting = h.sockets.length;
|
|
|
|
expect(h.client.reconnectNow()).toBe(true);
|
|
expect(h.sockets.length).toBe(waiting + 1);
|
|
});
|
|
|
|
it('cancels the pending attempt, so the retry does not become two sockets', () => {
|
|
const h = setup();
|
|
dropAfterOpen(h);
|
|
|
|
h.client.reconnectNow();
|
|
const afterRetry = h.sockets.length;
|
|
// Whatever was scheduled has been cancelled, so flushing the clock
|
|
// finds nothing left to fire.
|
|
h.timers.flush();
|
|
|
|
expect(h.sockets.length).toBe(afterRetry);
|
|
});
|
|
|
|
it('starts the schedule over, because the player asking is news about the network', () => {
|
|
const h = setup();
|
|
dropAfterOpen(h);
|
|
h.timers.flush();
|
|
h.last().drop();
|
|
|
|
// Two failures in, the next wait would be the second step of the ramp.
|
|
h.client.reconnectNow();
|
|
h.last().drop();
|
|
|
|
expect(h.reconnectDelays().at(-1)).toBe(BACKOFF_MS[0]);
|
|
});
|
|
|
|
it('refuses while a socket already exists, rather than opening a rival', () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
|
|
expect(h.client.reconnectNow()).toBe(false);
|
|
expect(h.sockets.length).toBe(1);
|
|
});
|
|
|
|
it('refuses after a handshake the server rejected outright', () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
h.last().deliver(serverMsg('error', { code: 'protocol_version_mismatch', message: '' }));
|
|
h.last().drop();
|
|
const before = h.sockets.length;
|
|
|
|
expect(h.client.reconnectNow()).toBe(false);
|
|
expect(h.sockets.length).toBe(before);
|
|
});
|
|
|
|
it('refuses after a deliberate close, which is not something to come back from', () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
h.client.close();
|
|
|
|
expect(h.client.reconnectNow()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('backoff and the handshake', () => {
|
|
it('resets on Welcome, not merely on the socket opening', () => {
|
|
// A server that accepts the connection and then rejects the handshake
|
|
// would otherwise look like a success, and every retry would start again
|
|
// from the shortest delay.
|
|
const h = setup({ random: () => 1 });
|
|
h.client.connect();
|
|
|
|
h.last().drop();
|
|
h.timers.flush();
|
|
h.last().open(); // opens, but no Welcome arrives
|
|
h.last().drop();
|
|
h.timers.flush();
|
|
|
|
expect(h.reconnectDelays()).toEqual([500, 1000]);
|
|
});
|
|
|
|
it('resets once the server has actually greeted the client', () => {
|
|
const h = setup({ random: () => 1 });
|
|
h.client.connect();
|
|
|
|
h.last().drop();
|
|
h.timers.flush();
|
|
h.last().open();
|
|
h.last().deliver(
|
|
serverMsg('welcome', {
|
|
sessionId: 's1',
|
|
resumeToken: 't1',
|
|
protocolVersion: 1,
|
|
acceptedNickname: 'Minh'
|
|
})
|
|
);
|
|
h.last().drop();
|
|
h.timers.flush();
|
|
|
|
expect(h.reconnectDelays().at(-1)).toBe(500);
|
|
});
|
|
});
|
|
|
|
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 {ReturnType<typeof setup>} h
|
|
* @param {number} ticks
|
|
* @param {number} start
|
|
*/
|
|
function tick(h, ticks, start) {
|
|
let clock = start;
|
|
for (let i = 0; i < ticks; i += 1) {
|
|
clock += PING_INTERVAL_MS;
|
|
h.setClock(clock);
|
|
h.timers.flush();
|
|
}
|
|
return clock;
|
|
}
|
|
|
|
it('is closed by the client once frames stop arriving', () => {
|
|
// A connection that fails without closing leaves onclose unfired, so the
|
|
// page would keep showing a live connection and a running countdown over
|
|
// a socket nothing can reach.
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
const socket = h.last();
|
|
|
|
tick(h, LIVENESS_TIMEOUT_MS / PING_INTERVAL_MS + 1, 1000);
|
|
|
|
expect(socket.closed).toBe(true);
|
|
});
|
|
|
|
it('leaves a socket alone while frames are still arriving', () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
const socket = h.last();
|
|
|
|
let clock = 1000;
|
|
for (let i = 0; i < 10; i += 1) {
|
|
clock += PING_INTERVAL_MS;
|
|
h.setClock(clock);
|
|
socket.deliver(serverMsg('pong', { clientTimeMs: 1n, serverTimeMs: 2n }));
|
|
h.timers.flush();
|
|
}
|
|
|
|
expect(socket.closed).toBe(false);
|
|
});
|
|
|
|
it('does not punish a tab whose timers were throttled', () => {
|
|
// A backgrounded tab fires its timers minutes late through no fault of
|
|
// the connection. Judging silence on a tick that was itself late would
|
|
// close a healthy socket every time the player switched away.
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
const socket = h.last();
|
|
|
|
h.setClock(1000 + 10 * 60_000);
|
|
h.timers.flush();
|
|
|
|
expect(socket.closed).toBe(false);
|
|
});
|
|
});
|
|
|
|
|
|
const welcome = () =>
|
|
serverMsg('welcome', {
|
|
sessionId: 's1',
|
|
resumeToken: 'token-1',
|
|
protocolVersion: 1,
|
|
acceptedNickname: 'Minh'
|
|
});
|
|
|
|
describe('a resume the server refuses', () => {
|
|
/** Opens a socket whose Hello carries a stored token, and greets it. */
|
|
function resumeAttempt() {
|
|
sessionStorage.setItem('noitu.resumeToken', 'old-token');
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
h.last().deliver(welcome());
|
|
return h;
|
|
}
|
|
|
|
it('reports a refusal that is the first frame after the Welcome', () => {
|
|
const h = resumeAttempt();
|
|
|
|
h.last().deliver(serverMsg('error', { code: 'session_not_resumable', message: '' }));
|
|
|
|
expect(h.refusals).toHaveLength(1);
|
|
});
|
|
|
|
it('reports a game_already_over refusal the same way', () => {
|
|
const h = resumeAttempt();
|
|
|
|
h.last().deliver(serverMsg('error', { code: 'game_already_over', message: '' }));
|
|
|
|
expect(h.refusals).toHaveLength(1);
|
|
});
|
|
|
|
it('spends the token, so the next reconnect does not ask for the same refusal', async () => {
|
|
const h = resumeAttempt();
|
|
h.last().deliver(serverMsg('error', { code: 'session_not_resumable', message: '' }));
|
|
|
|
h.last().drop();
|
|
h.timers.flush();
|
|
h.last().open();
|
|
|
|
const [hello] = await sentMessages(h.last());
|
|
expect(hello.payload.value.resumeToken).toBe('');
|
|
});
|
|
|
|
it('still forwards the error, after reporting the refusal', () => {
|
|
const h = resumeAttempt();
|
|
h.last().deliver(serverMsg('error', { code: 'session_not_resumable', message: '' }));
|
|
|
|
expect(h.received.map((m) => m.payload.case)).toEqual(['welcome', 'error']);
|
|
});
|
|
|
|
it('looks past a Pong that lands between the Welcome and the refusal', () => {
|
|
const h = resumeAttempt();
|
|
h.last().deliver(serverMsg('pong', { clientTimeMs: 1000n, serverTimeMs: 1000n }));
|
|
|
|
h.last().deliver(serverMsg('error', { code: 'session_not_resumable', message: '' }));
|
|
|
|
expect(h.refusals).toHaveLength(1);
|
|
});
|
|
|
|
it('does not report an error that follows the restored room', () => {
|
|
const h = resumeAttempt();
|
|
h.last().deliver(serverMsg('roomState', { roomCode: 'ABCD', players: [] }));
|
|
|
|
h.last().deliver(serverMsg('error', { code: 'not_your_turn', message: '' }));
|
|
|
|
expect(h.refusals).toEqual([]);
|
|
});
|
|
|
|
it('does not report an error on a Hello that carried no token', () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
h.last().deliver(welcome());
|
|
|
|
h.last().deliver(serverMsg('error', { code: 'room_not_found', message: '' }));
|
|
|
|
expect(h.refusals).toEqual([]);
|
|
});
|
|
|
|
it('watches each connection separately: a fresh Hello after a refusal is not a resume', () => {
|
|
const h = resumeAttempt();
|
|
h.last().deliver(serverMsg('error', { code: 'session_not_resumable', message: '' }));
|
|
h.last().drop();
|
|
h.timers.flush();
|
|
h.last().open();
|
|
h.last().deliver(welcome());
|
|
|
|
h.last().deliver(serverMsg('error', { code: 'room_not_found', message: '' }));
|
|
|
|
expect(h.refusals).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('clock offset filtering', () => {
|
|
it('keeps the sample with the shortest round trip, so a slow pong does not move it', () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
|
|
// Quick exchange: sent 1000, read at 1040, server stamped 5020. The
|
|
// server clock is 5020 + 20 - 1040 = 4000 ahead.
|
|
h.setClock(1040);
|
|
h.last().deliver(serverMsg('pong', { clientTimeMs: 1000n, serverTimeMs: 5020n }));
|
|
expect(h.client.clockOffset()).toBe(4000);
|
|
|
|
// A congested one, whose midpoint estimate is 350ms out.
|
|
h.setClock(3000);
|
|
h.last().deliver(serverMsg('pong', { clientTimeMs: 2000n, serverTimeMs: 6850n }));
|
|
|
|
expect(h.client.clockOffset()).toBe(4000);
|
|
});
|
|
|
|
it('follows a faster sample when one comes', () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
h.setClock(1200);
|
|
h.last().deliver(serverMsg('pong', { clientTimeMs: 1000n, serverTimeMs: 5150n }));
|
|
|
|
h.setClock(2020);
|
|
h.last().deliver(serverMsg('pong', { clientTimeMs: 2000n, serverTimeMs: 6010n }));
|
|
|
|
// rtt 20: 6010 + 10 - 2020 = 4000
|
|
expect(h.client.clockOffset()).toBe(4000);
|
|
});
|
|
|
|
it('forgets a fast sample once enough newer ones have pushed it out', () => {
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
h.setClock(1010);
|
|
h.last().deliver(serverMsg('pong', { clientTimeMs: 1000n, serverTimeMs: 5005n }));
|
|
expect(h.client.clockOffset()).toBe(4000);
|
|
|
|
// The device clock drifts against the server's, and every later probe
|
|
// says so. Five of them replace the old best.
|
|
for (let i = 0; i < 5; i++) {
|
|
const sent = 2000 + i * 1000;
|
|
h.setClock(sent + 100);
|
|
h.last().deliver(serverMsg('pong', { clientTimeMs: BigInt(sent), serverTimeMs: BigInt(sent + 4150) }));
|
|
}
|
|
|
|
expect(h.client.clockOffset()).toBe(4000 + 100);
|
|
});
|
|
});
|
|
|
|
describe('storage that refuses', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('reads as no session when the read throws', async () => {
|
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
|
throw new Error('denied');
|
|
});
|
|
const h = setup();
|
|
|
|
expect(hasStoredSession()).toBe(false);
|
|
h.client.connect();
|
|
h.last().open();
|
|
|
|
const [hello] = await sentMessages(h.last());
|
|
expect(hello.payload.value.resumeToken).toBe('');
|
|
});
|
|
|
|
it('keeps the session going when saving the token throws', () => {
|
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
throw new Error('quota');
|
|
});
|
|
const h = setup();
|
|
h.client.connect();
|
|
h.last().open();
|
|
|
|
expect(() => h.last().deliver(welcome())).not.toThrow();
|
|
expect(h.received.map((m) => m.payload.case)).toEqual(['welcome']);
|
|
});
|
|
|
|
it('forgets quietly when removing the token throws', () => {
|
|
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
|
|
throw new Error('denied');
|
|
});
|
|
const h = setup();
|
|
|
|
expect(() => h.client.forgetSession()).not.toThrow();
|
|
expect(() => forgetStoredSession()).not.toThrow();
|
|
});
|
|
|
|
it('works with no storage at all, as a private-mode browser can leave it', async () => {
|
|
const original = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage');
|
|
Object.defineProperty(globalThis, 'sessionStorage', {
|
|
configurable: true,
|
|
get() {
|
|
throw new Error('SecurityError');
|
|
}
|
|
});
|
|
try {
|
|
const h = setup();
|
|
expect(hasStoredSession()).toBe(false);
|
|
h.client.connect();
|
|
h.last().open();
|
|
expect(() => h.last().deliver(welcome())).not.toThrow();
|
|
expect(() => h.client.forgetSession()).not.toThrow();
|
|
|
|
const [hello] = await sentMessages(h.last());
|
|
expect(hello.payload.value.resumeToken).toBe('');
|
|
} finally {
|
|
if (original) Object.defineProperty(globalThis, 'sessionStorage', original);
|
|
}
|
|
});
|
|
});
|