Files
ytplayer/server/party.test.js

250 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
});