// Phone-remote relay: real Bun server + real WebSocket clients. import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { createRemoteHub, roomIdFor } from './remote.js'; const SECRET = 'a'.repeat(43); let hub, server, base, ipFor; function start(opts) { hub = createRemoteHub(opts); ipFor = () => '203.0.113.1'; server = Bun.serve({ port: 0, async fetch(req, srv) { const u = new URL(req.url); if (u.pathname === '/ws/remote') return hub.upgrade(req, srv, ipFor(req)); if (u.pathname === '/pair') { const body = await req.json(); const r = hub.pair({ ...body, ip: ipFor(req) }); return Response.json(r.body, { status: r.status }); } return new Response('nope', { status: 404 }); }, websocket: hub.websocket, }); base = `localhost:${server.port}`; } // A WebSocket client that queues messages and lets a test await the next // one of a given type. function client(query) { const ws = new WebSocket(`ws://${base}/ws/remote?${new URLSearchParams(query)}`); const inbox = []; const 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); }); return { ws, closed, 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) => { waiters.push({ type, resolve }); setTimeout(() => reject(new Error(`timeout waiting for ${type}`)), ms); }); }, opened: new Promise((r) => { ws.onopen = r; }), }; } const pair = async (code, name = 'Josh iPhone') => { const res = await fetch(`http://${base}/pair`, { method: 'POST', body: JSON.stringify({ code, name }) }); return { status: res.status, body: await res.json() }; }; describe('remote relay', () => { beforeAll(() => start()); afterAll(() => { server.stop(true); hub.stop(); }); let host, remote, creds; test('host gets a room and a 6-digit code', async () => { host = client({ role: 'host', secret: SECRET, name: 'Living Room TV' }); const hello = await host.next('hello'); expect(hello.roomId).toBe(roomIdFor(SECRET)); expect(hello.code).toMatch(/^\d{6}$/); host.code = hello.code; }); test('wrong code is refused; right code pairs once and rotates', async () => { expect((await pair('000000')).status === 404 || host.code === '000000').toBe(true); const ok = await pair(host.code); expect(ok.status).toBe(200); expect(ok.body.hostName).toBe('Living Room TV'); creds = ok.body; const fresh = await host.next('code'); expect(fresh.code).not.toBe(host.code); expect((await pair(host.code)).status).toBe(404); // one-time host.code = fresh.code; }); test('remote connects, host sees it, state flows down, commands flow up', async () => { host.send({ type: 'state', state: { title: 'Living Hope', cur: 84, paused: false } }); host.send({ type: 'queue', queue: { items: [{ id: 'x', title: 'House of the Lord' }], idx: -1 } }); remote = client({ role: 'remote', room: creds.roomId, rid: creds.remoteId, token: creds.token, name: 'Josh iPhone' }); const hello = await remote.next('hello'); expect(hello.state.title).toBe('Living Hope'); expect(hello.queue.items[0].title).toBe('House of the Lord'); const list = await host.next('remotes'); expect(list.remotes.map((r) => r.name)).toEqual(['Josh iPhone']); remote.send({ type: 'cmd', cmd: 'seek', t: 120 }); remote.send({ type: 'cmd', cmd: 'rm -rf' }); // not a command → dropped remote.send({ type: 'cmd', cmd: 'next' }); const c1 = await host.next('cmd'); expect(c1).toMatchObject({ cmd: 'seek', t: 120, from: 'Josh iPhone' }); expect((await host.next('cmd')).cmd).toBe('next'); host.send({ type: 'state', state: { title: 'Living Hope', cur: 121, paused: true } }); expect((await remote.next('state')).state.cur).toBe(121); }); test('ping goes through the host and back', async () => { remote.send({ type: 'ping', t: 42 }); const p = await host.next('ping'); expect(p.t).toBe(42); host.send({ type: 'pong', rid: p.rid, t: p.t }); expect((await remote.next('pong')).t).toBe(42); }); test('a forged token is rejected', async () => { const bad = client({ role: 'remote', room: creds.roomId, rid: creds.remoteId, token: 'forged', name: 'x' }); expect(await bad.closed).toBe(4001); }); test('desktop offline: remotes are told, reconnects wait; desktop back → phone still paired', async () => { host.ws.close(); await remote.next('host-offline'); const early = client({ role: 'remote', room: creds.roomId, rid: creds.remoteId, token: creds.token, name: 'x' }); expect(await early.closed).toBe(4004); host = client({ role: 'host', secret: SECRET, name: 'Living Room TV' }); await host.next('hello'); expect((await remote.next('hello')).hostName).toBe('Living Room TV'); }); test('unpair-all disconnects every phone', async () => { host.send({ type: 'unpair-all' }); expect(await remote.closed).toBe(4001); host.ws.close(); }); }); describe('same-network pairing', () => { beforeAll(() => start({ requireSameNetwork: true })); afterAll(() => { server.stop(true); hub.stop(); }); test('a phone on another network cannot pair', async () => { const host = client({ role: 'host', secret: 'b'.repeat(40), name: 'TV' }); const { code } = await host.next('hello'); ipFor = () => '198.51.100.7'; const r = await pair(code); expect(r.status).toBe(403); ipFor = () => '203.0.113.1'; expect((await pair(code)).status).toBe(200); host.ws.close(); }); });