250 lines
13 KiB
JavaScript
250 lines
13 KiB
JavaScript
// 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 <b>all</b>' });
|
||
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();
|
||
});
|
||
|
||
describe('watch party presenting', () => {
|
||
test('host shares a link that guests and late joiners see; junk and guest attempts are ignored', async () => {
|
||
const host = client({ role: 'host', pid: 'hostpid0010', name: 'H' });
|
||
const { code } = await host.next('hello');
|
||
const g = client({ role: 'guest', pid: 'guestpid0011', key: 'k'.repeat(36), name: 'G', code });
|
||
await g.next('hello');
|
||
g.send({ type: 'present', present: { url: 'https://evil.example/' } });
|
||
host.send({ type: 'present', present: { url: 'javascript:alert(1)' } });
|
||
host.send({ type: 'present', present: { url: 'https://docs.google.com/presentation/d/abc/edit', page: 3 } });
|
||
const shown = await g.next('present');
|
||
expect(shown.present).toMatchObject({ url: 'https://docs.google.com/presentation/d/abc/edit', page: 3, by: 'H' });
|
||
const late = client({ role: 'guest', pid: 'guestpid0012', key: 'l'.repeat(36), name: 'L', code });
|
||
expect((await late.next('hello')).present.page).toBe(3);
|
||
host.send({ type: 'present', present: null });
|
||
expect((await g.next('present')).present).toBe(null);
|
||
host.send({ type: 'end' }); await host.closed;
|
||
});
|
||
});
|
||
|
||
describe('watch party presentation queue', () => {
|
||
test('host prepares, reorders and removes queued presentations; guests cannot; it survives a host reload', async () => {
|
||
const host = client({ role: 'host', pid: 'hostpid0030', name: 'H' });
|
||
const { code, secret } = await host.next('hello');
|
||
host.send({ type: 'pq', action: 'add', url: 'https://docs.google.com/presentation/d/a/edit', mode: 'embed', title: 'Sermon slides' });
|
||
host.send({ type: 'pq', action: 'add', url: 'https://example.com/notes', mode: 'reader', title: '' });
|
||
host.send({ type: 'pq', action: 'add', url: 'javascript:alert(1)' });
|
||
let items = (await host.next('pq')).items; items = (await host.next('pq')).items;
|
||
expect(items.map((x) => x.title || x.url)).toEqual(['Sermon slides', 'https://example.com/notes']);
|
||
host.send({ type: 'pq', action: 'move', id: items[1].id, by: -1 });
|
||
expect((await host.next('pq')).items[0].url).toBe('https://example.com/notes');
|
||
const g = client({ role: 'guest', pid: 'guestpid0031', key: 'q'.repeat(36), name: 'G', code });
|
||
expect((await g.next('hello')).pq).toBeUndefined();
|
||
g.send({ type: 'pq', action: 'add', url: 'https://evil.example/' });
|
||
host.ws.close(); await host.closed;
|
||
const again = client({ role: 'host', pid: 'hostpid0030', name: 'H', code, secret });
|
||
expect((await again.next('hello')).pq.length).toBe(2);
|
||
again.send({ type: 'pq', action: 'remove', id: items[0].id });
|
||
expect((await again.next('pq')).items.length).toBe(1);
|
||
again.send({ type: 'end' }); await again.closed;
|
||
});
|
||
});
|
||
|
||
describe('watch party invites', () => {
|
||
test('host invites a profile; it is listed for that profile until uninvited or the party ends', async () => {
|
||
const host = client({ role: 'host', pid: 'hostpid0020', name: 'Josh' });
|
||
const { code } = await host.next('hello');
|
||
const p = hub._parties.get(code);
|
||
host.send({ type: 'invite', profile: 'Psalm-Team' });
|
||
expect((await host.next('invites')).invites).toEqual(['psalm-team']);
|
||
expect(hub.invitesFor('PSALM-TEAM')).toMatchObject([{ code, host: 'Josh', members: 1 }]);
|
||
host.send({ type: 'invite', profile: 'x' });
|
||
expect((await host.next('invites')).error).toMatch(/3–40/);
|
||
const g = client({ role: 'guest', pid: 'guestpid0021', key: 'g'.repeat(36), name: 'G', code });
|
||
await g.next('hello');
|
||
g.send({ type: 'invite', profile: 'someone' });
|
||
await Bun.sleep(150);
|
||
expect([...p.invites.keys()]).toEqual(['psalm-team']);
|
||
host.send({ type: 'uninvite', profile: 'psalm-team' });
|
||
expect((await host.next('invites')).invites).toEqual([]);
|
||
expect(hub.invitesFor('psalm-team')).toEqual([]);
|
||
host.send({ type: 'end' }); await host.closed;
|
||
});
|
||
});
|
||
|
||
describe('watch party seat rules', () => {
|
||
test('unknown code closes with 4004 (no reconnect loop)', async () => {
|
||
const bad = client({ role: 'guest', pid: 'guestpid7777', name: 'X', code: 'YYYYYY' });
|
||
expect(await bad.closed).toBe(4004);
|
||
});
|
||
|
||
test('a guest cannot take the host seat or another guest seat; the same device can', async () => {
|
||
const host = client({ role: 'host', pid: 'hostpid0002', name: 'H' });
|
||
const { code } = await host.next('hello');
|
||
const fake = client({ role: 'guest', pid: 'hostpid0002', name: 'Evil', code });
|
||
expect(await fake.closed).toBe(4003);
|
||
const g = client({ role: 'guest', pid: 'guestpid0003', key: 'k'.repeat(36), name: 'G', code });
|
||
await g.next('hello');
|
||
const thief = client({ role: 'guest', pid: 'guestpid0003', key: 'x'.repeat(36), name: 'T', code });
|
||
expect(await thief.closed).toBe(4005);
|
||
const again = client({ role: 'guest', pid: 'guestpid0003', key: 'k'.repeat(36), name: 'G', code });
|
||
await again.next('hello');
|
||
expect(await g.closed).toBe(4000);
|
||
host.send({ type: 'end' }); await host.closed;
|
||
});
|
||
|
||
test('a host resuming a room the server lost is told so', async () => {
|
||
const host = client({ role: 'host', pid: 'hostpid0004', name: 'H', code: 'QQQQQQ', secret: 'whatever-secret' });
|
||
const hello = await host.next('hello');
|
||
expect(hello.lost).toBe(true);
|
||
expect(hello.code).not.toBe('QQQQQQ');
|
||
host.send({ type: 'end' }); await host.closed;
|
||
});
|
||
|
||
test('voice presence floods are dropped', async () => {
|
||
const host = client({ role: 'host', pid: 'hostpid0005', name: 'H' });
|
||
const { code } = await host.next('hello');
|
||
const g = client({ role: 'guest', pid: 'guestpid0006', key: 'k'.repeat(36), name: 'G', code });
|
||
await g.next('hello'); host.drain('members');
|
||
for (let i = 0; i < 400; i++) g.send({ type: 'voice', on: i % 2 === 0 });
|
||
await Bun.sleep(300);
|
||
let n = 0; try { for (;;) { await host.next('members', 50); n++; } } catch { /* drained */ }
|
||
expect(n).toBeLessThanOrEqual(41);
|
||
host.send({ type: 'end' }); await host.closed;
|
||
});
|
||
});
|
||
|
||
test('heartbeat replies only to the sender with the server clock', async () => {
|
||
const h = client({ role: 'host', pid: 'heartbeat-host', name: 'Host' });
|
||
const hello = await h.next('hello');
|
||
const g = client({ role: 'guest', pid: 'heartbeat-guest', code: hello.code, name: 'Guest' });
|
||
await g.next('hello');
|
||
const before = Date.now();
|
||
g.send({ type: 'ping', echo: 12345 });
|
||
const pong = await g.next('pong');
|
||
expect(pong.echo).toBe(12345);
|
||
expect(pong.now).toBeGreaterThanOrEqual(before);
|
||
expect(pong.now).toBeLessThanOrEqual(Date.now());
|
||
await expect(h.next('pong', 50)).rejects.toThrow('timeout pong');
|
||
g.ws.close(); h.send({ type: 'end' }); await h.closed;
|
||
});
|