| 1 | // client-id.mjs: a tab keeps its presence id across a reload, two tabs |
| 2 | // do not share one, and a duplicated tab (which copies its original's |
| 3 | // sessionStorage) gives the copied id up. |
| 4 | import test from 'node:test' |
| 5 | import assert from 'node:assert/strict' |
| 6 | import { tabClientId, claimClientId, clientIdKey } from './client-id.mjs' |
| 7 | import { createPresence } from './presence.mjs' |
| 8 | |
| 9 | function memoryStorage () { |
| 10 | const m = new Map() |
| 11 | return { |
| 12 | getItem: k => (m.has(k) ? m.get(k) : null), |
| 13 | setItem: (k, v) => { m.set(k, String(v)) }, |
| 14 | copy () { const c = memoryStorage(); for (const [k, v] of m) c.setItem(k, v); return c } |
| 15 | } |
| 16 | } |
| 17 | |
| 18 | let n = 0 |
| 19 | const make = () => (++n).toString(16).padStart(16, '0') |
| 20 | |
| 21 | test('a reload reuses the id this tab stored for the deck', () => { |
| 22 | const tab = memoryStorage() |
| 23 | const first = tabClientId({ storage: tab, deck: 'd1', make }) |
| 24 | const reloaded = tabClientId({ storage: tab, deck: 'd1', make }) |
| 25 | assert.equal(reloaded, first) |
| 26 | assert.equal(tab.getItem(clientIdKey('d1')), first) |
| 27 | }) |
| 28 | |
| 29 | test('two tabs, and two decks in one tab, get different ids', () => { |
| 30 | const a = tabClientId({ storage: memoryStorage(), deck: 'd1', make }) |
| 31 | const b = tabClientId({ storage: memoryStorage(), deck: 'd1', make }) |
| 32 | assert.notEqual(a, b) |
| 33 | const tab = memoryStorage() |
| 34 | assert.notEqual(tabClientId({ storage: tab, deck: 'd1', make }), tabClientId({ storage: tab, deck: 'd2', make })) |
| 35 | }) |
| 36 | |
| 37 | test('a stored value that is not an id, or storage that throws, gives a fresh id', () => { |
| 38 | const tab = memoryStorage() |
| 39 | tab.setItem(clientIdKey('d1'), '<script>') |
| 40 | const id = tabClientId({ storage: tab, deck: 'd1', make }) |
| 41 | assert.match(id, /^[0-9a-f]{16}$/) |
| 42 | const throwing = { getItem () { throw new Error('blocked') }, setItem () { throw new Error('blocked') } } |
| 43 | assert.match(tabClientId({ storage: throwing, deck: 'd1', make }), /^[0-9a-f]{16}$/) |
| 44 | assert.match(tabClientId({ storage: null, deck: 'd1', make }), /^[0-9a-f]{16}$/) |
| 45 | }) |
| 46 | |
| 47 | // A pair of in-memory BroadcastChannels: a message posted on one is |
| 48 | // delivered to the other, never to itself (as the real one does). |
| 49 | function channelPair () { |
| 50 | const a = { onmessage: null, postMessage: m => queueMicrotask(() => b.onmessage && b.onmessage({ data: m })), close () {} } |
| 51 | const b = { onmessage: null, postMessage: m => queueMicrotask(() => a.onmessage && a.onmessage({ data: m })), close () {} } |
| 52 | return [a, b] |
| 53 | } |
| 54 | |
| 55 | test('a duplicated tab gives up the copied id; the original keeps it', async () => { |
| 56 | const original = memoryStorage() |
| 57 | const id = tabClientId({ storage: original, deck: 'd1', make }) |
| 58 | const duplicate = original.copy() |
| 59 | let dupId = tabClientId({ storage: duplicate, deck: 'd1', make }) |
| 60 | assert.equal(dupId, id, 'the duplicate starts with the copy — the case being guarded') |
| 61 | |
| 62 | const [chA, chB] = channelPair() |
| 63 | let originalTaken = false |
| 64 | claimClientId({ channel: chA, getId: () => id, onTaken: () => { originalTaken = true } }) |
| 65 | const guard = claimClientId({ |
| 66 | channel: chB, |
| 67 | getId: () => dupId, |
| 68 | onTaken: () => { |
| 69 | duplicate.setItem(clientIdKey('d1'), '') |
| 70 | dupId = tabClientId({ storage: duplicate, deck: 'd1', make }) |
| 71 | } |
| 72 | }) |
| 73 | guard.claim() |
| 74 | await new Promise(r => setTimeout(r, 0)) |
| 75 | assert.notEqual(dupId, id) |
| 76 | assert.equal(originalTaken, false) |
| 77 | assert.equal(original.getItem(clientIdKey('d1')), id) |
| 78 | assert.equal(duplicate.getItem(clientIdKey('d1')), dupId) |
| 79 | }) |
| 80 | |
| 81 | test('a reload finds no rival and keeps its id', async () => { |
| 82 | const tab = memoryStorage() |
| 83 | const id = tabClientId({ storage: tab, deck: 'd1', make }) |
| 84 | const [ch] = channelPair() // nobody on the other end: the old page is gone |
| 85 | let taken = false |
| 86 | claimClientId({ channel: ch, getId: () => id, onTaken: () => { taken = true } }).claim() |
| 87 | await new Promise(r => setTimeout(r, 0)) |
| 88 | assert.equal(taken, false) |
| 89 | assert.equal(tabClientId({ storage: tab, deck: 'd1', make }), id) |
| 90 | }) |
| 91 | |
| 92 | test('presence switches id: beats carry the new one and the old one is somebody else', () => { |
| 93 | const posts = [] |
| 94 | const p = createPresence({ |
| 95 | clientId: 'aaaaaaaaaaaaaaaa', |
| 96 | post: b => { posts.push(b); return Promise.resolve() }, |
| 97 | getState: () => ({ slide: 's', block: '' }), |
| 98 | setTimeoutFn: () => 1, |
| 99 | clearTimeoutFn: () => {} |
| 100 | }) |
| 101 | p.start() |
| 102 | p.setClientId('bbbbbbbbbbbbbbbb') |
| 103 | assert.equal(p.clientId, 'bbbbbbbbbbbbbbbb') |
| 104 | assert.equal(posts.at(-1).client, 'bbbbbbbbbbbbbbbb') |
| 105 | const d = p.applySet([{ client: 'aaaaaaaaaaaaaaaa', name: 'x' }, { client: 'bbbbbbbbbbbbbbbb', name: 'me' }]) |
| 106 | assert.deepEqual(d.people.map(x => x.client), ['aaaaaaaaaaaaaaaa']) |
| 107 | }) |
| 108 | |