CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
165 lines
6.8 KiB
JavaScript
165 lines
6.8 KiB
JavaScript
// Real BroadcastChannel election followed by real client/server session handshakes.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { once } from 'node:events';
|
|
import { BroadcastChannel } from 'node:worker_threads';
|
|
import WebSocket from 'ws';
|
|
import { startServer } from '../server/index.js';
|
|
import { CLAIM_QUERY_MS, createIdentity, Net } from '../public/js/net.js';
|
|
|
|
function storage(seed = {}) {
|
|
const values = new Map(Object.entries(seed));
|
|
return {
|
|
getItem: (key) => values.get(key) ?? null,
|
|
setItem: (key, value) => values.set(key, String(value)),
|
|
removeItem: (key) => values.delete(key),
|
|
};
|
|
}
|
|
|
|
function nextEvent(net, type) {
|
|
return new Promise((resolve, reject) => {
|
|
const off = net.on(type, (value) => { clearTimeout(timer); off(); resolve(value); });
|
|
const timer = setTimeout(() => { off(); reject(new Error(`Timed out waiting for ${type}`)); }, 2000);
|
|
});
|
|
}
|
|
|
|
async function harness(t) {
|
|
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
|
const clients = [], channels = [], replaced = [];
|
|
const local = storage();
|
|
const channelName = `sp.identity.test.${process.pid}.${srv.port}`;
|
|
t.after(async () => {
|
|
clients.forEach((net) => net.close());
|
|
channels.forEach((channel) => channel.close());
|
|
await srv.close();
|
|
});
|
|
return {
|
|
local, replaced,
|
|
tab(tabId, session = storage(), setTimeout) {
|
|
const channel = new BroadcastChannel(channelName);
|
|
channels.push(channel);
|
|
const id = createIdentity({ local, session, channel, tabId, setTimeout });
|
|
return { id, session, channel };
|
|
},
|
|
async connect(name, tab) {
|
|
const net = new Net({ url: `ws://127.0.0.1:${srv.port}/ws`, WebSocket, getToken: () => tab?.id.getToken() ?? null });
|
|
clients.push(net);
|
|
net.on('replaced', (error) => replaced.push({ name, code: error.code }));
|
|
net.on('welcome', (welcome) => tab?.id.saveToken(welcome.token));
|
|
const welcome = nextEvent(net, 'welcome');
|
|
net.setName(name);
|
|
return { net, welcome: await welcome };
|
|
},
|
|
};
|
|
}
|
|
|
|
async function closeClient(net) {
|
|
const closed = once(net.ws, 'close');
|
|
net.close();
|
|
await closed;
|
|
}
|
|
|
|
async function assertOnline(h, clients) {
|
|
// A round trip from each socket also lets a queued replacement close reach the client.
|
|
const replies = await Promise.allSettled(clients.map(({ net }) => net.request('ping', { c: Date.now() })));
|
|
assert.deepEqual(h.replaced, [], 'no client is replaced by another tab');
|
|
for (const [index, client] of clients.entries()) {
|
|
assert.equal(client.net.status, 'online');
|
|
assert.equal(client.net.lastError, null);
|
|
assert.equal(replies[index].status, 'fulfilled');
|
|
assert.equal(replies[index].value.t, 'pong');
|
|
}
|
|
}
|
|
|
|
for (const [firstId, secondId] of [['a-tab', 'b-tab'], ['b-tab', 'a-tab']]) {
|
|
test(`staggered tabs ${firstId} then ${secondId} keep separate server sessions`, { timeout: 5000 }, async (t) => {
|
|
const h = await harness(t);
|
|
const seed = await h.connect('Seed');
|
|
await closeClient(seed.net);
|
|
h.local.setItem('sp.tokens', JSON.stringify([seed.welcome.token]));
|
|
|
|
const deadlines = [];
|
|
const holdDeadline = (fn, ms) => { assert.equal(ms, CLAIM_QUERY_MS); deadlines.push(fn); };
|
|
const first = h.tab(firstId, storage(), holdDeadline);
|
|
const firstInit = first.id.init();
|
|
// The first who has already been sent when the second channel is created.
|
|
const second = h.tab(secondId, storage(), holdDeadline);
|
|
const delivered = new Promise((resolve) => {
|
|
second.channel.addEventListener('message', (event) => {
|
|
if (event.data.type === 'test.barrier') resolve();
|
|
});
|
|
});
|
|
first.channel.addEventListener('message', (event) => {
|
|
if (event.data.type === 'who') first.channel.postMessage({ type: 'test.barrier' });
|
|
});
|
|
const secondInit = second.id.init();
|
|
// FIFO delivery puts this barrier after any real reply to the later who. Both default
|
|
// 150 ms deadlines remain pending, without relying on sleeps or a fake channel hub.
|
|
await delivered;
|
|
assert.equal(deadlines.length, 2);
|
|
deadlines[0]();
|
|
await firstInit;
|
|
deadlines[1]();
|
|
await secondInit;
|
|
|
|
const a = await h.connect('First', first);
|
|
const b = await h.connect('Second', second);
|
|
await assertOnline(h, [a, b]);
|
|
assert.notEqual(a.welcome.token, b.welcome.token);
|
|
assert.notEqual(a.welcome.playerId, b.welcome.playerId);
|
|
const resumed = firstId < secondId ? a : b;
|
|
assert.equal(resumed.welcome.token, seed.welcome.token);
|
|
assert.equal(resumed.welcome.playerId, seed.welcome.playerId);
|
|
});
|
|
}
|
|
|
|
test('live token ownership survives a duplicated tab, reload and reconnect', { timeout: 5000 }, async (t) => {
|
|
const h = await harness(t);
|
|
const owner = h.tab('z-owner');
|
|
await owner.id.init();
|
|
const a = await h.connect('Owner', owner);
|
|
|
|
// A duplicated tab copies sessionStorage, but its smaller tab ID cannot evict a live owner.
|
|
const duplicate = h.tab('a-duplicate', storage({ 'sp.token': a.welcome.token }));
|
|
await duplicate.id.init();
|
|
const b = await h.connect('Duplicate', duplicate);
|
|
assert.notEqual(a.welcome.token, b.welcome.token);
|
|
assert.notEqual(a.welcome.playerId, b.welcome.playerId);
|
|
await assertOnline(h, [a, b]);
|
|
|
|
await closeClient(a.net);
|
|
owner.channel.close();
|
|
const reloaded = h.tab('z-reloaded', owner.session);
|
|
await reloaded.id.init();
|
|
const a2 = await h.connect('Owner', reloaded);
|
|
assert.equal(a2.welcome.token, a.welcome.token);
|
|
assert.equal(a2.welcome.playerId, a.welcome.playerId);
|
|
const reconnected = nextEvent(a2.net, 'welcome');
|
|
a2.net.reconnectNow();
|
|
const welcome = await reconnected;
|
|
assert.equal(welcome.token, a.welcome.token);
|
|
assert.equal(welcome.playerId, a.welcome.playerId);
|
|
await assertOnline(h, [a2, b]);
|
|
});
|
|
|
|
|
|
test('explicit account tokens are tentative until welcome and do not overwrite the last established local identity', async () => {
|
|
const old = '1'.repeat(32), selected = '2'.repeat(32), attached = '3'.repeat(32);
|
|
const local = storage(), session = storage({ 'sp.token': old });
|
|
const id = createIdentity({ local, session, channel: null });
|
|
await id.init();
|
|
id.saveToken(old);
|
|
assert.equal(id.stageToken(selected), true);
|
|
assert.equal(id.getToken(), selected);
|
|
assert.deepEqual(id.getTokenClaim(), { noReplace: false });
|
|
assert.equal(session.getItem('sp.token'), old);
|
|
assert.deepEqual(JSON.parse(local.getItem('sp.tokens')), [old]);
|
|
assert.equal(id.stageToken(''), false);
|
|
assert.equal(id.getToken(), selected);
|
|
id.saveToken(attached);
|
|
assert.equal(session.getItem('sp.token'), attached);
|
|
assert.equal(id.getToken(), attached);
|
|
assert.ok(Number.isFinite(id.getTokenClaim().claimAt));
|
|
assert.equal(JSON.parse(local.getItem('sp.tokens')).includes(selected), false, 'only the rotated welcome token is remembered');
|
|
});
|