Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_closers.mjs

31.9 KiB, 1 run

created by r2519314175:297, 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_closers.mjs — everything that covers the app carries a way out you can
2// reach with a thumb.
3//
4// `verify_escapable` asks whether a covering surface can be got rid of AT ALL,
5// by Escape or by whatever control it offers. This asks the harder question the
6// user actually reported: on a phone, is that control THERE, is it WHERE a hand
7// looks for it, and is it BIG ENOUGH to hit.
8//
9// "The top right settings dialog needs a top right closer cross, important on
10// mobile where there is not much space outside to touch to close."
11//
12// The appearance menu was 359px wide on a 390px screen. Nineteen pixels of
13// margin either side is not a way out, and it had no cross at all. Four other
14// surfaces had one under the thumb's floor, and About shipped a full-width Close
15// at the bottom of a card you had to scroll.
16//
17// FIVE PROPERTIES, ASKED OF EVERY DIALOG BY NAME:
18//
19// 1. A CLOSER EXISTS, and it is on screen. Not "the markup contains one" — a
20// `.ui-close` that CSS has hidden is no closer, which is exactly what the
21// `nocross` break serves.
22//
23// 2. IT IS IN THE TOP RIGHT of the thing it closes. Measured against the CARD,
24// not the viewport: a cross 8px from the right of the screen is in the
25// wrong place if its card ends 60px short of that. It must also be the
26// rightmost control in its own row, because "top right" is a position
27// relative to the other controls, not only a coordinate.
28//
29// 3. ITS HIT AREA IS AT LEAST 44x44 at a phone width, and a finger landing
30// anywhere across it reaches it. The box is measured AND probed with
31// `elementFromPoint` at the centre and at the middle of all four edges: a
32// 44px box with something laid over half of it measures fine and cannot be
33// pressed. (The corners are not probed — `border-radius` clips hit testing
34// there, correctly.)
35//
36// 4. PRESSING IT DISMISSES THAT DIALOG AND NOTHING ELSE. Not asserted as
37// "the dialog went": a census of every surface in the app is taken before
38// and after, and the difference has to be exactly the one named. A cross
39// that takes the drawer down with it passes any check that only looks at
40// what it was pointed at.
41//
42// 5. FOCUS LANDS BACK ON THE OPENER. The opener is focused and clicked, so
43// the keyboard genuinely starts there; after the cross, `activeElement`
44// has to be that same element and not the document body.
45//
46// EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a
47// deliberately damaged copy of a real file through `page.route`, and the run is
48// then expected to FAIL. A break whose anchor does not match exactly once aborts
49// rather than passing quietly: a check proved against code that was never broken
50// is not proved at all.
51//
52// node dev/verify_closers.mjs --break nocross # 1 fails: the closers vanish
53// node dev/verify_closers.mjs --break small # 3 fails: they go back to 28px
54// node dev/verify_closers.mjs --break moved # 2 fails: they slide left
55// node dev/verify_closers.mjs --break closesall # 4 fails: a cross takes the
56// # drawer down with the dialog
57// node dev/verify_closers.mjs --break nofocus # 5 fails: focus falls to body
58// node dev/verify_closers.mjs # and then, clean
59// node dev/verify_closers.mjs 'Appearance menu' # one dialog
60//
61// eval "$(bash dev/world.sh 2 --up)"
62// node dev/verify_closers.mjs
63//
64// Needs dev/serve.mjs only. No gateway: every surface here is reachable without
65// one, and any that is not is SKIPPED out loud rather than passed.
66
67import fs from 'node:fs';
68import path from 'node:path';
69import { fileURLToPath } from 'node:url';
70import { open, scratch, shot } from './harness.mjs';
71
72const HERE = path.dirname(fileURLToPath(import.meta.url));
73const WWW = path.join(HERE, '..', 'www');
74
75// A phone, and the one the app is drawn for: iPhone 14/15 at its CSS width.
76const PHONE = { width: 390, height: 844 };
77// The floor. Apple's Human Interface figure, above WCAG 2.2 AA's 24px, because
78// this is the control a user reaches for when they are stuck.
79const FLOOR = 44;
80// What check 5 asks about: the control `pressFrom`/`pressLabel` actually pressed
81// to open the dialog, stamped as it went. Naming a selector here instead was
82// wrong twice over — a prompt opened from a row INSIDE the admin drawer gives
83// the keyboard back to that row, which is right, and a check that named the
84// drawer's own opener called it a failure.
85const OPENER = '[data-closers-opener]';
86
87const BREAK = (() => {
88 const i = process.argv.indexOf('--break');
89 return i > 0 ? String(process.argv[i + 1] || '') : '';
90})();
91const ONLY = process.argv.filter((a, i) =>
92 i >= 2 && a !== '--break' && process.argv[i - 1] !== '--break')[0] || '';
93
94let failures = 0, skips = 0;
95const skipped = [];
96const check = (cond, msg, detail) => {
97 console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : ''));
98 if (!cond) failures++;
99};
100const skip = (name, why) => {
101 console.log(' SKIP ' + name + ' — ' + why);
102 skipped.push(name + ': ' + why);
103 skips++;
104};
105
106// ── The breaks ───────────────────────────────────────────────────────
107//
108// Each is a real edit to a real file, served in place of it. The CSS anchors are
109// deliberately the ONE rule that gives every closer its shape, so a single break
110// reaches the static crosses in index.html and the ones built in JS alike — a
111// break that only damaged one family would leave the other proving nothing.
112const CLOSER_RULE = '\t.ui-close { width: 44px; height: 44px; }';
113// The three closers written in static markup carry a size of their own from the
114// block each was tuned in, so every break has to name them beside `.ui-close` —
115// a break that only reached the shared rule left the sheet and the drawer at
116// full size and proved nothing about them.
117const ALL = '.ui-close, .msheet-close, #admin-close, .admin-back, .panel.rail .panel-close';
118const BREAKS = {
119 // No closer anywhere. Check 1 asks whether one is ON SCREEN, so hiding them
120 // is the same failure as never building them and covers both families at once.
121 nocross: [{ file: 'css/responsive.css', find: CLOSER_RULE,
122 with: '\t' + ALL + ' { display: none !important; }' }],
123 // The pointer size, on a phone. This is the state the app was in before this
124 // work: 28px for the tile dialog, 40 for the sheet and the admin drawer.
125 small: [{ file: 'css/responsive.css', find: CLOSER_RULE,
126 with: '\t' + ALL + ' { width: 28px !important; height: 28px !important;'
127 + ' min-width: 28px !important; min-height: 28px !important; }' }],
128 // Still there, still big, no longer in the corner. A margin on the flex item
129 // pushes it left of wherever its row put it, in every kind of row here.
130 moved: [{ file: 'css/responsive.css', find: CLOSER_RULE,
131 with: '\t.ui-close { width: 44px; height: 44px; }\n\t' + ALL
132 + ' { margin-right: 180px !important; }' }],
133 // A cross that closes more than the thing it sits on. The rail drawer is the
134 // witness for eleven of the thirteen: it is open underneath every dialog this
135 // file raises on a phone, and it is what a closer wired one scope too wide
136 // takes down with it. The sheet stands in as the drawer's own witness.
137 //
138 // The two it cannot reach are the Mobile sheet (the drawer would be drawn
139 // over the cross being pressed) and the Panel gallery (a desktop width, where
140 // there is no phone shell to witness anything). For those two, check 4 still
141 // asks that no OTHER surface in the census moved — a smaller claim, made
142 // honestly rather than dressed up.
143 closesall: [
144 { file: 'js/closer.js',
145 find: '\t\t\t\topts.onClose(e);',
146 with: '\t\t\t\tdocument.body.classList.remove(\'drawer-open\');\n\t\t\t\topts.onClose(e);' },
147 { file: 'js/workspace.js',
148 find: '\tfunction closePalette() {\n\t\tif (!palEl || palEl.hidden) return;',
149 with: '\tfunction closePalette() {\n\t\tif (!palEl || palEl.hidden) return;\n\t\tdocument.body.classList.remove(\'drawer-open\');' },
150 { file: 'js/daimond.js',
151 find: '\t\tfunction closeAdmin() {\n\t\t\tendForm();',
152 with: '\t\tfunction closeAdmin() {\n\t\t\tdocument.body.classList.remove(\'drawer-open\');\n\t\t\tendForm();' },
153 { file: 'js/daimond.js',
154 find: '\t\tfunction close() {\n\t\t\tdocument.removeEventListener(\'keydown\', onKey, true);\n\t\t\tif (sayPause) window.removeEventListener(\'daimond:pause\', sayPause);',
155 with: '\t\tfunction close() {\n\t\t\tdocument.body.classList.remove(\'drawer-open\');\n\t\t\tdocument.removeEventListener(\'keydown\', onKey, true);\n\t\t\tif (sayPause) window.removeEventListener(\'daimond:pause\', sayPause);' },
156 { file: 'js/mobile.js',
157 find: '\tfunction closeDrawer() { document.body.classList.remove(\'drawer-open\'); }',
158 with: '\tfunction closeDrawer() { document.body.classList.remove(\'drawer-open\');'
159 + ' var sh = document.getElementById(\'msheet\'); if (sh) sh.classList.remove(\'open\'); }' },
160 ],
161 // The keyboard is dropped on the floor. Both families again: the modal
162 // dialogs restore through `refocus`, and the popovers and shells each do it
163 // themselves, so all of them are cut.
164 nofocus: [
165 { file: 'js/daimond.js', find: '\tfunction refocus(prev, host) {\n\t\tif (tookFocus(prev)) return;', with: '\tfunction refocus(prev, host) {\n\t\tif (true) return;' },
166 { file: 'js/workspace.js', find: '\t\tif (b) { b.setAttribute(\'aria-expanded\', \'false\'); b.focus(); }', with: '\t\tif (b) { b.setAttribute(\'aria-expanded\', \'false\'); }' },
167 { file: 'js/mobile.js', find: '\t\t\tif (burger2) { try { burger2.focus(); } catch (x) { /* not on screen */ } }', with: '\t\t\tif (burger2) { /* dropped */ }' },
168 { file: 'js/pairing.js', find: '\t\t\tif (prev && prev.focus && prev.getClientRects && prev.getClientRects().length) {', with: '\t\t\tif (false) {' },
169 { file: 'js/workspace.js', find: '\t\tif (more) { more.setAttribute(\'aria-expanded\', \'false\'); more.focus(); }', with: '\t\tif (more) { more.setAttribute(\'aria-expanded\', \'false\'); }' },
170 { file: 'js/workspace.js', find: '\t\tif (palPrev && palPrev.focus && document.contains(palPrev)) {', with: '\t\tif (false) {' },
171 { file: 'js/handmode.js', find: '\t\t\ttry { chip.focus(); } catch (e) { /* gone from the page */ }', with: '\t\t\t/* dropped */' },
172 { file: 'js/mobile.js', find: '\t\t\ttry { opener.focus(); } catch (e) { /* gone with the redraw */ }', with: '\t\t\t/* dropped */' },
173 { file: 'js/daimond.js', find: '\t\t\t\ttry { drawerOpener.focus(); } catch (e) { /* gone with the redraw */ }', with: '\t\t\t\t/* dropped */' },
174 ],
175};
176
177if (BREAK && !BREAKS[BREAK]) {
178 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
179 process.exit(2);
180}
181
182/// Every edit a break makes to ONE file, applied to one copy of it.
183///
184/// Grouped by file, and that is load-bearing: `page.route` keeps the LAST
185/// handler registered for a pattern, so two specs against `js/daimond.js`
186/// registered separately meant only the second edit was ever served — the run
187/// then passed, and reported a check as proved that had been asked of working
188/// code.
189function damaged(file, specs) {
190 let src = fs.readFileSync(path.join(WWW, file), 'utf8');
191 for (const spec of specs) {
192 const n = src.split(spec.find).length - 1;
193 if (n !== 1) {
194 console.error(`break '${BREAK}': the anchor appears ${n} times in ${file}, `
195 + 'so nothing was broken and the run below would prove nothing.');
196 process.exit(2);
197 }
198 src = src.replace(spec.find, spec.with);
199 }
200 return src;
201}
202
203async function serveBreak(page) {
204 if (!BREAK) return;
205 const byFile = new Map();
206 for (const spec of BREAKS[BREAK]) {
207 if (!byFile.has(spec.file)) byFile.set(spec.file, []);
208 byFile.get(spec.file).push(spec);
209 }
210 for (const [file, specs] of byFile) {
211 const body = damaged(file, specs);
212 const type = /\.css$/.test(file) ? 'text/css' : 'application/javascript';
213 await page.route('**/' + file, r => r.fulfill({ status: 200, contentType: type, body }));
214 }
215}
216
217// ── Reading the page ─────────────────────────────────────────────────
218
219/// Every surface the app can put over itself, and whether it is up.
220///
221/// This is what makes check 4 mean something. "The dialog closed" is one bit;
222/// "and the drawer, the sheet, the menu and the other three modals are exactly
223/// as they were" is the property, and it is the one a cross wired to the wrong
224/// scope breaks.
225const CENSUS = () => {
226 const up = (sel) => [...document.querySelectorAll(sel)]
227 .some((el) => el.getClientRects().length > 0 && getComputedStyle(el).visibility !== 'hidden');
228 return {
229 'Rail drawer': document.body.classList.contains('drawer-open'),
230 'Mobile sheet': !!document.querySelector('#msheet.open'),
231 'Admin drawer': !!document.querySelector('.admin.admin-open'),
232 'Appearance menu': up('#settings-menu'),
233 'Panel gallery': up('#panel-gallery'),
234 'Permission ladder': up('#hand-mode-pop'),
235 'Command palette': up('#palette'),
236 'Pairing': up('.pair-scrim'),
237 // About IS a body dialog and a change-of-passphrase IS a modal, so each
238 // pair has to exclude the other or one dismissal reads as two and check 4
239 // fails against working code.
240 'Tile dialog': up('.tile-dlg-card:not(.about-card)'),
241 'About': up('.about-card'),
242 'Modal dialog': up('.modal.dlg:not(.tile-dlg):not(#cp-modal) .dlg-card'),
243 'Change passphrase': up('#cp-modal'),
244 'Identity gate': up('#identity-modal'),
245 };
246};
247
248/// The closer's box, where it sits relative to the card it closes, and whether a
249/// finger actually lands on it.
250const MEASURE = ({ cardSel, closeSel }) => {
251 const card = document.querySelector(cardSel);
252 if (!card) return { err: 'no card ' + cardSel };
253 const x = card.querySelector(closeSel) || document.querySelector(closeSel);
254 if (!x || !x.getClientRects().length) return { closer: false };
255 const cr = card.getBoundingClientRect();
256 const r = x.getBoundingClientRect();
257 // The card's inner right edge, which is where a corner control actually sits.
258 // `getBoundingClientRect().right` includes the border AND the scrollbar
259 // gutter, and this browser draws a classic 15px scrollbar where a phone draws
260 // an overlay -- so measuring from it reported every scrolling card's closer as
261 // 15px further in than a person would ever see it.
262 const innerRight = cr.left + card.clientLeft + card.clientWidth;
263 const innerTop = cr.top + card.clientTop;
264 // The card's own padding is what "flush with the corner" MEANS here, and it
265 // is not a constant: the warm skin multiplies every card's 24px by
266 // `--space-scale`, so a dialog's closer is 33.6px in from the border on the
267 // default skin and would be 24 on the sharp one. A fixed tolerance would have
268 // been a check on which skin was in force.
269 const cs = getComputedStyle(card);
270 const padR = parseFloat(cs.paddingRight) || 0;
271 const padT = parseFloat(cs.paddingTop) || 0;
272
273 // The middles of the four edges and the centre. Not the corners: a rounded
274 // button correctly does not receive a pointer in the clipped corner, so
275 // probing there would demand a square target the design does not want.
276 const i = 1;
277 const pts = [
278 ['top', r.left + r.width / 2, r.top + i],
279 ['bottom', r.left + r.width / 2, r.bottom - i],
280 ['left', r.left + i, r.top + r.height / 2],
281 ['right', r.right - i, r.top + r.height / 2],
282 ['centre', r.left + r.width / 2, r.top + r.height / 2],
283 ];
284 const missed = pts.filter(([, px, py]) => {
285 const el = document.elementFromPoint(px, py);
286 return !(el && (el === x || x.contains(el)));
287 }).map(([k]) => k);
288
289 // Is it the rightmost control in its own row? "Top right" is a place among
290 // the other controls, not only a coordinate.
291 const row = x.parentElement;
292 const sibs = row ? [...row.children].filter((n) => n !== x && n.getClientRects().length) : [];
293 const rightmost = sibs.every((n) => n.getBoundingClientRect().right <= r.right + 0.5);
294
295 return {
296 closer: true,
297 w: +r.width.toFixed(1), h: +r.height.toFixed(1),
298 fromRight: +(innerRight - r.right).toFixed(1),
299 fromTop: +(r.top - innerTop).toFixed(1),
300 padR: +padR.toFixed(1), padT: +padT.toFixed(1),
301 cardW: +cr.width.toFixed(1), cardH: +cr.height.toFixed(1),
302 onScreen: r.top >= 0 && r.bottom <= window.innerHeight + 0.5
303 && r.left >= 0 && r.right <= window.innerWidth + 0.5,
304 rightmost,
305 missed: missed.join(','),
306 name: x.getAttribute('aria-label') || '',
307 };
308};
309
310/// Mark an element as the opener and give it the keyboard, without pressing it.
311/// For the three surfaces raised through their own API, where there is no
312/// control to click but the keyboard still has to come home to somewhere.
313async function stamp(page, sel) {
314 await page.waitForSelector(sel, { timeout: 10000 });
315 await page.evaluate((s) => {
316 const e = document.querySelector(s);
317 if (!e) return;
318 [...document.querySelectorAll('[data-closers-opener]')]
319 .forEach((n) => n.removeAttribute('data-closers-opener'));
320 e.setAttribute('data-closers-opener', '1');
321 try { e.focus(); } catch (x) { /* not focusable */ }
322 }, sel);
323}
324
325/// Focus an element and click it, so the keyboard really starts where the user's
326/// hand did. A bare `.click()` leaves `activeElement` on the body and any focus
327/// check afterwards is asking nothing.
328async function pressFrom(page, sel) {
329 await page.waitForSelector(sel, { timeout: 10000 });
330 await page.evaluate((s) => {
331 const e = document.querySelector(s);
332 if (!e) return;
333 // Stamped, so check 5 can ask about the control the hand actually pressed
334 // rather than about a selector written out here. A dialog opened from a row
335 // INSIDE the admin drawer must give the keyboard back to that row, and a
336 // check naming the drawer's own opener would have called that a failure.
337 [...document.querySelectorAll('[data-closers-opener]')]
338 .forEach((n) => n.removeAttribute('data-closers-opener'));
339 e.setAttribute('data-closers-opener', '1');
340 try { e.focus(); } catch (x) { /* not focusable */ }
341 e.click();
342 }, sel);
343 await page.waitForTimeout(350);
344}
345
346/// Press the control inside `rootSel` whose text is exactly `text`. Exact, not
347/// Playwright's `:has-text`, which is a case-insensitive SUBSTRING and would
348/// press "Change name…" when asked for "Change passphrase…".
349async function pressLabel(page, rootSel, text) {
350 await page.waitForSelector(rootSel, { timeout: 10000 });
351 const hit = await page.evaluate(({ rootSel, text }) => {
352 const root = document.querySelector(rootSel);
353 if (!root) return false;
354 const b = [...root.querySelectorAll('button')].find((x) => (x.textContent || '').trim() === text);
355 if (!b) return false;
356 [...document.querySelectorAll('[data-closers-opener]')]
357 .forEach((n) => n.removeAttribute('data-closers-opener'));
358 b.setAttribute('data-closers-opener', '1');
359 try { b.focus(); } catch (e) { /* not focusable */ }
360 b.click();
361 return true;
362 }, { rootSel, text });
363 if (!hit) throw new Error(`no control labelled "${text}" in ${rootSel}`);
364 await page.waitForTimeout(400);
365}
366
367/// Open the rail drawer, which is the WITNESS for check 4: on a phone it is
368/// reachable from anywhere, it stays up under every dialog below, and a cross
369/// wired to the wrong scope takes it down.
370async function openDrawer(page) {
371 await pressFrom(page, '#drawer-btn');
372 await page.waitForTimeout(300);
373}
374
375// ── The surfaces ─────────────────────────────────────────────────────
376//
377// `card` is the box the closer must be in the top right OF. `close` finds the
378// closer inside it. `opener` is the selector focus must come back to, and is
379// what `reach` presses first. `witness: false` marks the two surfaces that
380// cannot have the drawer open underneath them — the drawer itself, and the one
381// the drawer would cover.
382const DIALOGS = [
383 {
384 // The one the user reported, by name.
385 name: 'Appearance menu',
386 open: { connect: false },
387 card: '#settings-menu',
388 close: '.ui-close',
389 reach: async (page) => {
390 await pressFrom(page, '#settings-menu-btn');
391 await page.waitForSelector('#settings-menu', { state: 'visible', timeout: 8000 });
392 },
393 },
394 {
395 name: 'Permission ladder',
396 open: { connect: false },
397 card: '#hand-mode-pop',
398 close: '.ui-close',
399 reach: async (page) => {
400 // The chip lives in the chat head and is hidden until a chat is live;
401 // what is being checked is the popover, not what reveals the chip.
402 await page.evaluate(() => { const c = document.getElementById('hand-mode-chip'); if (c) c.style.display = ''; });
403 await pressFrom(page, '#hand-mode-chip');
404 await page.waitForSelector('#hand-mode-pop', { state: 'visible', timeout: 8000 });
405 },
406 },
407 {
408 name: 'Command palette',
409 open: { connect: false },
410 card: '.pal-box',
411 close: '#pal-close',
412 reach: async (page) => {
413 // Focused and stamped first, so there is a real place for the keyboard to
414 // go back to — the palette is opened with a key from wherever the hand
415 // already was.
416 await stamp(page, '#drawer-btn');
417 await page.keyboard.press('Control+k');
418 await page.waitForSelector('#palette', { state: 'visible', timeout: 8000 });
419 },
420 },
421 {
422 name: 'Modal dialog (Change name)',
423 open: { connect: false },
424 card: '.modal.dlg .dlg-card',
425 close: '.ui-close',
426 reach: async (page) => {
427 await pressFrom(page, '#user-row');
428 await pressLabel(page, '#admin-home', 'Change name…');
429 },
430 // The prompt is opened from a row INSIDE the admin drawer, which the
431 // dialog's own dismissal leaves standing — so the drawer is in the census
432 // twice over and the rail drawer underneath must survive as well.
433 census: 'Modal dialog',
434 },
435 {
436 name: 'Confirm dialog (Forget this identity)',
437 open: { connect: false },
438 card: '.modal.dlg .dlg-card',
439 close: '.ui-close',
440 reach: async (page) => {
441 await pressFrom(page, '#user-row');
442 await pressLabel(page, '#admin-home', 'Forget this identity…');
443 },
444 census: 'Modal dialog',
445 },
446 {
447 name: 'Change passphrase',
448 open: { connect: false },
449 card: '#cp-modal .dlg-card',
450 close: '.ui-close',
451 reach: async (page) => {
452 await pressFrom(page, '#user-row');
453 await pressLabel(page, '#admin-home', 'Change passphrase…');
454 await page.waitForSelector('.dlg-card', { timeout: 8000 });
455 await page.evaluate(() => { const i = document.querySelector('.dlg .dlg-input'); if (i) i.focus(); });
456 await page.keyboard.type('testpass1234');
457 await page.evaluate(() => { const b = [...document.querySelectorAll('.dlg .dlg-ok')].pop(); if (b) b.click(); });
458 await page.waitForSelector('#cp-modal', { timeout: 8000 });
459 },
460 census: 'Change passphrase',
461 },
462 {
463 name: 'About',
464 open: { connect: false },
465 card: '.about-card',
466 close: '.ui-close',
467 reach: async (page) => {
468 await pressFrom(page, '#about-btn');
469 await page.waitForSelector('.about-card', { timeout: 8000 });
470 },
471 census: 'About',
472 },
473 {
474 name: 'Admin drawer',
475 open: { connect: false },
476 card: '.admin-drawer-head',
477 close: '#admin-close',
478 reach: async (page) => {
479 await pressFrom(page, '#user-row');
480 await page.waitForSelector('.admin.admin-open', { timeout: 8000 });
481 },
482 census: 'Admin drawer',
483 },
484 {
485 name: 'Pairing (Link another device)',
486 open: { connect: false },
487 card: '.pair-box',
488 close: '.ui-close',
489 reach: async (page) => {
490 await stamp(page, '#drawer-btn');
491 await page.evaluate(() => window.DaimondPairing && DaimondPairing.showLink());
492 await page.waitForSelector('.pair-scrim', { timeout: 8000 });
493 },
494 census: 'Pairing',
495 },
496 {
497 name: 'Tile dialog (Diamond cog)',
498 // A model IS connected here: making a Diamond is how a tile comes to
499 // exist, and a rail with no tiles has no cog to press.
500 open: {},
501 card: '.tile-dlg-card',
502 close: '.tile-dlg-x',
503 // The drawer is opened HERE rather than by the shared step, because the
504 // Admin panel has to be put away first (a connected session opens it) and
505 // putting it away is one of the things the `closesall` break makes close
506 // the drawer as well. Opened after, the witness is open when it matters.
507 witness: false,
508 reach: async (page) => {
509 await page.evaluate(() => { const b = document.getElementById('admin-close'); if (b) b.click(); });
510 await page.waitForTimeout(250);
511 await openDrawer(page);
512 await pressFrom(page, '#diamond-list .tile-cog');
513 await page.waitForSelector('.tile-dlg-card', { timeout: 8000 });
514 },
515 census: 'Tile dialog',
516 },
517 {
518 name: 'Mobile sheet',
519 open: { connect: false },
520 card: '.msheet-grab',
521 close: '#msheet-close',
522 witness: false, // the drawer would cover it
523 reach: async (page) => {
524 await stamp(page, '#drawer-btn');
525 await page.evaluate(() => window.DaimondSheet && DaimondSheet.open('doc'));
526 await page.waitForTimeout(700);
527 },
528 census: 'Mobile sheet',
529 },
530 {
531 name: 'Rail drawer',
532 open: { connect: false },
533 card: '.panel.rail .pptw-head',
534 close: '.panel-close',
535 witness: false, // it IS the witness for everything else
536 // The sheet stands in as the thing that must survive: the drawer is drawn
537 // over it, so closing the drawer with the sheet up is the case where a
538 // closer wired too wide would take two surfaces down at once.
539 under: async (page) => {
540 await page.evaluate(() => window.DaimondSheet && DaimondSheet.open('doc'));
541 await page.waitForTimeout(600);
542 },
543 reach: async (page) => {
544 await pressFrom(page, '#drawer-btn');
545 await page.waitForTimeout(400);
546 },
547 census: 'Rail drawer',
548 },
549 {
550 // Desktop and tablet only: the chip row it overflows out of is
551 // `display: none` on a phone, so there is no ⋯ to press there at all. It
552 // is measured at the width it is REACHABLE at, and its floor is the
553 // coarse-pointer one — which is why the harness is asked for a touch
554 // context rather than a narrow window.
555 name: 'Panel gallery',
556 open: { connect: false },
557 viewport: { width: 900, height: 820 },
558 touch: true,
559 card: '#panel-gallery',
560 close: '.ui-close',
561 witness: false, // no phone shell at this width
562 reach: async (page) => {
563 await page.evaluate(() => {
564 ['doc', 'msg', 'compose'].forEach((p) => {
565 try { DaimondPanels.markUsed(p); } catch (e) { /* not built yet */ }
566 });
567 try { DaimondPanels.reflow(); } catch (e) { /* nothing to reflow */ }
568 });
569 await page.waitForTimeout(500);
570 await pressFrom(page, '#panel-more');
571 await page.waitForSelector('#panel-gallery', { state: 'visible', timeout: 8000 });
572 },
573 census: 'Panel gallery',
574 },
575];
576
577// ── The run ──────────────────────────────────────────────────────────
578
579for (const d of DIALOGS) {
580 if (ONLY && d.name !== ONLY) continue;
581 console.log(`\n── ${d.name}`);
582
583 const dir = scratch('pw', 'closers-' + Math.random().toString(36).slice(2, 10));
584 let s = null;
585 try {
586 s = await open({ ...d.open, name: 'closers', profile: dir, touch: !!d.touch,
587 route: serveBreak });
588 await s.page.setViewportSize(d.viewport || PHONE);
589 await s.page.waitForTimeout(400);
590
591 if (d.under) await d.under(s.page);
592 if (d.witness !== false) await openDrawer(s.page);
593
594 await d.reach(s.page);
595 await s.page.waitForTimeout(350);
596
597 const before = await s.page.evaluate(CENSUS);
598 const key = d.census || d.name;
599 if (!before[key]) throw new Error(`${key} did not open (census: ${JSON.stringify(before)})`);
600
601 const m = await s.page.evaluate(MEASURE, { cardSel: d.card, closeSel: d.close });
602 if (m.err) throw new Error(m.err);
603
604 // ── 1 ──
605 check(m.closer === true, `${d.name}: carries a closer, and it is on screen`,
606 m.closer ? m.name : 'no visible ' + d.close + ' in ' + d.card);
607 if (!m.closer) {
608 await shot(s, 'closers-missing-' + d.name.replace(/\W+/g, '-').toLowerCase());
609 await s.close();
610 fs.rmSync(dir, { recursive: true, force: true });
611 continue;
612 }
613
614 // ── 2 ──
615 // Flush with the corner of the card's CONTENT box, give or take the row's
616 // own few pixels. Measured against the card's padding rather than a
617 // constant, so the check says the same thing on both skins; a closer that
618 // has drifted into the body, down to the foot, or behind another control
619 // in its row fails all three ways.
620 const cornered = m.fromRight >= -0.5 && m.fromRight <= m.padR + 10
621 && m.fromTop >= -0.5 && m.fromTop <= m.padT + 12
622 && m.rightmost && m.onScreen;
623 check(cornered, `${d.name}: its closer is in the card's top right`,
624 `${m.fromRight}px in from the card's right edge and ${m.fromTop}px down from its top,`
625 + ` against ${m.padR}/${m.padT}px of card padding`
626 + (m.rightmost ? '' : '; NOT the rightmost control in its row')
627 + (m.onScreen ? '' : '; and off the screen'));
628
629 // ── 3 ──
630 check(m.w >= FLOOR && m.h >= FLOOR && !m.missed,
631 `${d.name}: its closer is at least ${FLOOR}x${FLOOR} and reachable across that box`,
632 `${m.w}x${m.h}` + (m.missed ? `, and a finger misses it at: ${m.missed}` : ''));
633
634 // ── 4 and 5 ──
635 //
636 // The keyboard is put INSIDE the dialog first. Without this the check
637 // below is vacuous for every surface a user reaches without tabbing into
638 // it: focus never left the opener, so it is still there afterwards whether
639 // the dialog gives it back or not, and a broken restore reads as a pass.
640 // This is what a person does anyway — they touch the thing before they
641 // close it.
642 await s.page.evaluate((sel) => {
643 const root = document.querySelector(sel.card);
644 if (!root) return;
645 const inside = [...root.querySelectorAll('button,input,select,textarea,a[href]')]
646 .filter((n) => !n.disabled && n.getClientRects().length
647 && !n.matches(sel.close) && !n.classList.contains('ui-close'));
648 const target = inside[0] || root;
649 if (!target.hasAttribute('tabindex') && !inside.length) target.setAttribute('tabindex', '-1');
650 try { target.focus(); } catch (e) { /* not focusable */ }
651 }, { card: d.card, close: d.close });
652 await s.page.waitForTimeout(150);
653 const left = await s.page.evaluate((sel) => !document.querySelector(sel)
654 || document.activeElement !== document.querySelector(sel), OPENER);
655 if (!left) throw new Error('could not move the keyboard off the opener, so check 5 would prove nothing');
656
657 await s.page.evaluate((sel) => {
658 const root = document.querySelector(sel.card);
659 const x = (root && root.querySelector(sel.close)) || document.querySelector(sel.close);
660 if (x) x.click();
661 }, { card: d.card, close: d.close });
662 await s.page.waitForTimeout(600);
663 const after = await s.page.evaluate(CENSUS);
664 const changed = Object.keys(before).filter((k) => before[k] !== after[k]);
665 check(changed.length === 1 && changed[0] === key,
666 `${d.name}: pressing it dismisses ${key} and nothing else`,
667 changed.length === 0 ? 'nothing closed at all'
668 : `what changed: ${changed.join(', ')}`);
669 if (!(changed.length === 1 && changed[0] === key)) {
670 await shot(s, 'closers-scope-' + d.name.replace(/\W+/g, '-').toLowerCase());
671 }
672
673 // ── 5 ──
674 const landed = await s.page.evaluate((sel) => {
675 const want = document.querySelector(sel);
676 const a = document.activeElement;
677 return {
678 on: !!want && a === want,
679 where: a ? (a.tagName.toLowerCase() + (a.id ? '#' + a.id : '')
680 + (a.className ? '.' + String(a.className).split(/\s+/).join('.') : '')) : '(none)',
681 };
682 }, OPENER);
683 check(landed.on, `${d.name}: focus lands back on the control that opened it`,
684 landed.on ? null : `it is on ${landed.where}`);
685
686 } catch (e) {
687 skip(d.name, String(e && e.message ? e.message : e).split('\n')[0]);
688 } finally {
689 try { if (s) await s.close(); } catch (e) { /* already gone */ }
690 try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* gone */ }
691 }
692}
693
694if (skipped.length) console.log('\nskipped: ' + skipped.join('; '));
695if (BREAK) {
696 console.log(`\n(run with --break ${BREAK}: a FAILURE above is the point of it)`);
697}
698console.log(failures === 0
699 ? `\nclosers: every surface carries a way out a thumb can reach${skips ? ` (${skips} SKIPPED)` : ''}.`
700 : `\nclosers: ${failures} failure(s)${skips ? `, ${skips} SKIPPED` : ''}.`);
701process.exit(failures === 0 && skips === 0 ? 0 : 1);