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 | |
| 67 | import fs from 'node:fs'; |
| 68 | import path from 'node:path'; |
| 69 | import { fileURLToPath } from 'node:url'; |
| 70 | import { open, scratch, shot } from './harness.mjs'; |
| 71 | |
| 72 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 73 | const WWW = path.join(HERE, '..', 'www'); |
| 74 | |
| 75 | // A phone, and the one the app is drawn for: iPhone 14/15 at its CSS width. |
| 76 | const 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. |
| 79 | const 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. |
| 85 | const OPENER = '[data-closers-opener]'; |
| 86 | |
| 87 | const BREAK = (() => { |
| 88 | const i = process.argv.indexOf('--break'); |
| 89 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 90 | })(); |
| 91 | const ONLY = process.argv.filter((a, i) => |
| 92 | i >= 2 && a !== '--break' && process.argv[i - 1] !== '--break')[0] || ''; |
| 93 | |
| 94 | let failures = 0, skips = 0; |
| 95 | const skipped = []; |
| 96 | const check = (cond, msg, detail) => { |
| 97 | console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : '')); |
| 98 | if (!cond) failures++; |
| 99 | }; |
| 100 | const 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. |
| 112 | const 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. |
| 117 | const ALL = '.ui-close, .msheet-close, #admin-close, .admin-back, .panel.rail .panel-close'; |
| 118 | const 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 | |
| 177 | if (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. |
| 189 | function 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 | |
| 203 | async 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. |
| 225 | const 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. |
| 250 | const 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. |
| 313 | async 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. |
| 328 | async 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…". |
| 349 | async 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. |
| 370 | async 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. |
| 382 | const 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 | |
| 579 | for (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 | |
| 694 | if (skipped.length) console.log('\nskipped: ' + skipped.join('; ')); |
| 695 | if (BREAK) { |
| 696 | console.log(`\n(run with --break ${BREAK}: a FAILURE above is the point of it)`); |
| 697 | } |
| 698 | console.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` : ''}.`); |
| 701 | process.exit(failures === 0 && skips === 0 ? 0 : 1); |