Skip to content
File

Blob: worker/src/client/radio/session.test.ts

typescript337 lines
1import assert from 'node:assert/strict';
2import { test } from 'node:test';
3import type { RadioApi } from './api.ts';
4import type { Joined, RoomStatus } from '@/shared/contracts/signaling.ts';
5import { RadioSession } from './session.ts';
6import { playbackView } from './playback.ts';
7 
8const joined: Joined = {
9 id: 'viewer',
10 viewerToken: 'capability',
11 generation: 'generation',
12 sessionDescription: { type: 'offer', sdp: 'v=0' },
13};
14const status: RoomStatus = {
15 online: true,
16 viewers: 1,
17 controller: null,
18 generation: joined.generation,
19};
20 
21function 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 
42test('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 
60test('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 
98class 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}
112class 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 
139test('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 
176test('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 
229test('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 
296test('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});