File
Blob: worker/tests/states.mjs
| 1 | // Actual App rendering with a fixture at its public React/session boundary. |
| 2 | import assert from 'node:assert/strict'; |
| 3 | import fs from 'node:fs/promises'; |
| 4 | import { chromium } from 'playwright-core'; |
| 5 | const output = new URL('../../artifacts/worker/', import.meta.url); |
| 6 | await fs.mkdir(output, { recursive: true }); |
| 7 | const browser = await chromium.connectOverCDP( |
| 8 | process.env.RADIO_CDP_URL ?? 'http://127.0.0.1:19223', |
| 9 | ); |
| 10 | const results = []; |
| 11 | const track = { title: 'Morning Radio', artist: 'Example Artist', durationMs: 240000 }; |
| 12 | const nowPlaying = { revision: 1, trackIndex: 0, trackCount: 4, track }; |
| 13 | const room = { |
| 14 | online: true, |
| 15 | viewers: 1, |
| 16 | controller: null, |
| 17 | generation: 'fixture', |
| 18 | track, |
| 19 | nowPlaying, |
| 20 | }; |
| 21 | const telemetry = { |
| 22 | event: 'telemetry', |
| 23 | firmware: 'fixture', |
| 24 | sequence: 1, |
| 25 | uptimeMs: 20000, |
| 26 | positionMs: 30000, |
| 27 | durationMs: 240000, |
| 28 | playbackRevision: 1, |
| 29 | paused: false, |
| 30 | rssi: -62, |
| 31 | heap: 188000, |
| 32 | hardware: { |
| 33 | sampledAtMs: 19000, |
| 34 | cpuBusyBps: [2200, 5100], |
| 35 | chipTemperatureMc: 48000, |
| 36 | internal: { free: 188000, minimumFree: 170000, largestBlock: 120000 }, |
| 37 | psram: { free: 14500000, minimumFree: 14000000, largestBlock: 13800000 }, |
| 38 | sampleCostUs: 220, |
| 39 | samplerStackFree: 5000, |
| 40 | }, |
| 41 | stackFree: 8000, |
| 42 | led: [0, 24, 5], |
| 43 | audioErrors: 0, |
| 44 | dataErrors: 0, |
| 45 | skippedFrames: 0, |
| 46 | }; |
| 47 | const selectors = { |
| 48 | audio: '[data-anchor="leaf-audio"]', |
| 49 | spectrum: '[data-anchor="leaf-spectrum"]', |
| 50 | controls: '[data-anchor="leaf-robot"]', |
| 51 | connect: '#connect', |
| 52 | sound: '[aria-label="Mute"], [aria-label="Unmute"], [aria-label="Enable sound"]', |
| 53 | volume: '[aria-label="Listening volume"]', |
| 54 | }; |
| 55 | const near = (a, b, label) => assert.ok(Math.abs(a - b) < 0.6, `${label}: ${a} → ${b}`); |
| 56 | try { |
| 57 | for (const width of [320, 390, 768, 1024, 1380]) { |
| 58 | const context = await browser.newContext({ |
| 59 | viewport: { width, height: 900 }, |
| 60 | reducedMotion: 'reduce', |
| 61 | }); |
| 62 | try { |
| 63 | await context.route('**/api/**', (route) => route.abort()); |
| 64 | await context.route('**/src/client/radio/use-radio.ts*', (route) => |
| 65 | route.fulfill({ |
| 66 | contentType: 'application/javascript', |
| 67 | body: 'export { useFixtureRadio as useRadio } from "/tests/fixtures/radio.tsx";', |
| 68 | }), |
| 69 | ); |
| 70 | const page = await context.newPage(), |
| 71 | errors = []; |
| 72 | page.on('pageerror', (error) => errors.push(error.message)); |
| 73 | await page.goto(process.env.RADIO_TEST_URL ?? 'http://localhost:11880'); |
| 74 | await page.getByTestId('track-title').waitFor(); |
| 75 | await page.evaluate(() => document.fonts.ready); |
| 76 | const measure = async () => |
| 77 | page.evaluate( |
| 78 | (selectors) => ({ |
| 79 | overflow: document.documentElement.scrollWidth > innerWidth, |
| 80 | boxes: Object.fromEntries( |
| 81 | Object.entries(selectors).map(([key, selector]) => [ |
| 82 | key, |
| 83 | (() => { |
| 84 | const box = document.querySelector(selector).getBoundingClientRect(); |
| 85 | return { ...box.toJSON(), x: box.x + scrollX, y: box.y + scrollY }; |
| 86 | })(), |
| 87 | ]), |
| 88 | ), |
| 89 | }), |
| 90 | selectors, |
| 91 | ); |
| 92 | const patch = async (data) => { |
| 93 | await page.evaluate((data) => window.exhibitFixture.patch(data), data); |
| 94 | await page.evaluate( |
| 95 | () => |
| 96 | new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), |
| 97 | ); |
| 98 | }; |
| 99 | const idle = await measure(); |
| 100 | assert.equal(idle.overflow, false); |
| 101 | await page.locator('#connect').click(); |
| 102 | const connecting = await measure(); |
| 103 | assert.equal(connecting.overflow, false); |
| 104 | await patch({ |
| 105 | phase: 'connected', |
| 106 | viewerId: 'listener', |
| 107 | telemetry, |
| 108 | paused: false, |
| 109 | led: telemetry.led, |
| 110 | now: 10000, |
| 111 | telemetryAt: 10000, |
| 112 | audio: { packets: 1, bytes: 1000, kbps: 96, lost: 0, at: 10000 }, |
| 113 | message: 'Connected to the board.', |
| 114 | }); |
| 115 | const connected = await measure(); |
| 116 | for (const next of [connecting, connected]) { |
| 117 | for (const key of ['audio', 'spectrum', 'controls']) { |
| 118 | near(idle.boxes[key].y, next.boxes[key].y, `${width} ${key} connection y`); |
| 119 | near(idle.boxes[key].height, next.boxes[key].height, `${width} ${key} connection height`); |
| 120 | } |
| 121 | } |
| 122 | for (const next of [connecting, connected]) |
| 123 | for (const key of ['connect', 'sound', 'volume']) { |
| 124 | near(idle.boxes[key].x, next.boxes[key].x, `${width} ${key} x`); |
| 125 | near(idle.boxes[key].width, next.boxes[key].width, `${width} ${key} width`); |
| 126 | } |
| 127 | await page.locator('#claim').focus(); |
| 128 | await page.keyboard.press('Enter'); |
| 129 | assert.equal( |
| 130 | await page.locator('#claim').evaluate((el) => el === document.activeElement), |
| 131 | true, |
| 132 | ); |
| 133 | const calls = await page.evaluate(() => window.exhibitFixture.calls().length); |
| 134 | await page.keyboard.press('Enter'); |
| 135 | assert.equal(await page.evaluate(() => window.exhibitFixture.calls().length), calls); |
| 136 | await patch({ busy: false, room: { ...room, controller: 'listener' } }); |
| 137 | for (const selector of ['#pause', 'button[data-rgb="0,24,5"]']) { |
| 138 | await patch({ ack: { state: 'idle', text: '', at: 0 } }); |
| 139 | await page.locator(selector).focus(); |
| 140 | await page.keyboard.press('Enter'); |
| 141 | assert.equal( |
| 142 | await page.locator(selector).evaluate((el) => el === document.activeElement), |
| 143 | true, |
| 144 | ); |
| 145 | const count = await page.evaluate(() => window.exhibitFixture.calls().length); |
| 146 | await page.keyboard.press('Enter'); |
| 147 | assert.equal(await page.evaluate(() => window.exhibitFixture.calls().length), count); |
| 148 | await patch({ |
| 149 | ack: { |
| 150 | state: 'confirmed', |
| 151 | text: 'Board confirmed · 180 ms round trip', |
| 152 | at: 10000, |
| 153 | rtt: 180, |
| 154 | }, |
| 155 | }); |
| 156 | assert.equal( |
| 157 | await page.locator(selector).evaluate((el) => el === document.activeElement), |
| 158 | true, |
| 159 | ); |
| 160 | } |
| 161 | const baseline = await measure(); |
| 162 | await patch({ audioBlocked: true }); |
| 163 | const blocked = await measure(); |
| 164 | near(baseline.boxes.audio.height, blocked.boxes.audio.height, `${width} sound recovery`); |
| 165 | for (const changed of [ |
| 166 | { ...track, artist: '' }, |
| 167 | { |
| 168 | title: |
| 169 | 'A long song title with extra edition notes (Live From The Festival, Extended Version)', |
| 170 | artist: 'An Example Ensemble Featuring A Guest Vocalist', |
| 171 | durationMs: 240000, |
| 172 | }, |
| 173 | { title: 'A'.repeat(256), artist: 'B'.repeat(256), durationMs: 240000 }, |
| 174 | ]) { |
| 175 | await patch({ |
| 176 | audioBlocked: false, |
| 177 | nowPlaying: { ...nowPlaying, revision: 2, track: changed }, |
| 178 | }); |
| 179 | const metadata = await measure(); |
| 180 | assert.equal(metadata.overflow, false); |
| 181 | near(baseline.boxes.audio.height, metadata.boxes.audio.height, `${width} metadata height`); |
| 182 | await page |
| 183 | .getByRole('button', { name: `Track information: ${changed.title}`, exact: true }) |
| 184 | .click(); |
| 185 | const dialog = page.getByRole('dialog'); |
| 186 | await dialog.waitFor(); |
| 187 | assert.equal(await dialog.getByRole('heading').textContent(), changed.title); |
| 188 | const box = await dialog.boundingBox(); |
| 189 | assert.ok(box.x >= 0 && box.x + box.width <= width); |
| 190 | await page.keyboard.press('Escape'); |
| 191 | assert.equal( |
| 192 | await page |
| 193 | .getByRole('button', { name: `Track information: ${changed.title}`, exact: true }) |
| 194 | .evaluate((el) => el === document.activeElement), |
| 195 | true, |
| 196 | ); |
| 197 | } |
| 198 | await patch({ nowPlaying, room, audioBlocked: false }); |
| 199 | await page.locator('#connect').click(); |
| 200 | const disconnected = await measure(); |
| 201 | near(idle.boxes.connect.width, disconnected.boxes.connect.width, `${width} disconnect width`); |
| 202 | assert.equal( |
| 203 | await page.locator('#connect').evaluate((el) => el === document.activeElement), |
| 204 | true, |
| 205 | ); |
| 206 | assert.deepEqual(errors, []); |
| 207 | if (width === 390 || width === 1380) |
| 208 | await page.screenshot({ |
| 209 | path: new URL(`states-${width}.png`, output).pathname, |
| 210 | fullPage: true, |
| 211 | }); |
| 212 | results.push({ |
| 213 | width, |
| 214 | passed: true, |
| 215 | buttonWidth: connected.boxes.connect.width, |
| 216 | metadataHeight: baseline.boxes.audio.height, |
| 217 | overflow: false, |
| 218 | focusPreserved: true, |
| 219 | }); |
| 220 | } finally { |
| 221 | await context.close(); |
| 222 | } |
| 223 | } |
| 224 | } finally { |
| 225 | await browser.close(); |
| 226 | await fs.writeFile( |
| 227 | new URL('states-result.json', output), |
| 228 | JSON.stringify(results, null, 2) + '\n', |
| 229 | ); |
| 230 | } |
| 231 | console.log(JSON.stringify(results, null, 2)); |