#!/usr/bin/env node /* sendByeIfRealClose state-machine tests. * * node test/sendbye-fsm.test.js * * Pins the listener / speaker split on pagehide(persisted=true): * * - speaker + persisted=true -> no bye (bfcache nap; keep PCs warm) * - speaker + persisted=false -> bye (real unload) * - listener + persisted=true -> bye (no PCs to protect; roster * cleanup matters more than * bfcache continuity) * - listener + persisted=false -> bye (real unload) * * Background: fox 2026-06-03 hit "phone leaving and coming back cannot * hear the music" when bye was always sent — the SFU eviction killed * the speaker's PCs and the resumed page couldn't recover them. The * fix was to skip bye on persisted=true. fox 2026-06-06 hit the * opposite: "closed firefox on both phones [they] are both still in * the list" because listeners are exempt from the server's * aliveJanitor and the bfcache skip left them as 120s+8s zombies. The * split below threads the needle. The function is extracted from * web/zebra-spaces.html at test time so the spec can't drift. */ const fs = require('fs'); const path = require('path'); const src = fs.readFileSync(path.join(__dirname, '..', 'web', 'zebra-spaces.html'), 'utf8'); function extractFn(re){ const m = src.match(re); if (!m) throw new Error('could not find ' + re); let i = src.indexOf('{', m.index + m[0].length), depth = 0, j = i; for (; j < src.length; j++){ if (src[j] === '{') depth++; else if (src[j] === '}') { if (--depth === 0) { j++; break; } } } return src.slice(m.index, j); } const sendByeSrc = extractFn(/function sendByeIfRealClose\(/); /* Build a sandbox where myRole + ws are mutable so each scenario can * configure them, and ws.send is a spy that records every call. */ function makeHarness(initialRole){ const sent = []; const ws = { readyState: 1, /* OPEN — WebSocket.OPEN === 1 in browsers */ send(msg){ sent.push(msg); }, }; const factory = new Function( 'wsRef', 'WebSocket', 'initialRole', 'let myRole = initialRole;\n' + 'const ws = wsRef;\n' + sendByeSrc + '\n' + 'return {\n' + ' setRole(r){ myRole = r; },\n' + ' setWsState(s){ ws.readyState = s; },\n' + ' sendByeIfRealClose,\n' + '};\n' ); return { sent, ws, api: factory(ws, { OPEN: 1 }, initialRole) }; } let pass = 0, fail = 0; function test(name, fn){ try { fn(); console.log(' ✓ ' + name); pass++; } catch (e){ console.log(' ✗ ' + name + ' — ' + (e.message || e)); fail++; } } function eq(a, b, msg){ if (a !== b) throw new Error((msg || 'expected') + ' — got ' + JSON.stringify(a) + ' want ' + JSON.stringify(b)); } console.log('sendByeIfRealClose:'); test('speaker + persisted=true (bfcache): bye NOT sent (PCs must survive the nap)', () => { const h = makeHarness('speaker'); h.api.sendByeIfRealClose({ persisted: true }); eq(h.sent.length, 0, 'no bye on bfcache'); }); test('speaker + persisted=false (real unload): bye sent', () => { const h = makeHarness('speaker'); h.api.sendByeIfRealClose({ persisted: false }); eq(h.sent.length, 1, 'bye sent'); eq(JSON.parse(h.sent[0]).type, 'bye'); }); test('host + persisted=true (bfcache): bye NOT sent (host is a speaker role)', () => { const h = makeHarness('host'); h.api.sendByeIfRealClose({ persisted: true }); eq(h.sent.length, 0); }); test('cohost + persisted=true (bfcache): bye NOT sent', () => { const h = makeHarness('cohost'); h.api.sendByeIfRealClose({ persisted: true }); eq(h.sent.length, 0); }); test('listener + persisted=true (bfcache): bye SENT (no PCs to protect, roster cleanup wins)', () => { /* Pins the fox 2026-06-06 fix: closed Firefox on phone leaves listener * zombies because aliveJanitor exempts listeners + bfcache skip blocked * bye. Listeners ride opposite the speaker bfcache rule. */ const h = makeHarness('listener'); h.api.sendByeIfRealClose({ persisted: true }); eq(h.sent.length, 1, 'bye sent on bfcache for listener'); eq(JSON.parse(h.sent[0]).type, 'bye'); }); test('listener + persisted=false (real unload): bye sent', () => { const h = makeHarness('listener'); h.api.sendByeIfRealClose({ persisted: false }); eq(h.sent.length, 1); }); test('any role + no event (defensive): bye sent', () => { /* Some browsers call event handlers with undefined. Don't crash. */ const h = makeHarness('speaker'); h.api.sendByeIfRealClose(undefined); eq(h.sent.length, 1, 'bye sent when event missing'); }); test('ws not open: no bye attempted (no throw, no send)', () => { const h = makeHarness('listener'); h.api.setWsState(0); /* CONNECTING */ h.api.sendByeIfRealClose({ persisted: true }); eq(h.sent.length, 0, 'no send while ws not open'); }); test('role flips to listener mid-session, then bfcache fires: bye sent', () => { /* a user promoted/demoted during the session — the role-at-pagehide * is what matters, not the role-at-join. */ const h = makeHarness('speaker'); h.api.setRole('listener'); h.api.sendByeIfRealClose({ persisted: true }); eq(h.sent.length, 1, 'bye sent under post-demote listener state'); }); console.log(''); console.log((fail === 0 ? 'PASS' : 'FAIL') + ' — ' + pass + ' passed, ' + fail + ' failed'); process.exit(fail === 0 ? 0 : 1);