Oregami
Repositories/oxedyne/daimond

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
31import os from 'node:os';
32import path from 'node:path';
33import { 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.
40import './display.mjs';
41const APP = process.env.DAIMOND_APP || `http://localhost:${process.env.DAIMOND_PORT || 8777}`;
42const PW = process.env.DAIMOND_PW
43 || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
44const CHROME = process.env.DAIMOND_CHROME
45 || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`;
46
47const BREAK = (() => {
48 const i = process.argv.indexOf('--break');
49 return i >= 0 ? (process.argv[i + 1] || 'one') : null;
50})();
51
52const ok = [], bad = [];
53const check = (name, pass, detail) => {
54 (pass ? ok : bad).push(name);
55 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' -- ' + detail : ''));
56};
57const 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.
62const 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.
68function 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
88async 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.
115async 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.
140async 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
155const pwUrl = pathToFileURL(PW).href;
156const { 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.
160const browser = await chromium.launch({ executablePath: CHROME, headless: false,
161 args: ['--no-sandbox', '--disable-dev-shm-usage', '--headless=new'] });
162
163try {
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
327console.log('');
328console.log(`passed ${ok.length}, failed ${bad.length}` + (BREAK ? ` [--break ${BREAK}]` : ''));
329if (bad.length) { console.log('failures:'); bad.forEach(b => console.log(' - ' + b)); }
330process.exit(bad.length ? 1 : 0);