Skip to content
File

Blob: worker/tests/states.mjs

javascript232 lines
1// Actual App rendering with a fixture at its public React/session boundary.
2import assert from 'node:assert/strict';
3import fs from 'node:fs/promises';
4import { chromium } from 'playwright-core';
5const output = new URL('../../artifacts/worker/', import.meta.url);
6await fs.mkdir(output, { recursive: true });
7const browser = await chromium.connectOverCDP(
8 process.env.RADIO_CDP_URL ?? 'http://127.0.0.1:19223',
9);
10const results = [];
11const track = { title: 'Morning Radio', artist: 'Example Artist', durationMs: 240000 };
12const nowPlaying = { revision: 1, trackIndex: 0, trackCount: 4, track };
13const room = {
14 online: true,
15 viewers: 1,
16 controller: null,
17 generation: 'fixture',
18 track,
19 nowPlaying,
20};
21const 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};
47const 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};
55const near = (a, b, label) => assert.ok(Math.abs(a - b) < 0.6, `${label}: ${a} → ${b}`);
56try {
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}
231console.log(JSON.stringify(results, null, 2));