Skip to content
File

Blob: worker/tests/live.mjs

javascript301 lines
1// Explicit hardware/SFU test. Keep capabilities, passwords, and SDP out of output.
2import assert from 'node:assert/strict';
3import fs from 'node:fs/promises';
4import { parseEnv } from 'node:util';
5import { chromium } from 'playwright-core';
6 
7const url = process.env.RADIO_TEST_URL ?? 'http://localhost:11880';
8const { VIEWER_PASSWORD: password } = parseEnv(
9 await fs.readFile(new URL('../../.credential.env', import.meta.url), 'utf8'),
10);
11assert.ok(password, 'Set VIEWER_PASSWORD in .credential.env before running the live test.');
12const browser = await chromium.connectOverCDP(
13 process.env.RADIO_CDP_URL ?? 'http://127.0.0.1:19223',
14);
15const contexts = [],
16 pages = [],
17 errors = [];
18const result = { receivers: [] };
19let staticTrackRequests = 0;
20const output = new URL('../../artifacts/worker/', import.meta.url);
21await fs.mkdir(output, { recursive: true });
22try {
23 for (const width of [1380, 390]) {
24 const context = await browser.newContext({
25 viewport: { width, height: width < 600 ? 844 : 1100 },
26 isMobile: width < 600,
27 hasTouch: width < 600,
28 });
29 contexts.push(context);
30 await context.addInitScript(() => {
31 window.__peers = [];
32 const Original = window.RTCPeerConnection;
33 window.RTCPeerConnection = class extends Original {
34 constructor(...args) {
35 super(...args);
36 this.__channels = [];
37 window.__peers.push(this);
38 }
39 createDataChannel(...args) {
40 const channel = super.createDataChannel(...args);
41 this.__channels.push(channel);
42 channel.addEventListener('message', (event) => {
43 if (channel.label === 'robot') {
44 try {
45 const value = JSON.parse(event.data);
46 if (value.event === 'telemetry') this.__telemetry = value;
47 if (value.event === 'nowPlaying') this.__nowPlaying = value.nowPlaying;
48 } catch {}
49 }
50 if (
51 channel.label === 'spectrum' &&
52 event.data instanceof ArrayBuffer &&
53 (event.data.byteLength === 44 || event.data.byteLength === 48)
54 ) {
55 const view = new DataView(event.data);
56 this.__spectrum = {
57 pts: view.getUint32(4, true),
58 position: view.getUint32(8, true),
59 paused: !!view.getUint8(1),
60 bands: Array.from(new Uint8Array(event.data, 12, 32)),
61 revision: event.data.byteLength === 48 ? view.getUint32(44, true) : undefined,
62 };
63 }
64 });
65 return channel;
66 }
67 };
68 });
69 const page = await context.newPage();
70 pages.push(page);
71 page.on('pageerror', (error) => errors.push(error.message));
72 page.on('request', (request) => {
73 if (new URL(request.url()).pathname === '/track.json') staticTrackRequests++;
74 });
75 await page.goto(url);
76 await page.waitForFunction(
77 () =>
78 document.querySelector('input[name="password"]') ||
79 !document.querySelector('#connect')?.disabled,
80 );
81 if (await page.getByLabel('Viewer password', { exact: true }).count()) {
82 await page.getByLabel('Viewer password', { exact: true }).fill(password);
83 await page.getByRole('button', { name: 'Unlock exhibit' }).click();
84 }
85 await page.locator('#connect').waitFor();
86 await page.waitForFunction(() => !document.querySelector('#connect').disabled);
87 if (process.env.RADIO_EXPECT_TRACK) {
88 await page.waitForFunction(
89 (title) => document.querySelector('[data-testid="track-title"]')?.textContent === title,
90 process.env.RADIO_EXPECT_TRACK,
91 { timeout: 15000 },
92 );
93 result.metadataBeforeListening = true;
94 }
95 await page.locator('#connect').click();
96 await page.waitForFunction(
97 () => document.querySelector('#connect').textContent.includes('Disconnect'),
98 null,
99 { timeout: 60000 },
100 );
101 await page.waitForFunction(
102 () => window.__peers.at(-1)?.__spectrum?.bands.some((x) => x > 0),
103 null,
104 { timeout: 15000 },
105 );
106 }
107 await pages[0].waitForTimeout(3500);
108 for (const page of pages) {
109 await page.waitForFunction(
110 () => {
111 const h = window.__peers.at(-1).__telemetry?.hardware;
112 return h && h.cpuBusyBps.every((value) => value !== null) && h.chipTemperatureMc !== null;
113 },
114 undefined,
115 { timeout: 15000 },
116 );
117 const sample = await page.evaluate(async () => {
118 const peer = window.__peers.at(-1),
119 report = await peer.getStats();
120 let inbound, codec;
121 report.forEach((stat) => {
122 if (stat.type === 'inbound-rtp' && stat.kind === 'audio') {
123 inbound = stat;
124 codec = report.get(stat.codecId);
125 }
126 });
127 return {
128 connection: peer.connectionState,
129 packets: inbound?.packetsReceived,
130 energy: inbound?.totalAudioEnergy,
131 codec: codec?.mimeType,
132 firmware: peer.__telemetry?.firmware,
133 music: peer.__telemetry?.music,
134 hardware: peer.__telemetry?.hardware,
135 uptimeMs: peer.__telemetry?.uptimeMs,
136 hasRandom: Object.hasOwn(peer.__telemetry, 'random'),
137 nowPlaying: peer.__nowPlaying,
138 peerCount: window.__peers.length,
139 source: peer.__telemetry?.spectrum?.source,
140 core: peer.__telemetry?.spectrum?.core,
141 audioTime: document.querySelector('audio').currentTime,
142 overflow: document.documentElement.scrollWidth > innerWidth,
143 wires: document.querySelectorAll('.wire-track').length,
144 liveWires: document.querySelectorAll('.wire-flow').length,
145 trackTitle: document.querySelector('[data-testid="track-title"]').textContent,
146 };
147 });
148 result.receivers.push(sample);
149 assert.equal(sample.connection, 'connected');
150 assert.ok(sample.packets > 100);
151 assert.ok(sample.energy > 0);
152 assert.equal(sample.codec.toLowerCase(), 'audio/opus');
153 assert.equal(sample.firmware, 'rust');
154 assert.equal(sample.hasRandom, false);
155 assert.ok(
156 sample.hardware.cpuBusyBps.every(
157 (value) => Number.isInteger(value) && value >= 0 && value <= 10000,
158 ),
159 );
160 assert.ok(
161 sample.hardware.chipTemperatureMc >= -10000 && sample.hardware.chipTemperatureMc <= 80000,
162 );
163 assert.ok(sample.hardware.internal.free > 0);
164 assert.ok(sample.hardware.psram.free > 0 && sample.hardware.psram.free <= 16 * 1024 * 1024);
165 assert.ok(sample.uptimeMs - sample.hardware.sampledAtMs <= 3000);
166 assert.equal(sample.source, 'fft');
167 assert.equal(sample.core, 1);
168 assert.ok(sample.audioTime > 1);
169 assert.equal(sample.overflow, false);
170 }
171 assert.equal(result.receivers[0].wires, 6);
172 assert.ok(result.receivers[0].liveWires >= 6);
173 await pages[0].getByRole('button', { name: 'Spectrum', exact: true }).click();
174 await pages[0].getByRole('heading', { name: 'Live spectrum' }).waitFor();
175 await pages[0].getByRole('button', { name: 'Close signal explanation' }).click();
176 result.pathInspection = true;
177 await pages[0].getByRole('button', { name: 'Mute', exact: true }).click();
178 assert.equal(await pages[0].locator('audio').evaluate((el) => el.muted), true);
179 assert.equal(await pages[1].locator('audio').evaluate((el) => el.muted), false);
180 await pages[0].getByRole('button', { name: 'Unmute', exact: true }).click();
181 result.personalAudio = true;
182 if (process.env.RADIO_EXPECT_AUTO_ADVANCE) {
183 const before = await pages[0].evaluate(() => ({
184 revision: window.__peers.at(-1).__nowPlaying.revision,
185 pts: window.__peers.at(-1).__spectrum.pts,
186 }));
187 await pages[0].waitForFunction(
188 (revision) => window.__peers.at(-1).__nowPlaying.revision !== revision,
189 before.revision,
190 { timeout: 45000 },
191 );
192 await pages[0].waitForFunction(
193 () =>
194 window.__peers.at(-1).__spectrum?.revision === window.__peers.at(-1).__nowPlaying.revision,
195 );
196 assert.ok(
197 await pages[0].evaluate(
198 (pts) => window.__peers.length === 1 && window.__peers.at(-1).__spectrum.pts > pts,
199 before.pts,
200 ),
201 );
202 result.automaticAdvanceWithoutReconnect = true;
203 }
204 
205 await pages[0].locator('#claim').click();
206 await pages[0].getByRole('button', { name: 'Set LED to blue' }).click();
207 await pages[0].waitForFunction(() =>
208 document.querySelector('[data-testid="ack"]').textContent.includes('Board confirmed'),
209 );
210 await pages[1].waitForFunction(
211 () =>
212 document.querySelector('[data-testid="board-led"]').getAttribute('data-rgb') === '0,10,32',
213 );
214 assert.equal(await pages[1].getByRole('button', { name: 'Set LED to coral' }).isDisabled(), true);
215 result.ledAndSpectator = true;
216 await pages[0].locator('#pause').click();
217 await pages[0].waitForFunction(
218 () =>
219 window.__peers.at(-1).__spectrum?.paused &&
220 window.__peers.at(-1).__spectrum.bands.every((x) => x === 0),
221 );
222 
223 if (process.env.RADIO_EXPECT_PLAYLIST) {
224 const before = await pages[0].evaluate(() => ({
225 ...window.__peers.at(-1).__nowPlaying,
226 pts: window.__peers.at(-1).__spectrum.pts,
227 }));
228 assert.ok(before.trackCount > 1);
229 assert.equal(await pages[1].locator('#next-track').isDisabled(), true);
230 await pages[0].locator('#next-track').click();
231 for (const page of pages) {
232 await page.waitForFunction(
233 (expected) =>
234 window.__peers.at(-1).__nowPlaying.trackIndex === expected &&
235 window.__peers.at(-1).__spectrum?.revision ===
236 window.__peers.at(-1).__nowPlaying.revision,
237 (before.trackIndex + 1) % before.trackCount,
238 );
239 assert.equal(
240 await page.getByTestId('track-title').textContent(),
241 await page.evaluate(() => window.__peers.at(-1).__nowPlaying.track?.title ?? 'Music'),
242 );
243 assert.equal(await page.evaluate(() => window.__peers.length), 1);
244 assert.ok(
245 await page.evaluate((pts) => window.__peers.at(-1).__spectrum.pts > pts, before.pts),
246 );
247 }
248 result.nextTrackWithoutReconnect = true;
249 }
250 await pages[0].getByRole('button', { name: 'Resume track' }).click();
251 await pages[0].waitForFunction(
252 () =>
253 !window.__peers.at(-1).__spectrum?.paused &&
254 window.__peers.at(-1).__spectrum.bands.some((x) => x > 0),
255 );
256 await pages[0].locator('#restart').click();
257 await pages[0].waitForFunction(() => window.__peers.at(-1).__spectrum.position < 1000);
258 result.pauseResumeRestart = true;
259 await pages[0].locator('#claim').click();
260 await pages[1].waitForFunction(() => !document.querySelector('#claim').disabled);
261 await pages[1].locator('#claim').click();
262 await pages[1].getByRole('button', { name: 'Set LED to green' }).click();
263 await pages[1].waitForFunction(() =>
264 document.querySelector('[data-testid="ack"]').textContent.includes('Board confirmed'),
265 );
266 result.mobileHandoff = true;
267 for (let i = 0; i < pages.length; i++) {
268 await pages[i].screenshot({
269 path: new URL(i ? 'mobile-live.png' : 'desktop-live.png', output).pathname,
270 fullPage: true,
271 });
272 }
273 await pages[1].locator('#claim').click();
274 for (const page of pages) await page.locator('#connect').click();
275 result.passed = true;
276 assert.equal(staticTrackRequests, 0);
277 result.noStaticTrackManifest = true;
278 assert.deepEqual(errors, []);
279} catch (error) {
280 result.passed = false;
281 result.error = error.message;
282 process.exitCode = 1;
283 for (let i = 0; i < pages.length; i++) {
284 result[`page${i}`] = (
285 await pages[i]
286 .locator('body')
287 .innerText()
288 .catch(() => '')
289 ).slice(-1800);
290 await pages[i]
291 .screenshot({ path: new URL(`failure-${i}.png`, output).pathname, fullPage: true })
292 .catch(() => {});
293 }
294} finally {
295 result.errors = errors;
296 await fs.writeFile(new URL('live-result.json', output), JSON.stringify(result, null, 2) + '\n');
297 console.log(JSON.stringify(result, null, 2));
298 for (const context of contexts) await context.close();
299 await browser.close();
300}