Add phone remote control: pair a phone with a 6-digit code or QR to drive a desktop instance's playback, queue, volume, sleep timer and service mode
This commit is contained in:
153
server/remote.test.js
Normal file
153
server/remote.test.js
Normal file
@@ -0,0 +1,153 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user