1// The lobby (lobby-room.ts), with fake sockets whose attachments live in 2// memory as the Durable Object's would. 3import { describe, expect, it } from 'vitest'; 4import { BURST, Lobby, MAX_MESSAGE_BYTES, MAX_TITLE, statusOf, type Attachment, type Peer } from './lobby-room'; 5 6type Fake = Peer & { got: any[]; a: Attachment }; 7function setup() { 8 const peers: Fake[] = []; 9 let now = 1_000_000; 10 const lobby = new Lobby(() => peers, () => now); 11 const join = (id: string, name: string | null = null, group = id): Fake => { 12 const got: any[] = []; 13 const p: Fake = { 14 got, 15 a: { id, name, group, visible: false, changed: now, status: null }, 16 send: (m: string) => void got.push(JSON.parse(m)), 17 close() {}, 18 deserializeAttachment() { 19 return structuredClone(this.a); 20 }, 21 serializeAttachment(v) { 22 this.a = structuredClone(v as Attachment); 23 }, 24 }; 25 peers.push(p); 26 lobby.joined(p); 27 return p; 28 }; 29 const leave = (p: Fake) => { 30 lobby.left(p); 31 peers.splice(peers.indexOf(p), 1); 32 }; 33 const say = (p: Fake, m: unknown) => lobby.message(p, JSON.stringify(m)); 34 const last = (p: Fake, t: string) => [...p.got].reverse().find((m) => m.t === t); 35 return { lobby, join, leave, say, last, tick: (ms: number) => (now += ms), now: () => now }; 36} 37 38const playing = { t: 'status', visible: true, song: 'jev/system-one', playing: true, cycle: 12, cps: 0.5, length: 64 }; 39 40describe('the lobby', () => { 41 it('welcomes a tab with its id, the room’s clock and every visible tab', () => { 42 const { join, say, now } = setup(); 43 const a = join('tab-aaaaaaaa', 'Chris', 'g1'); 44 say(a, playing); 45 join('tab-hidden01'); 46 const c = join('tab-cccccccc'); 47 const welcome = c.got[0]; 48 expect(welcome).toMatchObject({ t: 'welcome', id: 'tab-cccccccc', now: now(), n: 3 }); 49 // a tab that has said nothing yet, like a hidden one, is not listed 50 expect(welcome.tabs).toEqual([ 51 expect.objectContaining({ id: 'tab-aaaaaaaa', name: 'Chris', group: 'g1', song: 'jev/system-one', cycle: 12, length: 64 }), 52 ]); 53 }); 54 55 it('tells everyone else when a tab changes, is hidden, or leaves, and how many are here', () => { 56 const { join, leave, say, last } = setup(); 57 const a = join('tab-aaaaaaaa'); 58 const b = join('tab-bbbbbbbb'); 59 expect(last(a, 'count')).toEqual({ t: 'count', n: 2 }); 60 say(b, playing); 61 expect(last(a, 'tab').tab).toMatchObject({ id: 'tab-bbbbbbbb', name: null, playing: true }); 62 expect(b.got.some((m) => m.t === 'tab')).toBe(false); 63 say(b, { ...playing, visible: false }); 64 expect(last(a, 'gone')).toEqual({ t: 'gone', id: 'tab-bbbbbbbb' }); 65 say(b, playing); 66 a.got.length = 0; 67 leave(b); 68 expect(a.got).toEqual([{ t: 'gone', id: 'tab-bbbbbbbb' }, { t: 'count', n: 1 }]); 69 }); 70 71 it('passes on only what it checked: bounded text, known shapes, no progress while stopped', () => { 72 const s = statusOf( 73 { 74 song: '../etc', 75 listener: 'abc_DEF-1', 76 title: `a\u0000${'x'.repeat(200)}`, 77 edited: true, 78 playing: false, 79 cycle: 5, 80 cps: 1, 81 length: -3, 82 section: '<b>drop</b>', 83 extra: 'dropped', 84 }, 85 7, 86 ); 87 expect(s).toEqual({ 88 song: null, 89 listener: 'abc_DEF-1', 90 title: `a ${'x'.repeat(MAX_TITLE - 2)}`, 91 edited: false, 92 playing: false, 93 cycle: null, 94 cps: null, 95 at: 7, 96 length: null, 97 max: null, 98 section: '<b>drop</b>', // text; pages render it as text 99 }); 100 expect(statusOf({ song: 'jev/a', listener: 'x', title: 't', edited: true }, 0)).toMatchObject({ 101 song: 'jev/a', 102 listener: null, 103 title: null, 104 edited: true, 105 }); 106 }); 107 108 it('refuses oversized messages and drops a flood past its bucket', () => { 109 const { join, say, last } = setup(); 110 const a = join('tab-aaaaaaaa'); 111 const b = join('tab-bbbbbbbb'); 112 a.got.length = 0; 113 b.got.length = 0; 114 say(b, { ...playing, title: 'x'.repeat(MAX_MESSAGE_BYTES) }); 115 expect(last(b, 'error').why).toMatch(/at most/); 116 for (let i = 0; i < BURST + 10; i++) say(b, { ...playing, cycle: i }); 117 // the refused message cost nothing; the flood gets the whole bucket 118 expect(a.got.filter((m) => m.t === 'tab').length).toBe(BURST); 119 }); 120 121 it('brokers listening along: the asker gets the party’s room, and nobody else does', () => { 122 const { join, say, last } = setup(); 123 const host = join('tab-hosthost'); 124 const asker = join('tab-askerask'); 125 const other = join('tab-otherrrr'); 126 say(host, playing); 127 say(asker, { t: 'listen', to: 'tab-hosthost' }); 128 expect(last(host, 'listen')).toEqual({ t: 'listen', from: 'tab-askerask' }); 129 say(host, { t: 'invite', to: 'tab-askerask', room: 'room-0123456789abcdef' }); 130 expect(last(asker, 'join')).toEqual({ t: 'join', from: 'tab-hosthost', room: 'room-0123456789abcdef' }); 131 expect(other.got.some((m) => m.t === 'join' || m.t === 'listen')).toBe(false); 132 // answered once: a second invite does not reach it 133 asker.got.length = 0; 134 say(host, { t: 'invite', to: 'tab-askerask', room: 'room-0123456789abcdef' }); 135 expect(asker.got).toEqual([]); 136 }); 137 138 it('sends nobody into a party they did not ask for', () => { 139 const { join, say } = setup(); 140 const mallory = join('tab-mallory1'); 141 const victim = join('tab-victim01'); 142 say(mallory, playing); 143 say(mallory, { t: 'invite', to: 'tab-victim01', room: 'room-0123456789abcdef' }); 144 expect(victim.got.some((m) => m.t === 'join')).toBe(false); 145 }); 146 147 it('asks only a visible tab playing a site song', () => { 148 const { join, say, last } = setup(); 149 const a = join('tab-aaaaaaaa'); 150 const b = join('tab-bbbbbbbb'); 151 say(b, { ...playing, song: null, listener: 'song1', title: 'a listener song' }); 152 say(a, { t: 'listen', to: 'tab-bbbbbbbb' }); 153 expect(last(a, 'error').why).toMatch(/site song/); 154 say(b, { ...playing, visible: false }); 155 say(a, { t: 'listen', to: 'tab-bbbbbbbb' }); 156 expect(last(a, 'error').why).toMatch(/not listed/); 157 expect(b.got.some((m) => m.t === 'listen')).toBe(false); 158 }); 159});