// Watch-party relay: real Bun server + real WebSocket clients. import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { createPartyHub } from './party.js'; let hub, server, base; beforeAll(() => { hub = createPartyHub(); server = Bun.serve({ port: 0, fetch(req, srv) { if (new URL(req.url).pathname === '/ws/party') return hub.upgrade(req, srv, '203.0.113.9'); return new Response('nope', { status: 404 }); }, websocket: hub.websocket, }); base = `localhost:${server.port}`; }); afterAll(() => { server.stop(true); hub.stop(); }); function client(query) { const ws = new WebSocket(`ws://${base}/ws/party?${new URLSearchParams(query)}`); const inbox = [], waiters = []; ws.onmessage = (e) => { const m = JSON.parse(e.data); const i = waiters.findIndex((w) => w.type === m.type); if (i >= 0) waiters.splice(i, 1)[0].resolve(m); else inbox.push(m); }; const closed = new Promise((r) => { ws.onclose = (e) => r(e.code); }); const errored = new Promise((r) => { ws.onerror = () => r(true); }); return { ws, closed, errored, send: (m) => ws.send(JSON.stringify(m)), next(type, ms = 2000) { const i = inbox.findIndex((m) => m.type === type); if (i >= 0) return Promise.resolve(inbox.splice(i, 1)[0]); return new Promise((resolve, reject) => { const w = { type, resolve }; waiters.push(w); setTimeout(() => { const k = waiters.indexOf(w); if (k >= 0) { waiters.splice(k, 1); reject(new Error('timeout ' + type)); } }, ms); }); }, drain(type) { for (let i = inbox.length - 1; i >= 0; i--) if (inbox[i].type === type) inbox.splice(i, 1); }, }; } describe('watch party', () => { let host, guest, code, secret; test('host creates a party with a 6-char code', async () => { host = client({ role: 'host', pid: 'hostpid0001', name: 'Josh' }); const hello = await host.next('hello'); expect(hello.code).toMatch(/^[A-HJ-NP-Z2-9]{6}$/); expect(hello.secret.length).toBeGreaterThan(10); code = hello.code; secret = hello.secret; }); test('unknown code is refused; guest joins and gets the state + chat history', async () => { const bad = client({ role: 'guest', pid: 'guestpid9999', name: 'X', code: 'ZZZZZZ' }); expect(await Promise.race([bad.errored, bad.closed.then(() => true)])).toBe(true); host.send({ type: 'state', state: { v: { id: 'rXFZsRhdT78', title: 'As the Deer' }, cur: 12, paused: false, rate: 1 } }); host.send({ type: 'chat', text: 'welcome!' }); await new Promise((r) => setTimeout(r, 100)); guest = client({ role: 'guest', pid: 'guestpid0001', name: 'Rex', code: code.toLowerCase() }); const hello = await guest.next('hello'); expect(hello.state.v.id).toBe('rXFZsRhdT78'); expect(typeof hello.state.ts).toBe('number'); expect(hello.chat.map((c) => c.text)).toEqual(['welcome!']); expect(hello.members.map((m) => m.name).sort()).toEqual(['Josh', 'Rex']); expect(hello.secret).toBeUndefined(); const m = await host.next('members'); expect(m.joined).toBe('Rex'); }); test('state, chat (flood-guarded), voice presence and targeted rtc relay', async () => { host.send({ type: 'state', state: { v: { id: 'x' }, cur: 30, paused: true } }); expect((await guest.next('state')).state.cur).toBe(30); host.drain('chat'); // its own "welcome!" echo guest.send({ type: 'chat', text: 'hi all' }); guest.send({ type: 'chat', text: 'spam' }); // < 400 ms later → dropped const c = await host.next('chat'); expect(c.msg).toMatchObject({ from: 'Rex', text: 'hi b all /b' }); await expect(host.next('chat', 300)).rejects.toThrow(); guest.send({ type: 'voice', on: true }); const mem = await host.next('members'); expect(mem.members.find((x) => x.name === 'Rex').voice).toBe(true); guest.send({ type: 'rtc', to: 'hostpid0001', data: { sdp: 'offer' } }); expect(await host.next('rtc')).toEqual({ type: 'rtc', from: 'guestpid0001', data: { sdp: 'offer' } }); }); test('guest commands reach the host only when control is allowed', async () => { guest.send({ type: 'cmd', cmd: 'toggle' }); await expect(host.next('cmd', 300)).rejects.toThrow(); host.send({ type: 'settings', allowControl: true }); expect((await guest.next('settings')).allowControl).toBe(true); guest.send({ type: 'cmd', cmd: 'seek', t: 50 }); guest.send({ type: 'cmd', cmd: 'rm' }); const cmd = await host.next('cmd'); expect(cmd).toMatchObject({ cmd: 'seek', t: 50, from: 'Rex' }); }); test('host drops and resumes with its secret; a stranger cannot take over', async () => { guest.drain('members'); host.ws.close(); const away = await guest.next('members'); expect(away.hostAway).toBe(true); const thief = client({ role: 'host', pid: 'thief000001', name: 'T', code, secret: 'wrong' }); expect(await thief.closed).toBe(4003); host = client({ role: 'host', pid: 'hostpid0001', name: 'Josh', code, secret }); expect((await host.next('hello')).code).toBe(code); }); test('host ends the party', async () => { host.send({ type: 'end' }); expect((await guest.next('ended')).reason).toContain('ended'); expect(await guest.closed).toBe(4010); }); }); test('DJ suggestions and votes are broadcast, host consumes approved songs', async () => { const h = client({ role: 'host', pid: 'djhost00001', name: 'Host' }); const hello = await h.next('hello'); const g = client({ role: 'guest', pid: 'djguest0001', name: 'Guest', code: hello.code }); await g.next('hello'); h.send({ type: 'dj', action: 'enable', enabled: true }); expect((await g.next('dj')).enabled).toBe(true); await h.next('dj'); g.send({ type: 'dj', action: 'suggest', video: { id: '0gfX0dFLaBc', title: 'Song' } }); expect((await h.next('dj')).entries[0].approved).toBe(false); await g.next('dj'); g.send({ type: 'dj', action: 'vote', id: '0gfX0dFLaBc' }); expect((await h.next('dj')).entries[0].votes).toEqual(['djguest0001']); await g.next('dj'); h.send({ type: 'dj', action: 'approve', id: '0gfX0dFLaBc' }); expect((await g.next('dj')).entries[0].approved).toBe(true); await h.next('dj'); h.send({ type: 'dj', action: 'take' }); expect((await h.next('dj-play')).video.id).toBe('0gfX0dFLaBc'); expect((await g.next('dj')).entries).toEqual([]); h.ws.close(); g.ws.close(); });