File
Blob: worker/tests/layout.mjs
| 1 | // Visual/accessibility states with an explicitly offline board fixture. |
| 2 | import assert from 'node:assert/strict'; |
| 3 | import fs from 'node:fs/promises'; |
| 4 | import { chromium } from 'playwright-core'; |
| 5 | await fs.mkdir('../artifacts/worker', { recursive: true }); |
| 6 | const browser = await chromium.connectOverCDP( |
| 7 | process.env.RADIO_CDP_URL ?? 'http://127.0.0.1:19223', |
| 8 | ); |
| 9 | const contexts = [], |
| 10 | result = { viewports: [] }, |
| 11 | errors = []; |
| 12 | try { |
| 13 | for (const width of [320, 390, 768, 1024, 1380]) { |
| 14 | const context = await browser.newContext({ |
| 15 | viewport: { width, height: 900 }, |
| 16 | reducedMotion: 'reduce', |
| 17 | }); |
| 18 | contexts.push(context); |
| 19 | await context.route('**/api/status', (route) => |
| 20 | route.fulfill({ json: { online: false, viewers: 0, controller: null, generation: null } }), |
| 21 | ); |
| 22 | const page = await context.newPage(); |
| 23 | page.on('pageerror', (e) => errors.push(e.message)); |
| 24 | await page.goto(process.env.RADIO_TEST_URL ?? 'http://localhost:11880'); |
| 25 | await page.getByText('Board offline.', { exact: false }).waitFor(); |
| 26 | assert.equal(await page.locator('#connect').isDisabled(), true); |
| 27 | assert.equal(await page.getByTestId('chip-temperature').textContent(), '–– °C'); |
| 28 | await page.getByRole('button', { name: 'Spectrum', exact: true }).click(); |
| 29 | await page.getByRole('heading', { name: 'Live spectrum' }).waitFor(); |
| 30 | await page.evaluate( |
| 31 | () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), |
| 32 | ); |
| 33 | const metrics = await page.evaluate(() => ({ |
| 34 | width: innerWidth, |
| 35 | overflow: document.documentElement.scrollWidth > innerWidth, |
| 36 | allButtonWidths: [...document.querySelectorAll('button')] |
| 37 | .filter((x) => x.checkVisibility()) |
| 38 | .map((x) => Math.round(x.getBoundingClientRect().width)), |
| 39 | motion: [...document.querySelectorAll('.wire-flow')].map( |
| 40 | (x) => getComputedStyle(x).animationName, |
| 41 | ), |
| 42 | wires: [...document.querySelectorAll('.wire-track')].map((path) => { |
| 43 | const [branch, segment] = path.parentElement.dataset.wire.split('-'); |
| 44 | const mobile = branch === 'wifi'; |
| 45 | const startAnchor = mobile |
| 46 | ? 'source-wifi' |
| 47 | : segment === 'source' |
| 48 | ? `source-${branch}` |
| 49 | : `sfu-${branch}`; |
| 50 | const endAnchor = mobile |
| 51 | ? 'sfu-input' |
| 52 | : segment === 'source' |
| 53 | ? `sfu-${branch}` |
| 54 | : `leaf-${branch}`; |
| 55 | const start = path.getPointAtLength(0).matrixTransform(path.getScreenCTM()); |
| 56 | const end = path |
| 57 | .getPointAtLength(path.getTotalLength()) |
| 58 | .matrixTransform(path.getScreenCTM()); |
| 59 | const source = document |
| 60 | .querySelector(`[data-anchor="${startAnchor}"]`) |
| 61 | .getBoundingClientRect(); |
| 62 | const target = document |
| 63 | .querySelector(`[data-anchor="${endAnchor}"]`) |
| 64 | .getBoundingClientRect(); |
| 65 | return { |
| 66 | branch, |
| 67 | segment, |
| 68 | startDistance: Math.hypot( |
| 69 | start.x - source.right, |
| 70 | start.y - (source.top + source.height / 2), |
| 71 | ), |
| 72 | endDistance: Math.hypot( |
| 73 | end.x - (mobile ? target.left + target.width / 2 : target.left), |
| 74 | end.y - (mobile ? target.top : target.top + target.height / 2), |
| 75 | ), |
| 76 | }; |
| 77 | }), |
| 78 | })); |
| 79 | assert.equal(metrics.overflow, false); |
| 80 | assert.ok(metrics.motion.every((x) => x === 'none')); |
| 81 | assert.equal(metrics.wires.length, width >= 1024 ? 6 : 1); |
| 82 | assert.ok(metrics.wires.every((wire) => wire.startDistance < 0.75 && wire.endDistance < 0.75)); |
| 83 | assert.equal( |
| 84 | metrics.wires.filter((wire) => wire.branch === 'robot').length, |
| 85 | width >= 1024 ? 2 : 0, |
| 86 | ); |
| 87 | result.viewports.push({ width, overflow: metrics.overflow, wiresAligned: true }); |
| 88 | await page.getByRole('button', { name: 'Close signal explanation' }).click(); |
| 89 | await page.evaluate(() => window.scrollTo(0, 0)); |
| 90 | await page.keyboard.press('Control+Home'); |
| 91 | await page.locator('body').click({ position: { x: 5, y: 5 } }); |
| 92 | await page.keyboard.press('Tab'); |
| 93 | assert.equal( |
| 94 | await page.evaluate(() => document.activeElement?.textContent), |
| 95 | 'Skip to the exhibit', |
| 96 | ); |
| 97 | await page.keyboard.press('Enter'); |
| 98 | assert.equal(new URL(page.url()).hash, '#exhibit'); |
| 99 | result.keyboard = true; |
| 100 | if (width === 390) |
| 101 | await page.screenshot({ path: '../artifacts/worker/mobile-offline.png', fullPage: true }); |
| 102 | } |
| 103 | assert.deepEqual(errors, []); |
| 104 | result.passed = true; |
| 105 | } catch (error) { |
| 106 | result.passed = false; |
| 107 | result.error = error.message; |
| 108 | process.exitCode = 1; |
| 109 | } finally { |
| 110 | result.errors = errors; |
| 111 | await fs.writeFile( |
| 112 | '../artifacts/worker/layout-result.json', |
| 113 | JSON.stringify(result, null, 2) + '\n', |
| 114 | ); |
| 115 | console.log(JSON.stringify(result)); |
| 116 | for (const c of contexts) await c.close(); |
| 117 | await browser.close(); |
| 118 | } |