File
Blob: worker/tests/live.mjs
| 1 | // Explicit hardware/SFU test. Keep capabilities, passwords, and SDP out of output. |
| 2 | import assert from 'node:assert/strict'; |
| 3 | import fs from 'node:fs/promises'; |
| 4 | import { parseEnv } from 'node:util'; |
| 5 | import { chromium } from 'playwright-core'; |
| 6 | |
| 7 | const url = process.env.RADIO_TEST_URL ?? 'http://localhost:11880'; |
| 8 | const { VIEWER_PASSWORD: password } = parseEnv( |
| 9 | await fs.readFile(new URL('../../.credential.env', import.meta.url), 'utf8'), |
| 10 | ); |
| 11 | assert.ok(password, 'Set VIEWER_PASSWORD in .credential.env before running the live test.'); |
| 12 | const browser = await chromium.connectOverCDP( |
| 13 | process.env.RADIO_CDP_URL ?? 'http://127.0.0.1:19223', |
| 14 | ); |
| 15 | const contexts = [], |
| 16 | pages = [], |
| 17 | errors = []; |
| 18 | const result = { receivers: [] }; |
| 19 | let staticTrackRequests = 0; |
| 20 | const output = new URL('../../artifacts/worker/', import.meta.url); |
| 21 | await fs.mkdir(output, { recursive: true }); |
| 22 | try { |
| 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 | } |