File
Blob: worker/src/client/radio/session.test.ts
| 1 | import assert from 'node:assert/strict'; |
| 2 | import { test } from 'node:test'; |
| 3 | import type { RadioApi } from './api.ts'; |
| 4 | import type { Joined, RoomStatus } from '@/shared/contracts/signaling.ts'; |
| 5 | import { RadioSession } from './session.ts'; |
| 6 | import { playbackView } from './playback.ts'; |
| 7 | |
| 8 | const joined: Joined = { |
| 9 | id: 'viewer', |
| 10 | viewerToken: 'capability', |
| 11 | generation: 'generation', |
| 12 | sessionDescription: { type: 'offer', sdp: 'v=0' }, |
| 13 | }; |
| 14 | const status: RoomStatus = { |
| 15 | online: true, |
| 16 | viewers: 1, |
| 17 | controller: null, |
| 18 | generation: joined.generation, |
| 19 | }; |
| 20 | |
| 21 | function stubApi(overrides: Partial<RadioApi> = {}): RadioApi { |
| 22 | return { |
| 23 | getStatus: async () => status, |
| 24 | login: async () => ({ ok: true }), |
| 25 | joinViewer: async () => joined, |
| 26 | answerViewer: async () => ({ |
| 27 | channels: [ |
| 28 | { id: 1, dataChannelName: 'robot', ordered: true }, |
| 29 | { id: 3, dataChannelName: 'spectrum', ordered: false, maxRetransmits: 0 }, |
| 30 | ], |
| 31 | }), |
| 32 | subscribeAudio: async () => ({ sessionDescription: { type: 'offer', sdp: 'v=0' } }), |
| 33 | renegotiateViewer: async () => ({ ok: true }), |
| 34 | heartbeatViewer: async () => ({ controller: null }), |
| 35 | claimControl: async () => ({ controller: joined.id }), |
| 36 | releaseControl: async () => ({ controller: null }), |
| 37 | leaveViewer: async () => ({ ok: true }), |
| 38 | ...overrides, |
| 39 | }; |
| 40 | } |
| 41 | |
| 42 | test('a successful status retry clears the failed status operation', async () => { |
| 43 | let fail = true; |
| 44 | const session = new RadioSession( |
| 45 | stubApi({ |
| 46 | getStatus: async () => { |
| 47 | if (fail) throw new Error('offline'); |
| 48 | return status; |
| 49 | }, |
| 50 | }), |
| 51 | ); |
| 52 | await session.refreshStatus(); |
| 53 | assert.match(session.getSnapshot().issues.status!, /unavailable/); |
| 54 | fail = false; |
| 55 | await session.refreshStatus(); |
| 56 | assert.equal(session.getSnapshot().room.online, true); |
| 57 | assert.equal(session.getSnapshot().issues.status, undefined); |
| 58 | }); |
| 59 | |
| 60 | test('track information follows the current publisher without fetching static assets', async (t) => { |
| 61 | const fetch = t.mock.method(globalThis, 'fetch', async () => { |
| 62 | throw new Error('Unexpected static metadata request'); |
| 63 | }); |
| 64 | Object.defineProperty(globalThis, 'window', { value: new EventTarget(), configurable: true }); |
| 65 | t.after(() => Reflect.deleteProperty(globalThis, 'window')); |
| 66 | let current: typeof status & { |
| 67 | track?: { title: string; artist: string; durationMs: number } | null; |
| 68 | } = { |
| 69 | ...status, |
| 70 | track: { title: 'First track', artist: 'Artist', durationMs: 2000 }, |
| 71 | }; |
| 72 | const api = stubApi({ getStatus: async () => current }); |
| 73 | const session = new RadioSession(api); |
| 74 | const stop = session.start({ |
| 75 | pause() {}, |
| 76 | volume: 0, |
| 77 | srcObject: null, |
| 78 | } as unknown as HTMLAudioElement); |
| 79 | try { |
| 80 | await Promise.resolve(); |
| 81 | assert.equal(playbackView(session.getSnapshot()).track?.title, 'First track'); |
| 82 | current = { |
| 83 | ...status, |
| 84 | generation: 'replacement', |
| 85 | track: { title: 'Second track', artist: '', durationMs: 4000 }, |
| 86 | }; |
| 87 | await session.refreshStatus(); |
| 88 | assert.equal(playbackView(session.getSnapshot()).track?.title, 'Second track'); |
| 89 | current = { ...status, track: null }; |
| 90 | await session.refreshStatus(); |
| 91 | assert.equal(playbackView(session.getSnapshot()).track, undefined); |
| 92 | assert.equal(fetch.mock.callCount(), 0); |
| 93 | } finally { |
| 94 | stop(); |
| 95 | } |
| 96 | }); |
| 97 | |
| 98 | class Channel extends EventTarget { |
| 99 | label: string; |
| 100 | readyState = 'open'; |
| 101 | bufferedAmount = 0; |
| 102 | onmessage?: (event: { data: unknown }) => void; |
| 103 | sent: string[] = []; |
| 104 | constructor(label: string) { |
| 105 | super(); |
| 106 | this.label = label; |
| 107 | } |
| 108 | send(value: string) { |
| 109 | this.sent.push(value); |
| 110 | } |
| 111 | } |
| 112 | class Peer extends EventTarget { |
| 113 | static instances: Peer[] = []; |
| 114 | connectionState = 'new'; |
| 115 | iceGatheringState = 'complete'; |
| 116 | localDescription?: RTCSessionDescriptionInit; |
| 117 | channels: Channel[] = []; |
| 118 | constructor() { |
| 119 | super(); |
| 120 | Peer.instances.push(this); |
| 121 | } |
| 122 | async createAnswer() { |
| 123 | return { type: 'answer' as const, sdp: 'v=0' }; |
| 124 | } |
| 125 | async setLocalDescription(answer: RTCSessionDescriptionInit) { |
| 126 | this.localDescription = answer; |
| 127 | } |
| 128 | async setRemoteDescription() {} |
| 129 | close() { |
| 130 | this.connectionState = 'closed'; |
| 131 | } |
| 132 | createDataChannel(name: string) { |
| 133 | const channel = new Channel(name); |
| 134 | this.channels.push(channel); |
| 135 | return channel; |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | test('cancellation cleans up a viewer allocated after the user disconnected', async (t) => { |
| 140 | t.mock.method(globalThis, 'fetch', async () => |
| 141 | Response.json({ title: 'Test', artist: 'Test', durationMs: 1000 }), |
| 142 | ); |
| 143 | Object.defineProperty(globalThis, 'window', { value: new EventTarget(), configurable: true }); |
| 144 | t.after(() => Reflect.deleteProperty(globalThis, 'window')); |
| 145 | Object.defineProperty(globalThis, 'RTCPeerConnection', { value: Peer, configurable: true }); |
| 146 | t.after(() => Reflect.deleteProperty(globalThis, 'RTCPeerConnection')); |
| 147 | const { promise, resolve } = Promise.withResolvers<Joined>(); |
| 148 | const left: string[] = []; |
| 149 | const api = stubApi({ |
| 150 | joinViewer: () => promise, |
| 151 | leaveViewer: async (member) => { |
| 152 | left.push(member.id); |
| 153 | return { ok: true }; |
| 154 | }, |
| 155 | }); |
| 156 | const session = new RadioSession(api); |
| 157 | const stop = session.start({ |
| 158 | pause() {}, |
| 159 | volume: 0, |
| 160 | srcObject: null, |
| 161 | } as unknown as HTMLAudioElement); |
| 162 | try { |
| 163 | const connecting = session.connect(); |
| 164 | await session.disconnect(); |
| 165 | resolve(joined); |
| 166 | await connecting; |
| 167 | assert.equal(session.getSnapshot().phase, 'idle'); |
| 168 | assert.equal(session.getSnapshot().viewerId, undefined); |
| 169 | assert.deepEqual(left, [joined.id]); |
| 170 | assert.ok(Peer.instances.every((peer) => peer.connectionState === 'closed')); |
| 171 | } finally { |
| 172 | stop(); |
| 173 | } |
| 174 | }); |
| 175 | |
| 176 | test('live song metadata wins over delayed HTTP state and rejects old FFT frames', async (t) => { |
| 177 | Object.defineProperty(globalThis, 'window', { value: new EventTarget(), configurable: true }); |
| 178 | t.after(() => Reflect.deleteProperty(globalThis, 'window')); |
| 179 | Object.defineProperty(globalThis, 'RTCPeerConnection', { value: Peer, configurable: true }); |
| 180 | t.after(() => Reflect.deleteProperty(globalThis, 'RTCPeerConnection')); |
| 181 | const first = { title: 'First', artist: '', durationMs: 2000 }; |
| 182 | const second = { title: 'Second', artist: 'Artist', durationMs: 3000 }; |
| 183 | const initial = { revision: 0, trackIndex: 0, trackCount: 2, track: first }; |
| 184 | let remote: RoomStatus = { ...status, track: first, nowPlaying: initial }; |
| 185 | const session = new RadioSession(stubApi({ getStatus: async () => remote })); |
| 186 | const stop = session.start({ |
| 187 | pause() {}, |
| 188 | volume: 0, |
| 189 | srcObject: null, |
| 190 | } as unknown as HTMLAudioElement); |
| 191 | try { |
| 192 | await session.connect(); |
| 193 | const peer = Peer.instances.at(-1)!; |
| 194 | const [robot, spectrum] = peer.channels; |
| 195 | robot.onmessage!({ |
| 196 | data: JSON.stringify({ |
| 197 | event: 'nowPlaying', |
| 198 | nowPlaying: { ...initial, revision: 1, trackIndex: 1, track: second }, |
| 199 | }), |
| 200 | }); |
| 201 | assert.equal(playbackView(session.getSnapshot()).track?.title, 'Second'); |
| 202 | await session.refreshStatus(); |
| 203 | assert.equal(playbackView(session.getSnapshot()).track?.title, 'Second'); |
| 204 | const frame = new ArrayBuffer(48); |
| 205 | const view = new DataView(frame); |
| 206 | view.setUint8(0, 2); |
| 207 | view.setUint8(12, 200); |
| 208 | view.setUint32(4, 100, true); |
| 209 | view.setUint32(44, 1, true); |
| 210 | spectrum.onmessage!({ data: frame }); |
| 211 | assert.equal(session.signal.frame?.revision, 1); |
| 212 | robot.onmessage!({ |
| 213 | data: JSON.stringify({ event: 'nowPlaying', nowPlaying: { ...initial, revision: 2 } }), |
| 214 | }); |
| 215 | assert.equal(session.signal.frame, undefined); |
| 216 | view.setUint32(4, 120, true); |
| 217 | spectrum.onmessage!({ data: frame }); |
| 218 | assert.equal(session.signal.frame, undefined); |
| 219 | assert.equal(peer.connectionState, 'new'); // the fake peer has not been closed/recreated |
| 220 | remote = { ...status, generation: 'replacement', track: first, nowPlaying: initial }; |
| 221 | await session.refreshStatus(); |
| 222 | assert.equal(session.getSnapshot().nowPlaying?.revision, 0); |
| 223 | assert.equal(session.getSnapshot().phase, 'idle'); |
| 224 | } finally { |
| 225 | stop(); |
| 226 | } |
| 227 | }); |
| 228 | |
| 229 | test('commands require ownership and acknowledgment; disconnect ignores late channel events', async (t) => { |
| 230 | t.mock.method(globalThis, 'fetch', async () => |
| 231 | Response.json({ title: 'Test', artist: 'Test', durationMs: 1000 }), |
| 232 | ); |
| 233 | Object.defineProperty(globalThis, 'window', { value: new EventTarget(), configurable: true }); |
| 234 | t.after(() => Reflect.deleteProperty(globalThis, 'window')); |
| 235 | Object.defineProperty(globalThis, 'RTCPeerConnection', { value: Peer, configurable: true }); |
| 236 | t.after(() => Reflect.deleteProperty(globalThis, 'RTCPeerConnection')); |
| 237 | const api = stubApi(); |
| 238 | const session = new RadioSession(api); |
| 239 | const stop = session.start({ |
| 240 | pause() {}, |
| 241 | volume: 0, |
| 242 | srcObject: null, |
| 243 | } as unknown as HTMLAudioElement); |
| 244 | try { |
| 245 | await session.connect(); |
| 246 | const channel = Peer.instances.at(-1)!.channels[0]; |
| 247 | assert.equal(session.getSnapshot().phase, 'connected'); |
| 248 | session.send({ led: [0, 10, 32] }); |
| 249 | assert.deepEqual(channel.sent, ['ack']); |
| 250 | await session.toggleControl(); |
| 251 | session.send({ led: [0, 10, 32] }); |
| 252 | session.send({ led: [32, 0, 0] }); |
| 253 | assert.equal(channel.sent.length, 2); |
| 254 | const command = JSON.parse(channel.sent[1]); |
| 255 | channel.onmessage!({ |
| 256 | data: JSON.stringify({ |
| 257 | event: 'ack', |
| 258 | command_id: command.command_id, |
| 259 | result: 0, |
| 260 | led: [0, 10, 32], |
| 261 | paused: false, |
| 262 | }), |
| 263 | }); |
| 264 | assert.equal(session.getSnapshot().ack.state, 'confirmed'); |
| 265 | assert.deepEqual(session.getSnapshot().led, [0, 10, 32]); |
| 266 | session.send({ action: 'pause' }); |
| 267 | const pause = JSON.parse(channel.sent.at(-1)!); |
| 268 | channel.onmessage!({ |
| 269 | data: JSON.stringify({ |
| 270 | event: 'ack', |
| 271 | command_id: pause.command_id, |
| 272 | result: 0, |
| 273 | led: [0, 10, 32], |
| 274 | paused: true, |
| 275 | }), |
| 276 | }); |
| 277 | assert.equal(playbackView(session.getSnapshot()).paused, true); |
| 278 | assert.equal(session.getSnapshot().telemetry, undefined); |
| 279 | assert.equal(session.getSnapshot().telemetryAt, 0); |
| 280 | await session.disconnect(); |
| 281 | channel.onmessage!({ |
| 282 | data: JSON.stringify({ |
| 283 | event: 'ack', |
| 284 | command_id: command.command_id, |
| 285 | result: 0, |
| 286 | led: [32, 0, 0], |
| 287 | paused: false, |
| 288 | }), |
| 289 | }); |
| 290 | assert.equal(session.getSnapshot().led, undefined); |
| 291 | } finally { |
| 292 | stop(); |
| 293 | } |
| 294 | }); |
| 295 | |
| 296 | test('heartbeat recovery clears its own error without hiding an interaction failure', async (t) => { |
| 297 | t.mock.timers.enable({ apis: ['setInterval'] }); |
| 298 | Object.defineProperty(globalThis, 'window', { value: new EventTarget(), configurable: true }); |
| 299 | Object.defineProperty(globalThis, 'document', { value: { hidden: false }, configurable: true }); |
| 300 | Object.defineProperty(globalThis, 'RTCPeerConnection', { value: Peer, configurable: true }); |
| 301 | t.after(() => { |
| 302 | for (const key of ['window', 'document', 'RTCPeerConnection']) |
| 303 | Reflect.deleteProperty(globalThis, key); |
| 304 | }); |
| 305 | let fail = true; |
| 306 | const session = new RadioSession( |
| 307 | stubApi({ |
| 308 | claimControl: async () => { |
| 309 | throw new Error('Lease request failed'); |
| 310 | }, |
| 311 | heartbeatViewer: async () => { |
| 312 | if (fail) throw new Error('offline'); |
| 313 | return { controller: null }; |
| 314 | }, |
| 315 | }), |
| 316 | ); |
| 317 | const stop = session.start({ |
| 318 | pause() {}, |
| 319 | volume: 0, |
| 320 | srcObject: null, |
| 321 | } as unknown as HTMLAudioElement); |
| 322 | try { |
| 323 | await session.connect(); |
| 324 | await session.toggleControl(); |
| 325 | t.mock.timers.tick(5000); |
| 326 | await new Promise<void>((resolve) => setImmediate(resolve)); |
| 327 | assert.match(session.getSnapshot().issues.heartbeat!, /renewal failed/); |
| 328 | fail = false; |
| 329 | t.mock.timers.tick(5000); |
| 330 | await new Promise<void>((resolve) => setImmediate(resolve)); |
| 331 | assert.equal(session.getSnapshot().issues.heartbeat, undefined); |
| 332 | assert.equal(session.getSnapshot().issues.control, 'Lease request failed'); |
| 333 | } finally { |
| 334 | stop(); |
| 335 | } |
| 336 | }); |