148 lines
7.5 KiB
JavaScript
148 lines
7.5 KiB
JavaScript
// Control delivery across two connections while using the real client and server hello handlers.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createIdentity, Net } from '../public/js/net.js';
|
|
import { Network, SessionRegistry } from '../server/net.js';
|
|
import { validateC2S } from '../shared/protocol.js';
|
|
|
|
function storage() {
|
|
const m = new Map();
|
|
return { getItem: (k) => m.get(k) ?? null, setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k) };
|
|
}
|
|
function hub() {
|
|
const members = new Set();
|
|
return { create() {
|
|
const channel = {
|
|
listeners: [],
|
|
postMessage(data) {
|
|
for (const peer of members) if (peer !== channel) queueMicrotask(() => {
|
|
if (members.has(peer)) for (const fn of peer.listeners) fn({ data: structuredClone(data) });
|
|
});
|
|
},
|
|
addEventListener(type, fn) { if (type === 'message') this.listeners.push(fn); },
|
|
close() { members.delete(channel); },
|
|
};
|
|
members.add(channel); return channel;
|
|
} };
|
|
}
|
|
async function harness(t) {
|
|
const local = storage(), ownerSession = storage(), guestSession = storage(), channels = hub();
|
|
const registry = new SessionRegistry(), server = new Network({ registry, handler: { onMessage() {} } });
|
|
const ownerSeat = registry.create('Owner'), guestSeat = registry.create('Guest'), clients = [];
|
|
t.after(() => { for (const net of clients) net.close(); clearInterval(server.heartbeatTimer); clearInterval(server.sweepTimer); });
|
|
const ownerChannel = channels.create();
|
|
const owner = createIdentity({ local, session: ownerSession, channel: ownerChannel, tabId: 'z-owner', queryMs: 1, now: () => 100 });
|
|
await owner.init(); owner.saveToken(ownerSeat.token); owner.rememberMatch({ name: 'Owner', code: 'ROOM1' });
|
|
const guest = createIdentity({ local, session: guestSession, channel: channels.create(), tabId: 'a-guest', queryMs: 1, now: () => 200 });
|
|
await guest.init(); guest.saveToken(guestSeat.token);
|
|
const client = (identity) => {
|
|
const sockets = [], welcomes = [], frames = [];
|
|
class Socket {
|
|
constructor() { this.readyState = 0; this.sent = []; sockets.push(this); }
|
|
send(raw) { this.sent.push(JSON.parse(raw)); }
|
|
// Client close need not reach the server before an already sent hello on this connection.
|
|
close(code) { this.readyState = 3; this.onclose?.({ code }); }
|
|
open() { this.readyState = 1; this.onopen?.(); }
|
|
}
|
|
const net = new Net({ WebSocket: Socket, getToken: () => identity.getToken(), getTokenClaim: () => identity.getTokenClaim() });
|
|
clients.push(net);
|
|
net.on('welcome', (m) => { welcomes.push(m); identity.saveToken(m.token); });
|
|
net.setName('Owner');
|
|
const ws = sockets[0]; ws.open();
|
|
const conn = { session: null, ip: '?', key: null, ws: {
|
|
readyState: 1, bufferedAmount: 0,
|
|
send(raw) { frames.push(JSON.parse(raw)); ws.onmessage?.({ data: raw }); },
|
|
close(code) { this.readyState = 3; ws.close(code); },
|
|
} };
|
|
return { net, ws, conn, welcomes, frames, sockets,
|
|
hello: () => ws.sent.find((m) => m.t === 'hello'),
|
|
deliver() {
|
|
const msg = ws.sent.find((m) => m.t === 'hello');
|
|
assert.equal(validateC2S(msg), null);
|
|
server.onHelloMsg(conn, msg, Date.now());
|
|
},
|
|
};
|
|
};
|
|
return { local, ownerSession, guestSession, channels, ownerSeat, guestSeat, ownerChannel, owner, guest, client,
|
|
async reload() {
|
|
const id = createIdentity({ local, session: ownerSession, channel: channels.create(), tabId: 'zz-reloaded', queryMs: 1, now: () => 300 });
|
|
assert.equal(await id.init(), ownerSeat.token); return id;
|
|
},
|
|
};
|
|
}
|
|
|
|
test('a recovery hello already in flight cannot displace the original window after its reload welcome', async (t) => {
|
|
const h = await harness(t);
|
|
h.ownerChannel.close();
|
|
assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true);
|
|
const pending = h.client(h.guest);
|
|
assert.equal(pending.hello().noReplace, true);
|
|
const owner = h.client(await h.reload()); owner.deliver();
|
|
assert.equal(h.guest.getToken(), h.guestSeat.token);
|
|
pending.deliver();
|
|
assert.equal(pending.frames.at(-1).code, 'SESSION_IN_USE');
|
|
assert.equal(owner.net.status, 'online'); assert.equal(owner.net.lastError, null);
|
|
assert.equal(h.ownerSeat.ws, owner.conn.ws);
|
|
assert.equal(pending.welcomes.length, 0);
|
|
assert.equal(h.guestSession.getItem('sp.token'), h.guestSeat.token);
|
|
});
|
|
|
|
test('a later welcomed holder also yields; its buffered reconnect cannot override the earlier welcome', async (t) => {
|
|
const h = await harness(t);
|
|
h.ownerChannel.close();
|
|
assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true);
|
|
const recovered = h.client(h.guest); recovered.deliver();
|
|
assert.equal(recovered.net.status, 'online');
|
|
const delayedReconnect = h.client(h.guest);
|
|
assert.equal(delayedReconnect.hello().claimAt, 200);
|
|
assert.equal(delayedReconnect.hello().noReplace, undefined);
|
|
const owner = h.client(await h.reload()); owner.deliver();
|
|
assert.equal(h.guest.getToken(), null);
|
|
assert.equal(h.guestSession.getItem('sp.token'), null);
|
|
delayedReconnect.deliver();
|
|
assert.equal(delayedReconnect.frames.at(-1).code, 'SESSION_IN_USE');
|
|
assert.equal(owner.net.status, 'online'); assert.equal(owner.net.lastError, null);
|
|
assert.equal(h.ownerSeat.ws, owner.conn.ws);
|
|
assert.equal(h.ownerSeat.claimAt, 100);
|
|
});
|
|
|
|
test('shared recovery can bind an offline seat and a same-stamp reconnect can replace its own socket', async (t) => {
|
|
const h = await harness(t);
|
|
h.ownerChannel.close();
|
|
assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true);
|
|
const first = h.client(h.guest); first.deliver();
|
|
assert.equal(first.welcomes[0].playerId, h.ownerSeat.playerId);
|
|
const reconnect = h.client(h.guest); reconnect.deliver();
|
|
assert.equal(reconnect.welcomes[0].playerId, h.ownerSeat.playerId);
|
|
const again = h.client(h.guest); again.deliver();
|
|
assert.equal(again.net.status, 'online');
|
|
assert.equal(again.welcomes[0].playerId, h.ownerSeat.playerId);
|
|
assert.equal(h.ownerSeat.claimAt, 200);
|
|
});
|
|
|
|
test('a stale welcome is discarded before online status, queued actions or welcome listeners', async (t) => {
|
|
const h = await harness(t);
|
|
h.ownerChannel.close();
|
|
assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true);
|
|
const pending = h.client(h.guest), published = [], statuses = [];
|
|
pending.net.on('*', (m) => published.push(m));
|
|
pending.net.on('status', (s) => statuses.push(s.status));
|
|
const action = pending.net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' });
|
|
const rejected = assert.rejects(action, (e) => e.code === 'CLOSED');
|
|
h.guest.rejectToken();
|
|
pending.deliver();
|
|
assert.equal(pending.welcomes.length, 0);
|
|
assert.equal(published.length, 0);
|
|
assert.ok(!statuses.includes('online'));
|
|
assert.ok(!pending.ws.sent.some((m) => m.t === 'room.create'));
|
|
assert.equal(pending.sockets.length, 2, 'retry uses a new connection, never a repeat hello on the old session');
|
|
assert.equal(h.guestSession.getItem('sp.token'), h.guestSeat.token);
|
|
pending.net.close(); await rejected;
|
|
});
|
|
|
|
test('hello ownership fields reject malformed inputs but remain optional', () => {
|
|
const hello = { t: 'hello', name: 'Owner', token: 'seat' };
|
|
for (const fields of [{}, { noReplace: true }, { noReplace: false, claimAt: 100 }]) assert.equal(validateC2S({ ...hello, ...fields }), null);
|
|
for (const fields of [{ noReplace: 'yes' }, { claimAt: -1 }, { claimAt: Infinity }, { claimAt: '100' }]) assert.equal(typeof validateC2S({ ...hello, ...fields }), 'string');
|
|
});
|