oxedyne/daimond/dev/verify_consolenav.mjs
15.2 KiB, 1 run
created by r2519314175:311, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // verify_consolenav.mjs -- the operator console's section rail. |
| 2 | // |
| 3 | // The console was one column of nine cards and the reader scrolled past eight |
| 4 | // of them to reach the ninth. It is now a vertical tab set. This proves the |
| 5 | // properties that makes true, rather than that some buttons exist: |
| 6 | // |
| 7 | // * exactly one section is shown at any moment, and it is the chosen one; |
| 8 | // * the rail's aria-selected agrees with what is displayed, always -- the |
| 9 | // screen reader and the eye must not be told different things; |
| 10 | // * the fragment names the section, so an operator can send someone to one, |
| 11 | // and a reload lands back on it; |
| 12 | // * arrow keys walk the rail, because it is marked up as a tablist and a |
| 13 | // tablist that does not is a lie; |
| 14 | // * an account with no grant has no Operators tab, AND a link to #operators |
| 15 | // falls back rather than selecting a tab that is not there. |
| 16 | // |
| 17 | // The gateway is not needed: every /api/admin view is answered from a stub, so |
| 18 | // this tests the page and nothing else. Run against any world's dev server. |
| 19 | // |
| 20 | // node dev/verify_consolenav.mjs # all checks |
| 21 | // node dev/verify_consolenav.mjs --break one # showView stops hiding others |
| 22 | // node dev/verify_consolenav.mjs --break aria # aria-selected left behind |
| 23 | // node dev/verify_consolenav.mjs --break hash # the fragment is not written |
| 24 | // node dev/verify_consolenav.mjs --break grant # the Operators tab always drawn |
| 25 | // node dev/verify_consolenav.mjs --break head # the sticky offset goes back to |
| 26 | // a hard-coded 53px |
| 27 | // |
| 28 | // Each --break is a defect the checks below are supposed to catch. If a break |
| 29 | // runs green, the check for it is worthless and should be rewritten. |
| 30 | |
| 31 | import os from 'node:os'; |
| 32 | import path from 'node:path'; |
| 33 | import { pathToFileURL } from 'node:url'; |
| 34 | |
| 35 | // Chromium's ozone platform is chosen by autodetection and prefers Wayland whenever |
| 36 | // `WAYLAND_DISPLAY` is set -- which it is in every rc session on argonaut -- so a headed |
| 37 | // run under `xvfb-run` still went to the compositor and opened a window on the owner's |
| 38 | // desktop. Importing this strips the two variables from `process.env`, which is all a |
| 39 | // launcher that spreads `process.env` needs. See dev/display.mjs. |
| 40 | import './display.mjs'; |
| 41 | const APP = process.env.DAIMOND_APP || `http://localhost:${process.env.DAIMOND_PORT || 8777}`; |
| 42 | const PW = process.env.DAIMOND_PW |
| 43 | || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 44 | const CHROME = process.env.DAIMOND_CHROME |
| 45 | || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`; |
| 46 | |
| 47 | const BREAK = (() => { |
| 48 | const i = process.argv.indexOf('--break'); |
| 49 | return i >= 0 ? (process.argv[i + 1] || 'one') : null; |
| 50 | })(); |
| 51 | |
| 52 | const ok = [], bad = []; |
| 53 | const check = (name, pass, detail) => { |
| 54 | (pass ? ok : bad).push(name); |
| 55 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' -- ' + detail : '')); |
| 56 | }; |
| 57 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 58 | |
| 59 | // The seven sections, in rail order. Named here rather than read off the page, |
| 60 | // so a section silently dropped from the markup fails a check instead of |
| 61 | // shrinking the test with it. |
| 62 | const VIEWS = ['overview', 'money', 'accounts', 'capacity', 'settings', 'releases', 'operators']; |
| 63 | |
| 64 | // ── The stub gateway ──────────────────────────────────────── |
| 65 | // Enough of each view for the page to render without throwing. The rail does |
| 66 | // not depend on any of it; what matters is that a failing card cannot be |
| 67 | // mistaken for a failing rail. |
| 68 | function stubFor(role) { |
| 69 | const grant = (role === 'owner'); |
| 70 | return { |
| 71 | whoami: { ok: true, account_id: 'acct_test', client_fp: '0000 0000 0000 0000', |
| 72 | role, can_grant: grant }, |
| 73 | summary: { ok: true, health: { store_ok: true, api: 1 }, accounts: 0, |
| 74 | revenue: [], credits_minor: 0, spend_minor: 0 }, |
| 75 | revenue: { ok: true, days: [] }, |
| 76 | consumption: { ok: true, days: [], services: [] }, |
| 77 | geo: { ok: true, rows: [] }, |
| 78 | accounts: { ok: true, rows: [], page: 0, pages: 1, total: 0 }, |
| 79 | ledger: { ok: true, rows: [], page: 0, pages: 1, total: 0 }, |
| 80 | releases: { ok: true, declared: [], planned: null, builds: [] }, |
| 81 | operators: { ok: true, rows: [] }, |
| 82 | capacity: { ok: true, storage: null, egress: null }, |
| 83 | settings: { ok: true, groups: [] }, |
| 84 | secrets: { ok: true, items: [] }, |
| 85 | }; |
| 86 | } |
| 87 | |
| 88 | async function openConsole(browser, role, hash) { |
| 89 | const page = await browser.newPage(); |
| 90 | const stub = stubFor(role); |
| 91 | await page.route('**/api/admin*', async route => { |
| 92 | const view = new URL(route.request().url()).searchParams.get('view'); |
| 93 | const body = stub[view] || { ok: true }; |
| 94 | await route.fulfill({ status: 200, contentType: 'application/json', |
| 95 | body: JSON.stringify(body) }); |
| 96 | }); |
| 97 | if (BREAK) await page.addInitScript(mode => { window.__navBreak = mode; }, BREAK); |
| 98 | await page.goto(APP + '/console/' + (hash || ''), { waitUntil: 'domcontentloaded' }); |
| 99 | await page.waitForSelector('#admin-app:not([hidden])', { timeout: 15000 }); |
| 100 | await sleep(400); |
| 101 | return page; |
| 102 | } |
| 103 | |
| 104 | /// Click a rail item, having first established that it could really be clicked. |
| 105 | /// |
| 106 | /// Every click in this tree is a force-click, because Playwright's "stable" |
| 107 | /// actionability check never settles here -- this browser produces no animation |
| 108 | /// frames, so the check waits for a second frame that never comes and hangs on |
| 109 | /// a button that is perfectly fine (see the same note throughout harness.mjs). |
| 110 | /// |
| 111 | /// A force-click skips exactly the checks that would have caught a tab which is |
| 112 | /// invisible or sitting under the sticky header, so those are asserted here |
| 113 | /// instead of being thrown away with them: real size, and the topmost element |
| 114 | /// at its own centre being the button itself. |
| 115 | async function clickTab(page, v) { |
| 116 | const sel = `#admin-nav .admin-nav-item[data-view="${v}"]`; |
| 117 | const reach = await page.evaluate(s => { |
| 118 | const b = document.querySelector(s); |
| 119 | if (!b) return { ok: false, why: 'no such tab' }; |
| 120 | // Below 860px the rail is a strip that scrolls sideways, so a later tab |
| 121 | // starts off-screen and a reader swipes to it. Do that first, then ask |
| 122 | // whether it can be reached -- the question is whether the tab is |
| 123 | // obstructed, not whether it happened to be in view. |
| 124 | b.scrollIntoView({ block: 'nearest', inline: 'nearest' }); |
| 125 | const r = b.getBoundingClientRect(); |
| 126 | if (r.width < 8 || r.height < 8) return { ok: false, why: `box ${r.width}x${r.height}` }; |
| 127 | const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); |
| 128 | if (top !== b && !b.contains(top)) { |
| 129 | return { ok: false, why: 'covered by ' + (top ? top.tagName + '.' + top.className : 'nothing') }; |
| 130 | } |
| 131 | return { ok: true }; |
| 132 | }, sel); |
| 133 | check(`the ${v} tab is reachable by a real pointer`, reach.ok, reach.why); |
| 134 | await page.click(sel, { force: true }); |
| 135 | await sleep(120); |
| 136 | } |
| 137 | |
| 138 | /// What the page is actually showing, read from the DOM rather than from what |
| 139 | /// the rail claims: the whole point is to catch the two disagreeing. |
| 140 | async function state(page) { |
| 141 | return page.evaluate(views => { |
| 142 | const shown = views.filter(v => { |
| 143 | const p = document.getElementById('view-' + v); |
| 144 | return p && !p.hidden; |
| 145 | }); |
| 146 | const selected = Array.from(document.querySelectorAll('#admin-nav .admin-nav-item')) |
| 147 | .filter(b => b.getAttribute('aria-selected') === 'true') |
| 148 | .map(b => b.dataset.view); |
| 149 | const tabs = Array.from(document.querySelectorAll('#admin-nav .admin-nav-item')) |
| 150 | .filter(b => !b.hidden).map(b => b.dataset.view); |
| 151 | return { shown, selected, tabs, hash: location.hash }; |
| 152 | }, VIEWS); |
| 153 | } |
| 154 | |
| 155 | const pwUrl = pathToFileURL(PW).href; |
| 156 | const { chromium } = await import(pwUrl); |
| 157 | // Launched as dev/harness.mjs launches. No mode on this host produces animation |
| 158 | // frames -- measured, on a blank page, in both headless modes -- which is why |
| 159 | // every click here is forced and clickability is asserted separately. |
| 160 | const browser = await chromium.launch({ executablePath: CHROME, headless: false, |
| 161 | args: ['--no-sandbox', '--disable-dev-shm-usage', '--headless=new'] }); |
| 162 | |
| 163 | try { |
| 164 | // ── An owner sees every section, one at a time ────────── |
| 165 | const page = await openConsole(browser, 'owner'); |
| 166 | |
| 167 | { |
| 168 | const s = await state(page); |
| 169 | check('the rail carries all seven sections', |
| 170 | VIEWS.every(v => s.tabs.includes(v)), |
| 171 | 'saw ' + s.tabs.join(',')); |
| 172 | check('a fresh load shows Overview and nothing else', |
| 173 | s.shown.length === 1 && s.shown[0] === 'overview', |
| 174 | 'showing ' + JSON.stringify(s.shown)); |
| 175 | } |
| 176 | |
| 177 | // Every section in turn: chosen, alone, agreed with, and named in the URL. |
| 178 | for (const v of VIEWS) { |
| 179 | await clickTab(page, v); |
| 180 | const s = await state(page); |
| 181 | check(`choosing ${v} shows ${v} and no other section`, |
| 182 | s.shown.length === 1 && s.shown[0] === v, |
| 183 | 'showing ' + JSON.stringify(s.shown)); |
| 184 | check(`choosing ${v} marks exactly that tab selected`, |
| 185 | s.selected.length === 1 && s.selected[0] === v, |
| 186 | 'selected ' + JSON.stringify(s.selected)); |
| 187 | check(`choosing ${v} names it in the fragment`, |
| 188 | s.hash === '#' + v, 'hash was ' + JSON.stringify(s.hash)); |
| 189 | } |
| 190 | |
| 191 | // ── The fragment survives a reload ────────────────────── |
| 192 | { |
| 193 | await clickTab(page, 'settings'); |
| 194 | await page.reload({ waitUntil: 'domcontentloaded' }); |
| 195 | await page.waitForSelector('#admin-app:not([hidden])', { timeout: 15000 }); |
| 196 | await sleep(400); |
| 197 | const s = await state(page); |
| 198 | check('a reload lands back on the section that was open', |
| 199 | s.shown.length === 1 && s.shown[0] === 'settings', |
| 200 | 'showing ' + JSON.stringify(s.shown)); |
| 201 | } |
| 202 | |
| 203 | // ── Arrow keys walk the rail ──────────────────────────── |
| 204 | { |
| 205 | await clickTab(page, 'overview'); |
| 206 | await page.keyboard.press('ArrowDown'); |
| 207 | await sleep(120); |
| 208 | let s = await state(page); |
| 209 | check('ArrowDown moves to the next section', |
| 210 | s.shown.length === 1 && s.shown[0] === 'money', |
| 211 | 'showing ' + JSON.stringify(s.shown)); |
| 212 | await page.keyboard.press('End'); |
| 213 | await sleep(120); |
| 214 | s = await state(page); |
| 215 | check('End moves to the last section', |
| 216 | s.shown.length === 1 && s.shown[0] === 'operators', |
| 217 | 'showing ' + JSON.stringify(s.shown)); |
| 218 | // Wrapping, because a rail that stops at the end makes the reader |
| 219 | // change direction to reach the thing one step the other way. |
| 220 | await page.keyboard.press('ArrowDown'); |
| 221 | await sleep(120); |
| 222 | s = await state(page); |
| 223 | check('ArrowDown from the last section wraps to the first', |
| 224 | s.shown.length === 1 && s.shown[0] === 'overview', |
| 225 | 'showing ' + JSON.stringify(s.shown)); |
| 226 | } |
| 227 | |
| 228 | // ── Only one tab is reachable by Tab ──────────────────── |
| 229 | { |
| 230 | const tabbable = await page.evaluate(() => |
| 231 | Array.from(document.querySelectorAll('#admin-nav .admin-nav-item')) |
| 232 | .filter(b => !b.hidden && b.tabIndex === 0).length); |
| 233 | check('the rail costs one Tab stop, not seven', tabbable === 1, |
| 234 | tabbable + ' items were tabbable'); |
| 235 | } |
| 236 | await page.close(); |
| 237 | |
| 238 | // ── An account with no grant ──────────────────────────── |
| 239 | { |
| 240 | const p2 = await openConsole(browser, 'operator'); |
| 241 | const s = await state(p2); |
| 242 | check('an operator gets no Operators tab', |
| 243 | !s.tabs.includes('operators'), 'tabs ' + s.tabs.join(',')); |
| 244 | check('an operator still gets the other six', |
| 245 | VIEWS.filter(v => v !== 'operators').every(v => s.tabs.includes(v)), |
| 246 | 'tabs ' + s.tabs.join(',')); |
| 247 | await p2.close(); |
| 248 | |
| 249 | // The link somebody sent them. It must not select a tab that is not |
| 250 | // there, and it must not leave the page showing nothing at all. |
| 251 | const p3 = await openConsole(browser, 'operator', '#operators'); |
| 252 | const s3 = await state(p3); |
| 253 | check('#operators without the grant falls back to a real section', |
| 254 | s3.shown.length === 1 && s3.shown[0] === 'overview', |
| 255 | 'showing ' + JSON.stringify(s3.shown)); |
| 256 | check('#operators without the grant selects no phantom tab', |
| 257 | s3.selected.length === 1 && s3.selected[0] === 'overview', |
| 258 | 'selected ' + JSON.stringify(s3.selected)); |
| 259 | await p3.close(); |
| 260 | } |
| 261 | |
| 262 | // ── Narrow ────────────────────────────────────────────── |
| 263 | // The rail lies down into a horizontal strip below 860px, and the header |
| 264 | // wraps to two lines below 620px. Both change where the sticky rail has to |
| 265 | // start, which is why that offset is measured rather than written down. |
| 266 | // A phone width is the case where getting it wrong hides the rail entirely. |
| 267 | { |
| 268 | const p5 = await browser.newPage({ viewport: { width: 430, height: 600 } }); |
| 269 | const stub = stubFor('owner'); |
| 270 | await p5.route('**/api/admin*', async route => { |
| 271 | const view = new URL(route.request().url()).searchParams.get('view'); |
| 272 | await route.fulfill({ status: 200, contentType: 'application/json', |
| 273 | body: JSON.stringify(stub[view] || { ok: true }) }); |
| 274 | }); |
| 275 | if (BREAK) await p5.addInitScript(mode => { window.__navBreak = mode; }, BREAK); |
| 276 | await p5.goto(APP + '/console/', { waitUntil: 'domcontentloaded' }); |
| 277 | await p5.waitForSelector('#admin-app:not([hidden])', { timeout: 15000 }); |
| 278 | await sleep(500); |
| 279 | |
| 280 | // Releases is the tallest section, so there is something to scroll -- |
| 281 | // and scrolling is the whole test. A sticky `top` does nothing at rest: |
| 282 | // at scroll 0 the rail sits in normal flow directly under the header and |
| 283 | // looks correct whatever the offset says. Only once the page moves does |
| 284 | // a wrong offset park the rail underneath the header. |
| 285 | await clickTab(p5, 'releases'); |
| 286 | await p5.evaluate(() => window.scrollTo(0, 400)); |
| 287 | await sleep(250); |
| 288 | |
| 289 | const geom = await p5.evaluate(() => { |
| 290 | const head = document.getElementById('admin-head').getBoundingClientRect(); |
| 291 | const nav = document.getElementById('admin-nav').getBoundingClientRect(); |
| 292 | return { headBottom: head.bottom, navTop: nav.top, scrolled: window.scrollY, |
| 293 | docWidth: document.documentElement.scrollWidth, |
| 294 | winWidth: window.innerWidth }; |
| 295 | }); |
| 296 | check('at 430px the page actually scrolled, so the next check means something', |
| 297 | geom.scrolled > 100, 'scrollY ' + geom.scrolled); |
| 298 | check('at 430px a scrolled rail is not tucked under the header', |
| 299 | geom.navTop >= geom.headBottom - 1, |
| 300 | `nav top ${Math.round(geom.navTop)} vs header bottom ${Math.round(geom.headBottom)}`); |
| 301 | check('at 430px nothing pushes the page sideways', |
| 302 | geom.docWidth <= geom.winWidth + 1, |
| 303 | `document ${geom.docWidth} wide in a ${geom.winWidth} window`); |
| 304 | |
| 305 | // The rail still works there, which is the point of measuring at all. |
| 306 | await clickTab(p5, 'settings'); |
| 307 | const s = await state(p5); |
| 308 | check('at 430px choosing a section still shows exactly it', |
| 309 | s.shown.length === 1 && s.shown[0] === 'settings', |
| 310 | 'showing ' + JSON.stringify(s.shown)); |
| 311 | await p5.close(); |
| 312 | } |
| 313 | |
| 314 | // ── A fragment that names nothing ─────────────────────── |
| 315 | { |
| 316 | const p4 = await openConsole(browser, 'owner', '#nonsense'); |
| 317 | const s = await state(p4); |
| 318 | check('an unknown fragment falls back to Overview', |
| 319 | s.shown.length === 1 && s.shown[0] === 'overview', |
| 320 | 'showing ' + JSON.stringify(s.shown)); |
| 321 | await p4.close(); |
| 322 | } |
| 323 | } finally { |
| 324 | await browser.close(); |
| 325 | } |
| 326 | |
| 327 | console.log(''); |
| 328 | console.log(`passed ${ok.length}, failed ${bad.length}` + (BREAK ? ` [--break ${BREAK}]` : '')); |
| 329 | if (bad.length) { console.log('failures:'); bad.forEach(b => console.log(' - ' + b)); } |
| 330 | process.exit(bad.length ? 1 : 0); |