| 1 | // fixtures/browser-drive.mjs — drive the real editor in a real |
| 2 | // browser, and assert what a person would actually see. |
| 3 | // |
| 4 | // Spec §12 asks for "a browser drive of the editor itself, because a JS |
| 5 | // syntax error takes the whole client down". This is the first one, and |
| 6 | // its first run earned its place: the toolbar branch had made the |
| 7 | // editor silently discard every edit — typing did nothing, bold did |
| 8 | // nothing, no error anywhere — and the Go suite was green throughout, |
| 9 | // because nothing in it runs the bundle. A screenshot did not show it |
| 10 | // either: the bar renders correctly and the document looks untouched |
| 11 | // because it IS untouched. |
| 12 | // |
| 13 | // Run it through scripts/browser-drive.sh, which brings up a scratch |
| 14 | // server and a headless Chrome around it. This file only speaks the |
| 15 | // DevTools protocol, over Node's own WebSocket — no puppeteer, no |
| 16 | // playwright, nothing to install (the same "no build step, no new |
| 17 | // dependency" constraint the editor bundle itself lives under). |
| 18 | // |
| 19 | // One limitation to know before extending this to two pages (a |
| 20 | // cross-tab check wants one): headless Chromium refuses more than one |
| 21 | // startup target — "Multiple targets are not supported in headless |
| 22 | // mode" — so a second tab has to be opened through CDP |
| 23 | // (Target.createTarget) or the /json/new endpoint, not by passing a |
| 24 | // second URL on the command line. Found by the live-sync session. |
| 25 | // |
| 26 | // It uses REAL input events, not element.click() and not a synthetic |
| 27 | // DOM Range. Both of those skip what the editor actually listens to: a |
| 28 | // Range set by script is not the selection ProseMirror reads, and a |
| 29 | // click() with no mousedown skips the preventDefault the toolbar |
| 30 | // depends on to keep the selection alive while the button is pressed. |
| 31 | // A drive that fakes its input tests the fake. |
| 32 | |
| 33 | const [, , wsUrl, base, signinLink] = process.argv |
| 34 | if (!wsUrl || !base) { |
| 35 | console.error('usage: node browser-drive.mjs <devtools-ws-url> <base-url> [sign-in-link]') |
| 36 | process.exit(2) |
| 37 | } |
| 38 | |
| 39 | const ws = new WebSocket(wsUrl) |
| 40 | let nextId = 0 |
| 41 | const pending = new Map() |
| 42 | const pageProblems = [] |
| 43 | |
| 44 | function send(method, params = {}) { |
| 45 | const id = ++nextId |
| 46 | return new Promise((resolve, reject) => { |
| 47 | pending.set(id, { resolve, reject }) |
| 48 | ws.send(JSON.stringify({ id, method, params })) |
| 49 | }) |
| 50 | } |
| 51 | |
| 52 | ws.onmessage = (e) => { |
| 53 | const msg = JSON.parse(e.data) |
| 54 | if (msg.id && pending.has(msg.id)) { |
| 55 | const { resolve, reject } = pending.get(msg.id) |
| 56 | pending.delete(msg.id) |
| 57 | if (msg.error) reject(new Error(JSON.stringify(msg.error))) |
| 58 | else resolve(msg.result) |
| 59 | return |
| 60 | } |
| 61 | // A JS error in the bundle is the failure §12 names first, so it is |
| 62 | // collected here rather than left to show up as "nothing happened". |
| 63 | if (msg.method === 'Runtime.exceptionThrown') { |
| 64 | const d = msg.params.exceptionDetails |
| 65 | pageProblems.push('uncaught: ' + (d.exception?.description || d.text || '').split('\n')[0]) |
| 66 | } |
| 67 | if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') { |
| 68 | pageProblems.push('console.error: ' + msg.params.args.map((a) => a.description || a.value).join(' ')) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) |
| 73 | |
| 74 | async function evaluate(expression) { |
| 75 | const res = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }) |
| 76 | if (res.exceptionDetails) { |
| 77 | throw new Error('page threw: ' + (res.exceptionDetails.exception?.description || '').split('\n')[0]) |
| 78 | } |
| 79 | return res.result.value |
| 80 | } |
| 81 | |
| 82 | async function click(x, y, clickCount = 1) { |
| 83 | await send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount, buttons: 1 }) |
| 84 | await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount, buttons: 0 }) |
| 85 | } |
| 86 | |
| 87 | // clickSelector clicks the centre of the first element matching a |
| 88 | // selector — and refuses two things that used to fail much later and |
| 89 | // much less clearly. |
| 90 | // |
| 91 | // A ZERO-SIZE TARGET IS AN ERROR, not a click at (0,0). This cost five |
| 92 | // checks and an hour. The menu bar gave a second home to every |
| 93 | // data-docs-command name, and menu items come before the toolbar in |
| 94 | // document order, so `[data-docs-command="strong"]` started matching a |
| 95 | // menu item inside a closed <details> — display: none, rect all zeros. |
| 96 | // Every click went to the top-left corner of the page, and the checks |
| 97 | // failed thirty seconds later saying "timed out waiting for bold to |
| 98 | // apply", which is true and points nowhere near the cause. Refusing the |
| 99 | // click names it instead. |
| 100 | // |
| 101 | // MORE THAN ONE MATCH IS ALSO AN ERROR, because that is the same bug |
| 102 | // one step earlier: the selector stopped identifying one control and |
| 103 | // nothing said so. A caller that genuinely wants the first of several |
| 104 | // scopes its selector, which is what the toolbar's three now do. |
| 105 | async function clickSelector(selector) { |
| 106 | const at = await evaluate( |
| 107 | `(()=>{const all=document.querySelectorAll(${JSON.stringify(selector)});` + |
| 108 | `if(!all.length)return {none:true};` + |
| 109 | `const e=all[0];const r=e.getBoundingClientRect();` + |
| 110 | `return {count:all.length,w:r.width,h:r.height,x:r.left+r.width/2,y:r.top+r.height/2}})()`, |
| 111 | ) |
| 112 | if (at.none) throw new Error(`no element matching ${selector}`) |
| 113 | if (at.count > 1) { |
| 114 | throw new Error( |
| 115 | `${at.count} elements match ${selector} — the selector no longer identifies one control, so this would click whichever comes first in the document`, |
| 116 | ) |
| 117 | } |
| 118 | if (at.w === 0 || at.h === 0) { |
| 119 | throw new Error( |
| 120 | `${selector} matched an element with no size (${at.w}x${at.h}) — it is hidden, so clicking its centre would click the page corner`, |
| 121 | ) |
| 122 | } |
| 123 | await click(at.x, at.y) |
| 124 | } |
| 125 | |
| 126 | async function editorHTML() { |
| 127 | return evaluate(`document.querySelector('[docs-editor]').innerHTML`) |
| 128 | } |
| 129 | |
| 130 | // pressKey dispatches one key the way a keyboard does. |
| 131 | // |
| 132 | // rawKeyDown rather than keyDown for the non-printing keys: a keyDown |
| 133 | // carrying no text is what CDP turns into a char event, and a stray |
| 134 | // char is how an Escape ends up inserting nothing visible while the |
| 135 | // handler that should have run never did. Printing keys pass `text` |
| 136 | // and get the char event they need. |
| 137 | // |
| 138 | // modifiers is CDP's bitfield — 2 is Control, which is the one the |
| 139 | // palette is bound to. |
| 140 | async function pressKey(key, { code, vk = 0, modifiers = 0, text } = {}) { |
| 141 | const base = { key, code: code || key, windowsVirtualKeyCode: vk, modifiers } |
| 142 | await send('Input.dispatchKeyEvent', { type: text ? 'keyDown' : 'rawKeyDown', ...base, ...(text ? { text } : {}) }) |
| 143 | await send('Input.dispatchKeyEvent', { type: 'keyUp', ...base }) |
| 144 | } |
| 145 | |
| 146 | // typeText types a string one character at a time, with a real gap |
| 147 | // between them. The palette debounces its query, so a burst delivered |
| 148 | // instantly is not the input it was written for. |
| 149 | async function typeText(text) { |
| 150 | for (const ch of text) { |
| 151 | await send('Input.dispatchKeyEvent', { type: 'keyDown', text: ch, key: ch }) |
| 152 | await send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch }) |
| 153 | await sleep(20) |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | const CONTROL = 2 |
| 158 | |
| 159 | // The editor writes through the CRDT and re-renders from it, so a |
| 160 | // change is not in the DOM the instant a command returns. settle waits |
| 161 | // for a condition rather than for a fixed delay, which is what keeps |
| 162 | // this from being flaky in one direction and slow in the other. |
| 163 | async function settle(describe, predicate, timeoutMs = 5000) { |
| 164 | const deadline = Date.now() + timeoutMs |
| 165 | let last = '' |
| 166 | for (;;) { |
| 167 | last = await editorHTML() |
| 168 | if (predicate(last)) return last |
| 169 | if (Date.now() > deadline) { |
| 170 | throw new Error(`timed out waiting for ${describe}\n document is: ${last.slice(0, 200)}`) |
| 171 | } |
| 172 | await sleep(150) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | // settleOn polls one probe until it satisfies a predicate. settle |
| 177 | // above watches the whole document; this watches one value, which is |
| 178 | // what an assertion about a single block needs. |
| 179 | async function settleOn(describe, probe, predicate, timeoutMs = 5000) { |
| 180 | const deadline = Date.now() + timeoutMs |
| 181 | let last |
| 182 | for (;;) { |
| 183 | last = await probe() |
| 184 | if (predicate(last)) return last |
| 185 | if (Date.now() > deadline) { |
| 186 | throw new Error(`timed out waiting for ${describe}\n it is: ${JSON.stringify(last)}`) |
| 187 | } |
| 188 | await sleep(150) |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | // settleScrollToTop blurs the editor, scrolls to the top, and waits |
| 193 | // until the position actually holds still. |
| 194 | // |
| 195 | // The page scrolls the window itself — verified, not assumed: nothing |
| 196 | // between [docs-page] and the document element is a scroll container, |
| 197 | // document.scrollingElement is the document element, and |
| 198 | // window.scrollTo moves window.scrollY. That matters because the |
| 199 | // no-yank check below asserts on window.scrollY, and on a layout where |
| 200 | // some inner element did the scrolling that check would pass while |
| 201 | // measuring nothing at all. |
| 202 | async function settleScrollToTop() { |
| 203 | await evaluate(`document.querySelector('[docs-editor]').blur();window.scrollTo(0,0)`) |
| 204 | for (let i = 0; i < 20; i++) { |
| 205 | await sleep(100) |
| 206 | if ((await evaluate(`window.scrollY`)) === 0) { |
| 207 | // Two consecutive readings, because a scroll still in flight |
| 208 | // reads zero once on its way past. |
| 209 | await sleep(100) |
| 210 | if ((await evaluate(`window.scrollY`)) === 0) return |
| 211 | } |
| 212 | await evaluate(`window.scrollTo(0,0)`) |
| 213 | } |
| 214 | throw new Error('the page would not stay scrolled to the top') |
| 215 | } |
| 216 | |
| 217 | // blockTag is the tag name of one top-level block of the document. |
| 218 | async function blockTag(index) { |
| 219 | return evaluate( |
| 220 | `(()=>{const b=document.querySelectorAll('[docs-editor] > *')[${index}];return b?b.tagName:null})()`, |
| 221 | ) |
| 222 | } |
| 223 | |
| 224 | const checks = [] |
| 225 | function check(name, ok, detail) { |
| 226 | checks.push({ name, ok, detail }) |
| 227 | console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok || !detail ? '' : ' — ' + detail}`) |
| 228 | } |
| 229 | |
| 230 | // group runs a set of checks so that one throwing does not delete the |
| 231 | // ones that come after it. |
| 232 | // |
| 233 | // Without this, a group that dies partway through simply stops, the |
| 234 | // later groups never run, and the summary line reports a smaller total |
| 235 | // that looks like a smaller suite rather than like missing coverage. |
| 236 | // The abort becomes its own named failure instead. |
| 237 | async function group(name, fn) { |
| 238 | try { |
| 239 | await fn() |
| 240 | } catch (err) { |
| 241 | check(`${name} ran to completion`, false, err.message) |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | // caretInfo reports where the caret is, read from the DOM selection |
| 246 | // rather than from ProseMirror's own state. |
| 247 | // |
| 248 | // The DOM is the honest probe: it is what a person actually sees, and |
| 249 | // it catches the case where the selection is set correctly in the |
| 250 | // editor's state but the DOM never followed. (Credited to the |
| 251 | // caret-placement session, which learned it the hard way.) |
| 252 | const CARET_INFO = `(()=>{ |
| 253 | const sel = document.getSelection() |
| 254 | const ed = document.querySelector('[docs-editor]') |
| 255 | if (!sel || sel.rangeCount === 0 || !ed) return null |
| 256 | const r = sel.getRangeAt(0) |
| 257 | const blocks = Array.from(ed.children) |
| 258 | const rect = r.getBoundingClientRect() |
| 259 | // The caret can sit ON the editor element itself, between two |
| 260 | // blocks, in which case startOffset is a child index rather than a |
| 261 | // text offset. Walking up from there runs straight past the editor |
| 262 | // and reports -1, which reads as "the caret went somewhere else |
| 263 | // entirely" when it did not. |
| 264 | if (r.startContainer === ed) { |
| 265 | return { blockIndex: r.startOffset, blockCount: blocks.length, offset: 0, |
| 266 | onBoundary: true, collapsed: r.collapsed, top: rect.top, bottom: rect.bottom } |
| 267 | } |
| 268 | let node = r.startContainer |
| 269 | while (node && node.parentElement && node.parentElement !== ed) node = node.parentElement |
| 270 | return { |
| 271 | blockIndex: blocks.indexOf(node), |
| 272 | blockCount: blocks.length, |
| 273 | offset: r.startOffset, |
| 274 | onBoundary: false, |
| 275 | inEditor: ed.contains(r.startContainer), |
| 276 | collapsed: r.collapsed, |
| 277 | top: rect.top, |
| 278 | bottom: rect.bottom, |
| 279 | } |
| 280 | })()` |
| 281 | |
| 282 | // pickLongestDocument opens the longest of the seeded documents. |
| 283 | // |
| 284 | // On a short one, the scroll-related checks below pass trivially |
| 285 | // because the whole document is already on screen — which is exactly |
| 286 | // how the caret session nearly convinced itself an unconditional focus |
| 287 | // handler was fine. Measured by fetching each document's own |
| 288 | // server-rendered page rather than by mounting the editor six times. |
| 289 | async function pickLongestDocument() { |
| 290 | return evaluate(`(async () => { |
| 291 | const hrefs = Array.from(document.querySelectorAll('a[href^="/d/"]')).map(a => a.getAttribute('href')) |
| 292 | let best = null, bestLen = -1 |
| 293 | for (const href of hrefs) { |
| 294 | const res = await fetch(href, { credentials: 'same-origin' }) |
| 295 | const html = await res.text() |
| 296 | if (html.length > bestLen) { bestLen = html.length; best = href } |
| 297 | } |
| 298 | return best |
| 299 | })()`) |
| 300 | } |
| 301 | |
| 302 | async function run() { |
| 303 | await send('Page.enable') |
| 304 | await send('Runtime.enable') |
| 305 | |
| 306 | // Sign in the way a person does: the emailed link opens the confirm |
| 307 | // door, which spends nothing, and its one Sign in button posts it. |
| 308 | if (signinLink) { |
| 309 | await send('Page.navigate', { url: signinLink }) |
| 310 | await sleep(1500) |
| 311 | await clickSelector('form[action="/auth/verify"] button[type="submit"]') |
| 312 | await sleep(1500) |
| 313 | } |
| 314 | await send('Page.navigate', { url: base + '/' }) |
| 315 | await sleep(1500) |
| 316 | const href = await pickLongestDocument() |
| 317 | if (!href) throw new Error('the document list has no documents to open') |
| 318 | await send('Page.navigate', { url: base + href }) |
| 319 | await sleep(2500) |
| 320 | |
| 321 | // editorChecks is the toolbar and editing section: it mounted, you |
| 322 | // can type, the buttons and selects act on a real selection, and undo |
| 323 | // undoes. |
| 324 | // |
| 325 | // EXTRACTED INTO A GROUP, which is the follow-up the comment at the |
| 326 | // call site used to describe and defer. It throws deliberately in two |
| 327 | // places — there is no point continuing without a selection — and |
| 328 | // while it was inline in run() those aborts took every group after it |
| 329 | // down with them. That cost was paid for real: a selector that had |
| 330 | // stopped being unique threw here, and the run reported 5/6 rather |
| 331 | // than one failure plus 38 other checks. The output read like a |
| 332 | // smaller suite instead of like missing coverage, which is the exact |
| 333 | // shape this file exists to refuse. |
| 334 | async function editorChecks() { |
| 335 | check('the editor mounted', await evaluate(`!!document.querySelector('[docs-editor] p')`)) |
| 336 | check('the formatting bar rendered', await evaluate(`!!document.querySelector('[docs-toolbar]')`)) |
| 337 | check( |
| 338 | 'the editor did not fall back to the read view', |
| 339 | !(await evaluate(`document.querySelector('[docs-editor]').hasAttribute('data-editor-failed')`)), |
| 340 | ) |
| 341 | |
| 342 | // Work on a paragraph chosen by its own type rather than on |
| 343 | // whatever happens to be first: these documents open with a heading, |
| 344 | // and a "make this a heading" check aimed at one is a no-op that |
| 345 | // passes. |
| 346 | const paragraphIndex = await evaluate( |
| 347 | `Array.from(document.querySelectorAll('[docs-editor] > *')).findIndex(e => e.tagName === 'P')`, |
| 348 | ) |
| 349 | if (paragraphIndex < 0) throw new Error('the document has no paragraph to edit') |
| 350 | |
| 351 | // Double-click a word: the ordinary way a person selects one. |
| 352 | // |
| 353 | // Tried at a few offsets along the line rather than at one fixed |
| 354 | // point. A double click that lands between words, on an indent, or |
| 355 | // past the end of a short line selects nothing — which is a fact |
| 356 | // about where the pointer went, not about the editor, and a check |
| 357 | // that cannot tell those apart is worse than no check. |
| 358 | let wordAt = null |
| 359 | let word = '' |
| 360 | for (const dx of [60, 120, 200, 300]) { |
| 361 | const at = await evaluate( |
| 362 | `(()=>{const e=document.querySelectorAll('[docs-editor] > *')[${paragraphIndex}];` + |
| 363 | `const r=e.getBoundingClientRect();const t=document.querySelector('[docs-toolbar]');` + |
| 364 | `const floor=t?t.getBoundingClientRect().bottom:0;const y=r.top+8;` + |
| 365 | `if(y<floor+4||y>window.innerHeight-4||${dx}>r.width-20) return null;` + |
| 366 | `return {x:r.left+${dx},y}})()`, |
| 367 | ) |
| 368 | if (!at) continue |
| 369 | await click(at.x, at.y, 1) |
| 370 | await click(at.x, at.y, 2) |
| 371 | await sleep(400) |
| 372 | word = await evaluate(`window.getSelection().toString()`) |
| 373 | if (word.trim().length > 0) { |
| 374 | wordAt = at |
| 375 | break |
| 376 | } |
| 377 | } |
| 378 | check('a double click selects a word', !!wordAt, `selected ${JSON.stringify(word)}`) |
| 379 | if (!wordAt) throw new Error('could not select a word to format') |
| 380 | |
| 381 | // Typing. This is the one that was broken, and it is not a toolbar |
| 382 | // feature at all — which is exactly why it is asserted here. |
| 383 | await send('Input.dispatchKeyEvent', { type: 'keyDown', text: 'Z', key: 'Z', code: 'KeyZ', windowsVirtualKeyCode: 90 }) |
| 384 | await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Z', code: 'KeyZ', windowsVirtualKeyCode: 90 }) |
| 385 | try { |
| 386 | await settle('the typed character to appear', (html) => html.includes('Z')) |
| 387 | check('typing changes the document', true) |
| 388 | } catch (err) { |
| 389 | check('typing changes the document', false, err.message) |
| 390 | } |
| 391 | |
| 392 | // Bold, through the toolbar, over a real selection. |
| 393 | await click(wordAt.x, wordAt.y, 1) |
| 394 | await click(wordAt.x, wordAt.y, 2) |
| 395 | await sleep(300) |
| 396 | await clickSelector('[docs-toolbar] [data-docs-command="strong"]') |
| 397 | try { |
| 398 | await settle('bold to apply', (html) => html.includes('<strong>')) |
| 399 | check('the bold button bolds the selection', true) |
| 400 | } catch (err) { |
| 401 | check('the bold button bolds the selection', false, err.message) |
| 402 | } |
| 403 | check( |
| 404 | 'the selection survives pressing a toolbar button', |
| 405 | (await evaluate(`window.getSelection().toString()`)).length > 0, |
| 406 | ) |
| 407 | |
| 408 | // A font mark, through the select, and the class it must produce — |
| 409 | // the same class core/src/fragment.rs renders server-side. |
| 410 | // |
| 411 | // Serif, not sans, and that is not interchangeable. Sans is the |
| 412 | // default now (fixtures/fonts.mjs's DEFAULT_FONT), and choosing the |
| 413 | // default CLEARS the group rather than setting a mark — so a drive |
| 414 | // that picked sans would wait for a class the control is correct never |
| 415 | // to write, and report the font control broken. Any non-default face |
| 416 | // makes the check honest; serif is the one this change made sayable. |
| 417 | await evaluate( |
| 418 | `(()=>{const s=document.querySelector('[data-docs-select="font"]');s.value='serif';` + |
| 419 | `s.dispatchEvent(new Event('change',{bubbles:true}));return true})()`, |
| 420 | ) |
| 421 | try { |
| 422 | await settle('the serif face to apply', (html) => html.includes('docs-font-serif')) |
| 423 | check('the font control applies the serif mark', true) |
| 424 | } catch (err) { |
| 425 | check('the font control applies the serif mark', false, err.message) |
| 426 | } |
| 427 | |
| 428 | // A heading, through the block select — asserted on the ONE block |
| 429 | // being changed, not on the document's markup as a whole. A seeded |
| 430 | // document that already contains an <h2> makes a |
| 431 | // "does the HTML contain <h2>" check pass without the control having |
| 432 | // done anything, and makes its undo impossible to observe. Both of |
| 433 | // those happened here, on the second document this drive was pointed |
| 434 | // at, which is a good argument for the longest fixture. |
| 435 | await evaluate( |
| 436 | `(()=>{const s=document.querySelector('[data-docs-select="block"]');s.value='heading-2';` + |
| 437 | `s.dispatchEvent(new Event('change',{bubbles:true}));return true})()`, |
| 438 | ) |
| 439 | try { |
| 440 | await settleOn(`the block to become a heading`, () => blockTag(paragraphIndex), (tag) => tag === 'H2') |
| 441 | check('the style control makes a heading', true) |
| 442 | } catch (err) { |
| 443 | check('the style control makes a heading', false, err.message) |
| 444 | } |
| 445 | |
| 446 | // Undo has to be available now, and has to walk the heading back. |
| 447 | check('undo becomes available after an edit', !(await evaluate( |
| 448 | `document.querySelector('[docs-toolbar] [data-docs-command="undo"]').disabled`, |
| 449 | ))) |
| 450 | await clickSelector('[docs-toolbar] [data-docs-command="undo"]') |
| 451 | try { |
| 452 | await settleOn(`the heading to be undone`, () => blockTag(paragraphIndex), (tag) => tag === 'P') |
| 453 | check('undo reverses the last change', true) |
| 454 | } catch (err) { |
| 455 | check('undo reverses the last change', false, err.message) |
| 456 | } |
| 457 | } |
| 458 | |
| 459 | |
| 460 | await group('the editor and toolbar checks', editorChecks) |
| 461 | |
| 462 | // Each of these is wrapped so that a failure inside one cannot take |
| 463 | // the checks after it down with it. Proven necessary rather than |
| 464 | // assumed: breaking the palette's Control-K binding threw on the |
| 465 | // first null it produced, and the run ended at 31 checks instead of |
| 466 | // 44 — with the 320px reflow checks never reached and simply absent |
| 467 | // from the output rather than reported as anything. A check that |
| 468 | // silently removes other checks when it fails is the same "cannot |
| 469 | // tell no from I can't see it" shape this file exists to avoid, so |
| 470 | // an aborted group is now itself a failed check that names the group. |
| 471 | // |
| 472 | // The toolbar section is a group too now. It used to be inline here |
| 473 | // and this comment said extracting it was a follow-up, on the |
| 474 | // reasoning that moving a hundred working checks while fixing them is |
| 475 | // how a harness stops being trustworthy. That was right, and the |
| 476 | // follow-up then demonstrated itself: an ambiguous selector threw |
| 477 | // inside that section and the run reported 5/6 — one failure, and 38 |
| 478 | // checks simply absent rather than failed or skipped. Moved on its |
| 479 | // own, with no change to the checks themselves. |
| 480 | await group('the caret-placement checks', caretChecks) |
| 481 | await group('the jump-to palette checks', paletteChecks) |
| 482 | await group('the 320px reflow checks', reflowChecks) |
| 483 | |
| 484 | check('the page reported no JavaScript errors', pageProblems.length === 0, pageProblems.join(' | ')) |
| 485 | } |
| 486 | |
| 487 | // The jump-to palette (Control-K), and the plain search URL under it. |
| 488 | // |
| 489 | // These are the seventeen checks discussion #14 ran and could not |
| 490 | // commit: the harness they belonged in was on another branch at the |
| 491 | // time, and shipping a second harness to avoid waiting would have been |
| 492 | // worse than waiting. Both branches have landed, so this is that fold — |
| 493 | // branch task 22. What protected the palette's keyboard contract until |
| 494 | // now was one person having run a scratch script once, which is exactly |
| 495 | // what this repo does not count as a gate. |
| 496 | // |
| 497 | // Two things are different from the scratch version, both because a |
| 498 | // check that depends on the fixture data is a check that fails for |
| 499 | // reasons that have nothing to do with the code: |
| 500 | // |
| 501 | // - The queries are DERIVED from the titles actually on the list, |
| 502 | // not typed in as "s" and "waterworks". Those worked against the |
| 503 | // six seeded documents and would go red the day somebody edits |
| 504 | // seeddata — reporting "the palette is broken" when it is not, |
| 505 | // which is the failure this file's own header warns about. |
| 506 | // - The single-result case queries a whole title, so the exact-match |
| 507 | // rank guarantees which row lands first. Asserting on "the first |
| 508 | // result" after a query that merely matches something is asserting |
| 509 | // on the ranker's tie-break by accident. |
| 510 | // |
| 511 | // It runs before reflowChecks because that one leaves a phone-sized |
| 512 | // viewport behind, and a 320px palette is a different check from this |
| 513 | // one. |
| 514 | async function paletteChecks() { |
| 515 | // Where the palette matters most: inside a document, which is where |
| 516 | // the run() above has already left us. A shortcut that works on the |
| 517 | // list and not in the editor is the case worth catching, because the |
| 518 | // editor is the page with a keymap of its own to collide with. |
| 519 | await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL }) |
| 520 | await sleep(400) |
| 521 | check('Control-K opens the palette inside a document', await evaluate(`!document.querySelector('[docs-jumpto]').hidden`)) |
| 522 | await pressKey('Escape', { vk: 27 }) |
| 523 | await sleep(300) |
| 524 | |
| 525 | // Everything else from the list. |
| 526 | await send('Page.navigate', { url: base + '/' }) |
| 527 | await sleep(1500) |
| 528 | |
| 529 | // The titles this instance actually has, with the hrefs, so every |
| 530 | // query below is derived rather than assumed. |
| 531 | const docs = await evaluate(`(() => { |
| 532 | return Array.from(document.querySelectorAll('a[href^="/d/"]')).map(a => ({ |
| 533 | href: a.getAttribute('href'), |
| 534 | title: a.textContent.trim(), |
| 535 | })).filter(d => d.title.length > 0) |
| 536 | })()`) |
| 537 | if (!docs || docs.length === 0) throw new Error('the document list has no titles to search for') |
| 538 | |
| 539 | // A query that matches more than one, found rather than guessed: the |
| 540 | // shortest single letter contained in at least two titles. The arrow |
| 541 | // keys need two options to move between, and "there are two" is a |
| 542 | // property of the data that this asks about instead of assuming. |
| 543 | const multi = await evaluate(`(() => { |
| 544 | const titles = ${JSON.stringify(docs.map((d) => d.title.toLowerCase()))} |
| 545 | for (const ch of 'etaoinsrhldcumfpgwybvkxjqz') { |
| 546 | if (titles.filter(t => t.includes(ch)).length >= 2) return ch |
| 547 | } |
| 548 | return null |
| 549 | })()`) |
| 550 | |
| 551 | // --- The no-JS path. Search is a plain page at a plain URL, and |
| 552 | // that is what lets the palette be an addition rather than the only |
| 553 | // way in. Asserted with the palette's own bundle irrelevant. |
| 554 | const one = docs[0] |
| 555 | const others = docs.slice(1).map((d) => d.title) |
| 556 | await send('Page.navigate', { url: base + '/?q=' + encodeURIComponent(one.title) }) |
| 557 | await sleep(1200) |
| 558 | const searched = await evaluate(`document.body.innerText`) |
| 559 | check('a search URL renders the matching document', searched.includes(one.title), searched.slice(0, 160)) |
| 560 | // Only meaningful if some other title would have shown on an |
| 561 | // unfiltered list — with one document there is nothing to hide, and |
| 562 | // a check that cannot fail is not a check. |
| 563 | const hidden = others.filter((t) => !searched.includes(t)) |
| 564 | check( |
| 565 | 'and hides the ones that do not match', |
| 566 | others.length === 0 || hidden.length > 0, |
| 567 | `none of the other ${others.length} titles were filtered out`, |
| 568 | ) |
| 569 | |
| 570 | await send('Page.navigate', { url: base + '/' }) |
| 571 | await sleep(1500) |
| 572 | |
| 573 | check( |
| 574 | 'the palette is on the page but hidden', |
| 575 | await evaluate(`(()=>{const e=document.querySelector('[docs-jumpto]');return !!e && e.hidden})()`), |
| 576 | ) |
| 577 | |
| 578 | await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL }) |
| 579 | await sleep(400) |
| 580 | check('Control-K opens the palette from the list', await evaluate(`!document.querySelector('[docs-jumpto]').hidden`)) |
| 581 | check( |
| 582 | 'and focus lands in the field', |
| 583 | await evaluate(`document.activeElement === document.querySelector('[docs-jumpto-input]')`), |
| 584 | ) |
| 585 | check( |
| 586 | 'aria-expanded says it is open', |
| 587 | (await evaluate(`document.querySelector('[docs-jumpto-input]').getAttribute('aria-expanded')`)) === 'true', |
| 588 | ) |
| 589 | |
| 590 | let options = 0 |
| 591 | if (multi) { |
| 592 | await typeText(multi) |
| 593 | try { |
| 594 | options = await settleOn( |
| 595 | 'results', |
| 596 | () => evaluate(`document.querySelectorAll('[docs-jumpto-list] li').length`), |
| 597 | (n) => n > 0, |
| 598 | ) |
| 599 | check('typing produces results', true) |
| 600 | } catch (err) { |
| 601 | check('typing produces results', false, err.message) |
| 602 | } |
| 603 | } else { |
| 604 | check('typing produces results', false, 'no letter is shared by two titles — cannot form a query') |
| 605 | } |
| 606 | |
| 607 | // Read through a null-safe probe rather than dereferencing the first |
| 608 | // <li> directly. With no results that dereference throws, and a throw |
| 609 | // here used to end the whole run — so the failure of one check |
| 610 | // deleted every check after it instead of reporting itself. |
| 611 | const firstOption = await evaluate(`(() => { |
| 612 | const li = document.querySelector('[docs-jumpto-list] li') |
| 613 | const input = document.querySelector('[docs-jumpto-input]') |
| 614 | return { |
| 615 | exists: !!li, |
| 616 | selected: li ? li.getAttribute('aria-selected') : null, |
| 617 | id: li ? li.id : null, |
| 618 | activeDescendant: input ? input.getAttribute('aria-activedescendant') : null, |
| 619 | } |
| 620 | })()`) |
| 621 | check( |
| 622 | 'the first result is the active option', |
| 623 | firstOption.selected === 'true', |
| 624 | firstOption.exists ? `aria-selected is ${firstOption.selected}` : 'there are no results at all', |
| 625 | ) |
| 626 | check( |
| 627 | 'aria-activedescendant names the active option', |
| 628 | firstOption.exists && firstOption.activeDescendant === firstOption.id, |
| 629 | `aria-activedescendant is ${firstOption.activeDescendant}, the first option's id is ${firstOption.id}`, |
| 630 | ) |
| 631 | |
| 632 | // Which option is selected, as an index — one probe, so a movement |
| 633 | // is asserted as "it moved from here to there" rather than as two |
| 634 | // separate facts about two separate elements. |
| 635 | const selectedIndex = () => |
| 636 | evaluate( |
| 637 | `Array.from(document.querySelectorAll('[docs-jumpto-list] li')).findIndex(l => l.getAttribute('aria-selected') === 'true')`, |
| 638 | ) |
| 639 | |
| 640 | if (options > 1) { |
| 641 | await pressKey('ArrowDown', { vk: 40 }) |
| 642 | await sleep(250) |
| 643 | const afterDown = await selectedIndex() |
| 644 | check('Down moves to the second result', afterDown === 1, `the selected option is ${afterDown}`) |
| 645 | |
| 646 | await pressKey('ArrowUp', { vk: 38 }) |
| 647 | await sleep(250) |
| 648 | const afterUp = await selectedIndex() |
| 649 | // Guarded on Down having actually moved. "The first option is |
| 650 | // selected" is true both when Up walked back to it and when |
| 651 | // nothing moved at all, so on its own this check passes against an |
| 652 | // arrow-key handler that does nothing — verified: breaking move() |
| 653 | // turned Down red and left this one green. It is only evidence |
| 654 | // when there was somewhere to come back FROM. |
| 655 | check( |
| 656 | 'Up moves back to the first', |
| 657 | afterDown === 1 && afterUp === 0, |
| 658 | afterDown !== 1 |
| 659 | ? `Down never moved (selected ${afterDown}), so returning to 0 proves nothing` |
| 660 | : `the selected option is ${afterUp}`, |
| 661 | ) |
| 662 | } else { |
| 663 | check('Down moves to the second result', false, `only ${options} result(s) to move through`) |
| 664 | check('Up moves back to the first', false, `only ${options} result(s) to move through`) |
| 665 | } |
| 666 | |
| 667 | // Escape closes and hands focus back. Tab is deliberately NOT a |
| 668 | // cycle here — the panel holds two focusable things, and a general |
| 669 | // focus trap is where a palette usually strands somebody. |
| 670 | await pressKey('Escape', { vk: 27 }) |
| 671 | await sleep(400) |
| 672 | check('Escape closes the palette', await evaluate(`document.querySelector('[docs-jumpto]').hidden`)) |
| 673 | |
| 674 | // Focus returns to WHERE IT CAME FROM, asserted by naming that |
| 675 | // element rather than by observing that it is no longer in the |
| 676 | // field. |
| 677 | // |
| 678 | // "activeElement is not the palette input" was the obvious way to |
| 679 | // write this and it is worthless: closing the palette hides the |
| 680 | // input, so focus leaves it whether or not anything put focus back, |
| 681 | // and the check passes either way. Verified — deleting the |
| 682 | // returnFocusTo.focus() call left it green at 44/44, which is a |
| 683 | // check that cannot tell "no" from "I can't see it", and this file |
| 684 | // exists to not do that. |
| 685 | // |
| 686 | // So focus a known element first, open from there, and assert that |
| 687 | // Escape brings focus back to that exact element. A keyboard user |
| 688 | // who opens the palette from the search field and dismisses it |
| 689 | // should be back in the search field, not at the top of the page. |
| 690 | const returned = await evaluate(`(() => { |
| 691 | const f = document.querySelector('[docs-search-input]') |
| 692 | if (!f) return null |
| 693 | f.focus() |
| 694 | return document.activeElement === f |
| 695 | })()`) |
| 696 | if (returned) { |
| 697 | await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL }) |
| 698 | await sleep(400) |
| 699 | await pressKey('Escape', { vk: 27 }) |
| 700 | await sleep(400) |
| 701 | check( |
| 702 | 'and focus goes back to whatever opened it', |
| 703 | await evaluate(`document.activeElement === document.querySelector('[docs-search-input]')`), |
| 704 | `focus is on ${await evaluate(`document.activeElement ? document.activeElement.tagName + (document.activeElement.className || '') : 'nothing'`)}`, |
| 705 | ) |
| 706 | } else { |
| 707 | check('and focus goes back to whatever opened it', false, 'no [docs-search-input] to open the palette from') |
| 708 | } |
| 709 | |
| 710 | // A query matching nothing says so rather than showing an empty box. |
| 711 | // |
| 712 | // Compared against the status element's own data-empty, NOT merely |
| 713 | // "some text appeared". The palette writes two different strings |
| 714 | // into that element — data-empty when nothing matched, data-failed |
| 715 | // when the fetch threw — so "text is non-empty" passes just as well |
| 716 | // when the search is BROKEN and the person is being told so. That is |
| 717 | // the wrong answer under a check whose name claims to be about the |
| 718 | // empty case. |
| 719 | await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL }) |
| 720 | await sleep(300) |
| 721 | await typeText('zzzznothingmatchesthis') |
| 722 | try { |
| 723 | const said = await settleOn( |
| 724 | 'the empty-result message', |
| 725 | () => evaluate(`(()=>{const s=document.querySelector('[docs-jumpto-status]');return s.hidden?'':s.textContent})()`), |
| 726 | (t) => t.length > 0, |
| 727 | ) |
| 728 | const expected = await evaluate( |
| 729 | `document.querySelector('[docs-jumpto-status]').getAttribute('data-empty')`, |
| 730 | ) |
| 731 | check( |
| 732 | 'a query matching nothing says so', |
| 733 | said.trim() === (expected || '').trim(), |
| 734 | `it says ${JSON.stringify(said)}; the empty-result copy is ${JSON.stringify(expected)}`, |
| 735 | ) |
| 736 | } catch (err) { |
| 737 | check('a query matching nothing says so', false, err.message) |
| 738 | } |
| 739 | |
| 740 | // Closing on that message and reopening must not still be saying it. |
| 741 | // |
| 742 | // This one is a regression check rather than a contract check: the |
| 743 | // Fable review of #14 found the palette reopening over an EMPTY query |
| 744 | // still showing "No documents match", and because the status is a |
| 745 | // live region it announced the stale answer again to a screen reader |
| 746 | // — an answer to a question nobody had asked yet. |
| 747 | await pressKey('Escape', { vk: 27 }) |
| 748 | await sleep(300) |
| 749 | await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL }) |
| 750 | await sleep(400) |
| 751 | const stale = await evaluate( |
| 752 | `(()=>{const s=document.querySelector('[docs-jumpto-status]');return s.hidden?'':s.textContent.trim()})()`, |
| 753 | ) |
| 754 | check('reopening it does not repeat the last empty-result message', stale === '', `it still says ${JSON.stringify(stale)}`) |
| 755 | check( |
| 756 | 'and it reopens with an empty field', |
| 757 | (await evaluate(`document.querySelector('[docs-jumpto-input]').value`)) === '', |
| 758 | ) |
| 759 | |
| 760 | // Enter opens the chosen document. Queried by a whole title, so the |
| 761 | // exact-match rank decides which row is first rather than the |
| 762 | // tie-break happening to. |
| 763 | await typeText(one.title) |
| 764 | try { |
| 765 | await settleOn( |
| 766 | 'a result for a whole title', |
| 767 | () => evaluate(`document.querySelectorAll('[docs-jumpto-list] li').length`), |
| 768 | (n) => n > 0, |
| 769 | ) |
| 770 | const target = await evaluate( |
| 771 | `(()=>{const l=document.querySelector('[docs-jumpto-list] li');return l?l.dataset.href:null})()`, |
| 772 | ) |
| 773 | check('the exact title ranks first', target === one.href, `first result was ${target}, wanted ${one.href}`) |
| 774 | await pressKey('Enter', { vk: 13 }) |
| 775 | await sleep(2000) |
| 776 | const url = await evaluate(`location.pathname`) |
| 777 | check('Enter opens the chosen document', url === target, `went to ${url}, wanted ${target}`) |
| 778 | } catch (err) { |
| 779 | check('the exact title ranks first', false, err.message) |
| 780 | check('Enter opens the chosen document', false, err.message) |
| 781 | } |
| 782 | } |
| 783 | |
| 784 | // 320px reflow — WCAG 2.2 SC 1.4.10, which AGENTS.md takes with no |
| 785 | // exemptions. |
| 786 | // |
| 787 | // Last, and it changes the viewport, so nothing above it may depend on |
| 788 | // the window size afterwards. The criterion is binary: six pixels of |
| 789 | // horizontal scroll fails it exactly as a hundred would, and six is |
| 790 | // what the formatting bar shipped with — three selects in a group that |
| 791 | // could not break inside itself, ending at x=326 in a 320px viewport. |
| 792 | // It reached main, because nothing here was looking. |
| 793 | // |
| 794 | // The check names the offending element rather than reporting a |
| 795 | // number, because "something overflows" sends the next person hunting |
| 796 | // through a stylesheet and "input[docs-search-input] ends at 356" does |
| 797 | // not. |
| 798 | // |
| 799 | // Both pages, not just the document. The list page's own overflow — |
| 800 | // 36px, from a search form that claimed a fixed field width inside a |
| 801 | // row it had told not to wrap — was found only because this walks |
| 802 | // every page rather than the one that was being worked on. |
| 803 | async function reflowChecks() { |
| 804 | await send('Emulation.setDeviceMetricsOverride', { |
| 805 | width: 320, height: 720, deviceScaleFactor: 1, mobile: true, |
| 806 | }) |
| 807 | try { |
| 808 | for (const [name, path] of [['the document list', '/'], ['a document', null]]) { |
| 809 | if (path) { |
| 810 | await send('Page.navigate', { url: base + path }) |
| 811 | await sleep(1500) |
| 812 | } else { |
| 813 | const href = await evaluate( |
| 814 | `(()=>{const a=document.querySelector('a[href^="/d/"]');return a?a.getAttribute('href'):null})()`, |
| 815 | ) |
| 816 | await send('Page.navigate', { url: base + href }) |
| 817 | await sleep(2500) |
| 818 | } |
| 819 | const found = await evaluate(`(() => { |
| 820 | const vw = document.documentElement.clientWidth |
| 821 | const over = [] |
| 822 | for (const el of document.querySelectorAll('*')) { |
| 823 | const r = el.getBoundingClientRect() |
| 824 | if (r.width === 0 && r.height === 0) continue |
| 825 | if (r.right > vw + 0.5 || r.left < -0.5) { |
| 826 | let depth = 0 |
| 827 | for (let n = el; n.parentElement; n = n.parentElement) depth++ |
| 828 | over.push({ |
| 829 | d: depth, |
| 830 | what: el.tagName.toLowerCase() + |
| 831 | (el.getAttributeNames().filter(a => a.startsWith('docs-')).map(a => '[' + a + ']').join('') || ''), |
| 832 | right: Math.round(r.right), |
| 833 | }) |
| 834 | } |
| 835 | } |
| 836 | over.sort((a, b) => b.d - a.d) |
| 837 | return { scrollWidth: document.documentElement.scrollWidth, viewport: vw, worst: over.slice(0, 3) } |
| 838 | })()`) |
| 839 | // The viewport is asserted to BE 320, not merely used as the |
| 840 | // yardstick. Everything here measures against |
| 841 | // documentElement.clientWidth, and that reads 320 only because |
| 842 | // rastrillo's vendored topbar layout emits |
| 843 | // <meta name="viewport" content="width=device-width"> — nothing |
| 844 | // in this app does. Lose that meta on a module upgrade and |
| 845 | // Chrome's mobile emulation lays out at 980px, clientWidth reads |
| 846 | // 980, and both of these checks pass while measuring a |
| 847 | // desktop-width page: a green result that has stopped looking at |
| 848 | // the thing it is named for. |
| 849 | check( |
| 850 | `${name} does not scroll sideways at 320px`, |
| 851 | found.viewport === 320 && found.scrollWidth <= found.viewport && found.worst.length === 0, |
| 852 | found.viewport !== 320 |
| 853 | ? `the page laid out at ${found.viewport}px, not 320 — check the viewport meta tag; this check measured nothing` |
| 854 | : `scrollWidth ${found.scrollWidth} vs viewport ${found.viewport}; ` + |
| 855 | found.worst.map((o) => `${o.what} ends at ${o.right}`).join(', '), |
| 856 | ) |
| 857 | } |
| 858 | } finally { |
| 859 | // Always put the viewport back, even if a check threw — a later |
| 860 | // reader running this by hand should not inherit a phone. |
| 861 | await send('Emulation.clearDeviceMetricsOverride') |
| 862 | } |
| 863 | } |
| 864 | |
| 865 | // The caret-placement checks. |
| 866 | // |
| 867 | // These cover fd1d21a (clicking the page margins places the caret, and |
| 868 | // the focus ring is gone from the editable surface), which shipped with |
| 869 | // no regression coverage because it was verified with throwaway |
| 870 | // scripts. The assertions are the ones that branch's author actually |
| 871 | // ran, contributed to this harness afterwards; the reasoning in each |
| 872 | // comment is theirs. |
| 873 | async function caretChecks() { |
| 874 | const page = '[docs-page]' |
| 875 | const ed = '[docs-editor]' |
| 876 | |
| 877 | // 1. A click in the left margin, level with a block, puts the caret |
| 878 | // at the START of that block. "Is the editor focused" is not the |
| 879 | // assertion — that passes even when the caret went to the end of |
| 880 | // the document, which is the bug. |
| 881 | // Settle the scroll position BEFORE measuring. Every toolbar command |
| 882 | // ends in view.focus(), and a focus the editor did not attribute to a |
| 883 | // pointer scrolls the caret into view — so a coordinate measured |
| 884 | // immediately after one can be stale by the time it is clicked, and |
| 885 | // the click then lands on the sticky formatting bar instead of the |
| 886 | // paper. That produced a failure that looked exactly like broken |
| 887 | // caret placement and was not. |
| 888 | await settleScrollToTop() |
| 889 | // Aim at a block that is actually on screen and clear of the sticky |
| 890 | // formatting bar, rather than at a fixed index. A point below the |
| 891 | // viewport receives the click and does nothing at all — which looks |
| 892 | // identical to the caret being placed wrongly, and is the trap this |
| 893 | // check exists to avoid rather than to fall into. Which block it |
| 894 | // lands on is not the point; that it lands at the START of the one |
| 895 | // beside the pointer is. |
| 896 | const target = await evaluate( |
| 897 | `(()=>{const p=document.querySelector('${page}');const t=document.querySelector('[docs-toolbar]');` + |
| 898 | `const floor=t?t.getBoundingClientRect().bottom:0;` + |
| 899 | `const blocks=Array.from(document.querySelectorAll('${ed} > *'));` + |
| 900 | `for(let i=0;i<blocks.length;i++){const br=blocks[i].getBoundingClientRect();` + |
| 901 | `if(br.top>floor+12 && br.bottom<window.innerHeight-12 && blocks[i].textContent.trim().length>0){` + |
| 902 | `const pr=p.getBoundingClientRect();return {index:i,x:pr.left+20,y:br.top+8}}}return null})()`, |
| 903 | ) |
| 904 | if (!target) throw new Error('no block is both on screen and clear of the formatting bar') |
| 905 | await click(target.x, target.y) |
| 906 | await sleep(400) |
| 907 | const fromMargin = await evaluate(CARET_INFO) |
| 908 | check( |
| 909 | 'a click in the left margin puts the caret at the start of that line', |
| 910 | !!fromMargin && fromMargin.blockIndex === target.index && fromMargin.offset === 0, |
| 911 | `aimed at block ${target.index}, got ${JSON.stringify(fromMargin)}`, |
| 912 | ) |
| 913 | |
| 914 | // 2. A click in the empty space below the last paragraph but still |
| 915 | // inside the editable node. Only meaningful once scrolled to the |
| 916 | // bottom — a click below the viewport lands on nothing at all, |
| 917 | // which reads exactly like the bug it is supposed to catch. |
| 918 | await evaluate(`window.scrollTo(0, document.body.scrollHeight)`) |
| 919 | await sleep(400) |
| 920 | const belowText = await evaluate( |
| 921 | `(()=>{const e=document.querySelector('${ed}');const r=e.getBoundingClientRect();` + |
| 922 | `const y=r.bottom-12;if(y<0||y>window.innerHeight) return null;return {x:r.left+40,y}})()`, |
| 923 | ) |
| 924 | if (belowText) { |
| 925 | await click(belowText.x, belowText.y) |
| 926 | await sleep(400) |
| 927 | const c = await evaluate(CARET_INFO) |
| 928 | check( |
| 929 | 'a click below the last paragraph puts the caret in the last block', |
| 930 | !!c && c.blockIndex === c.blockCount - 1, |
| 931 | JSON.stringify(c), |
| 932 | ) |
| 933 | } else { |
| 934 | check('a click below the last paragraph puts the caret in the last block', false, 'the point was off screen') |
| 935 | } |
| 936 | |
| 937 | // 3. A click in the page's bottom margin, outside the editable node |
| 938 | // entirely. This is the path that goes through placeCaretFromPage |
| 939 | // rather than through ProseMirror's own hit testing. |
| 940 | const bottomMargin = await evaluate( |
| 941 | `(()=>{const p=document.querySelector('${page}');const e=document.querySelector('${ed}');` + |
| 942 | `const pr=p.getBoundingClientRect();const er=e.getBoundingClientRect();` + |
| 943 | `const y=(er.bottom+pr.bottom)/2;if(y<0||y>window.innerHeight) return null;return {x:pr.left+80,y}})()`, |
| 944 | ) |
| 945 | if (bottomMargin) { |
| 946 | await click(bottomMargin.x, bottomMargin.y) |
| 947 | await sleep(400) |
| 948 | const c = await evaluate(CARET_INFO) |
| 949 | check( |
| 950 | 'a click in the bottom margin puts the caret in the last block', |
| 951 | !!c && c.blockIndex === c.blockCount - 1, |
| 952 | JSON.stringify(c), |
| 953 | ) |
| 954 | } else { |
| 955 | check('a click in the bottom margin puts the caret in the last block', false, 'the point was off screen') |
| 956 | } |
| 957 | |
| 958 | // 4. And typing then lands THERE, not merely somewhere. The last |
| 959 | // element's own text, not the whole editor's, or a character that |
| 960 | // went to the top of the document would still pass. |
| 961 | await send('Input.dispatchKeyEvent', { type: 'keyDown', text: 'Q', key: 'Q', code: 'KeyQ', windowsVirtualKeyCode: 81 }) |
| 962 | await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Q', code: 'KeyQ', windowsVirtualKeyCode: 81 }) |
| 963 | await sleep(1200) |
| 964 | const lastBlockText = await evaluate(`document.querySelector('${ed}').lastElementChild.textContent`) |
| 965 | check('typing after that click lands in the last block', lastBlockText.includes('Q')) |
| 966 | |
| 967 | // 5. The no-yank regression, and the one most worth keeping. A focus |
| 968 | // event fires BEFORE ProseMirror sets the selection a click asked |
| 969 | // for, so a focus handler that scrolls unconditionally yanks the |
| 970 | // view to wherever the selection was last left. With the selection |
| 971 | // at the end of a long document, clicking in the first block must |
| 972 | // leave the page where it is. |
| 973 | await settleScrollToTop() |
| 974 | const firstBlock = await evaluate( |
| 975 | `(()=>{const b=document.querySelector('${ed} > *');const r=b.getBoundingClientRect();return {x:r.left+40,y:r.top+8}})()`, |
| 976 | ) |
| 977 | await click(firstBlock.x, firstBlock.y) |
| 978 | await sleep(600) |
| 979 | const scrolledTo = await evaluate(`window.scrollY`) |
| 980 | check( |
| 981 | 'clicking near the top does not yank the view to the old selection', |
| 982 | scrolledTo < 40, |
| 983 | `scrollY became ${scrolledTo}`, |
| 984 | ) |
| 985 | |
| 986 | // 6. The other half of that trade: the editable surface has no focus |
| 987 | // ring (views/show.html removes it, on the argument that a caret |
| 988 | // says more), so a focus that did NOT come from a pointer has to |
| 989 | // scroll the caret into view — otherwise focus lands with no |
| 990 | // visible indicator anywhere on screen, which is a WCAG 2.2 AA |
| 991 | // regression and AGENTS.md allows no exemptions. |
| 992 | // |
| 993 | // The caret session ran this as Tab from the sign-out button. That |
| 994 | // no longer reaches the editor: the formatting bar sits between |
| 995 | // the header and the paper, so Tab from sign-out now lands on the |
| 996 | // toolbar. The guarantee being checked is not about the tab order |
| 997 | // though — it is "a non-pointer focus scrolls the caret into |
| 998 | // view" — so it is exercised directly here, and stays true however |
| 999 | // the tab order changes next. |
| 1000 | await settleScrollToTop() |
| 1001 | await evaluate(`document.querySelector('${ed}').focus()`) |
| 1002 | await sleep(700) |
| 1003 | const caret = await evaluate(CARET_INFO) |
| 1004 | check( |
| 1005 | 'a keyboard focus scrolls the caret into view', |
| 1006 | !!caret && caret.top >= 0 && caret.bottom <= (await evaluate(`window.innerHeight`)), |
| 1007 | JSON.stringify(caret), |
| 1008 | ) |
| 1009 | |
| 1010 | check( |
| 1011 | 'the editable surface draws no focus ring (the caret is the indicator)', |
| 1012 | (await evaluate(`getComputedStyle(document.querySelector('${ed}')).outlineStyle`)) === 'none', |
| 1013 | ) |
| 1014 | |
| 1015 | // 7. And the document has to be REACHABLE by keyboard from outside |
| 1016 | // it. The check above focuses the editor programmatically, which |
| 1017 | // succeeds whether or not a person could ever have got there with |
| 1018 | // Tab — so if the formatting bar ever traps focus, every other |
| 1019 | // check here still passes while the document has become |
| 1020 | // unreachable to anyone not using a mouse. (The gap was spotted |
| 1021 | // by the caret-placement session; it is exactly the shape |
| 1022 | // AGENTS.md names, a check that cannot tell "no" from "I cannot |
| 1023 | // see it".) |
| 1024 | // |
| 1025 | // Bounded rather than exact: the bar is one tab stop today |
| 1026 | // (toolbar.mjs's roving tabindex), but asserting an exact number |
| 1027 | // would break the week someone adds a control outside the bar for |
| 1028 | // a reason that has nothing to do with this. Twelve is generous |
| 1029 | // and still fails loudly the day focus cannot get out of the |
| 1030 | // toolbar at all. |
| 1031 | // |
| 1032 | // Starts from the shell bar's brand link, and that is not a free |
| 1033 | // choice. It used to start from the sign-out button, which was in |
| 1034 | // this page's own header; sign-out is now inside the shell's |
| 1035 | // account dropdown, a CLOSED <details>, whose content is in the |
| 1036 | // DOM but is not rendered — so querySelector still finds the |
| 1037 | // button, .focus() is a silent no-op, and the loop below counted |
| 1038 | // Tab presses from wherever focus happened to be already (the |
| 1039 | // editor, from check 6 above). It reported 8, which was a true |
| 1040 | // measurement of nothing. That is the failure shape AGENTS.md |
| 1041 | // names twice, so: the anchor must be something always rendered. |
| 1042 | // [estilo-bar-brand] is — the estilo bar's brand link, the first |
| 1043 | // focusable thing in the bar at every width (it was rastrillo's |
| 1044 | // [rst-shell-brand] until round 2 replaced the shell) — and the |
| 1045 | // assertion below fails if it ever stops being focusable, rather |
| 1046 | // than quietly measuring from somewhere else. |
| 1047 | const anchored = await evaluate( |
| 1048 | `(()=>{const b=document.querySelector('[estilo-bar-brand]');if(!b)return false;b.focus();` + |
| 1049 | `return document.activeElement===b})()`, |
| 1050 | ) |
| 1051 | check( |
| 1052 | 'the tab-order check starts where it thinks it does', |
| 1053 | anchored === true, |
| 1054 | 'could not focus [estilo-bar-brand] — the count below would measure from somewhere else', |
| 1055 | ) |
| 1056 | // How many tab stops the bar itself has after the brand, counted from |
| 1057 | // the live page rather than pinned: the title field and its Rename |
| 1058 | // button, Share…, the switcher when configured, the account trigger — |
| 1059 | // not the rows inside a closed panel, which Chrome still lays out. |
| 1060 | // Pinning a total is what made the old bound go stale when the bar |
| 1061 | // changed; what check 8 below holds is the claim that matters — the menu |
| 1062 | // bar and the formatting bar are ONE stop each. |
| 1063 | const barStops = await evaluate( |
| 1064 | `(()=>{const bar=document.querySelector('[estilo-bar]');const brand=bar.querySelector('[estilo-bar-brand]');` + |
| 1065 | `return [...bar.querySelectorAll('a[href],button,input,summary')].filter(e=>{const shut=e.closest('details:not([open])');return e!==brand&&e.tabIndex>=0&&!e.disabled&&(!shut||e.matches('summary')&&e.parentElement===shut)&&e.getClientRects().length>0&&getComputedStyle(e).visibility!=='hidden'}).length})()`, |
| 1066 | ) |
| 1067 | await sleep(200) |
| 1068 | let reachedAfter = -1 |
| 1069 | for (let i = 1; i <= 12; i++) { |
| 1070 | await send('Input.dispatchKeyEvent', { type: 'rawKeyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }) |
| 1071 | await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }) |
| 1072 | await sleep(120) |
| 1073 | if (await evaluate(`document.activeElement === document.querySelector('${ed}')`)) { |
| 1074 | reachedAfter = i |
| 1075 | break |
| 1076 | } |
| 1077 | } |
| 1078 | check( |
| 1079 | 'the document is reachable by Tab from the shell bar', |
| 1080 | reachedAfter > 0, |
| 1081 | reachedAfter > 0 ? '' : 'twelve presses of Tab never reached the editor', |
| 1082 | ) |
| 1083 | |
| 1084 | // 8. And the bar is ONE stop on the way, not one per control. |
| 1085 | // role="toolbar" is a promise that the arrow keys move within it; |
| 1086 | // a bar that spends fourteen tab presses has made that promise |
| 1087 | // and broken it. |
| 1088 | // |
| 1089 | // Four, derived rather than chosen, at this drive's 1280px (above |
| 1090 | // the shell's own 800px breakpoint, so the bar is laid out wide: |
| 1091 | // the hamburger is hidden and the tail is display: contents). |
| 1092 | // (History: the paragraphs below derived a fixed four for the |
| 1093 | // rastrillo shell. Round 2 counts the bar's own stops from the page |
| 1094 | // instead; see barStops above and the check at the end.) |
| 1095 | // From [rst-shell-brand], in DOM order: the account summary (1), |
| 1096 | // the menu bar, entire, as one roving-tabindex stop (2), the |
| 1097 | // formatting bar, entire, as one roving-tabindex stop (3), the |
| 1098 | // editor (4). |
| 1099 | // |
| 1100 | // It was three when the path was sign-out -> bar -> editor and |
| 1101 | // this page drew its own header, then five when the two exports |
| 1102 | // moved into the bar's trailing end and the account control moved |
| 1103 | // into the shell. |
| 1104 | // |
| 1105 | // It is four now, and it went DOWN while the page gained a whole |
| 1106 | // menu bar, which is the reason to re-derive rather than nudge. A |
| 1107 | // menu bar adds one stop; the same change took the two export |
| 1108 | // links out of the tab order entirely, because they are File menu |
| 1109 | // items inside a closed <details> and a closed disclosure's |
| 1110 | // contents are not focusable. Nudging 5 upward to admit the new |
| 1111 | // control would have left a bound that passes whether the order is |
| 1112 | // right or wrong by two. |
| 1113 | // |
| 1114 | // ONE STOP FOR THE MENU BAR IS THE ENHANCED NUMBER. With no |
| 1115 | // JavaScript the bar is six <details> summaries and six stops, so |
| 1116 | // the count would be nine. This drive runs with scripts, so four |
| 1117 | // is what it should see — but the difference is real, this app |
| 1118 | // renders without JavaScript on purpose, and a reader of this |
| 1119 | // check is entitled to know which of the two numbers it pins. |
| 1120 | // |
| 1121 | // DERIVED, NOT MEASURED. Chromium cannot start in the sandbox this |
| 1122 | // was written in (scripts/browser-drive.sh's own note: "socket() |
| 1123 | // failed: Operation not permitted"), so the four above comes from |
| 1124 | // reading the DOM order in views/show.html rather than from a run. |
| 1125 | // The first real run of this check is therefore a check on this |
| 1126 | // derivation as much as on the page; if it reports five, the extra |
| 1127 | // stop is the thing to find, not the bound to raise. |
| 1128 | // Round 2 (estilo bar): the stops are the bar's own (barStops, |
| 1129 | // counted above from the page), then the menu bar as one stop, the |
| 1130 | // formatting bar as one stop, then the editor. So the editor is |
| 1131 | // exactly barStops + 3 presses away, and a menu bar or toolbar that |
| 1132 | // leaks a second stop makes it barStops + 4 or more. |
| 1133 | check( |
| 1134 | 'the menu bar and the formatting bar are one tab stop each', |
| 1135 | reachedAfter > 0 && reachedAfter === barStops + 3, |
| 1136 | `the editor was ${reachedAfter} tab presses from the brand; the bar has ${barStops} stops of its own, so ${barStops + 3} was expected`, |
| 1137 | ) |
| 1138 | |
| 1139 | // 9. ...and the arrow keys are what move inside it. |
| 1140 | await evaluate( |
| 1141 | `(()=>{const c=document.querySelector('[docs-toolbar] [data-docs-command],[docs-toolbar] [data-docs-select]');` + |
| 1142 | `const list=Array.from(document.querySelectorAll('[docs-toolbar] [data-docs-command],[docs-toolbar] [data-docs-select]'))` + |
| 1143 | `.filter(e=>!e.disabled);if(list[0])list[0].focus();return true})()`, |
| 1144 | ) |
| 1145 | await sleep(150) |
| 1146 | const before = await evaluate(`document.activeElement.getAttribute('data-docs-command')||document.activeElement.getAttribute('data-docs-select')`) |
| 1147 | await send('Input.dispatchKeyEvent', { type: 'rawKeyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }) |
| 1148 | await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }) |
| 1149 | await sleep(200) |
| 1150 | const after = await evaluate(`document.activeElement.getAttribute('data-docs-command')||document.activeElement.getAttribute('data-docs-select')`) |
| 1151 | check( |
| 1152 | 'the arrow keys move between the toolbar controls', |
| 1153 | !!before && !!after && before !== after, |
| 1154 | `focus went from ${before} to ${after}`, |
| 1155 | ) |
| 1156 | } |
| 1157 | |
| 1158 | ws.onopen = async () => { |
| 1159 | try { |
| 1160 | await run() |
| 1161 | } catch (err) { |
| 1162 | check('the drive ran to completion', false, err.message) |
| 1163 | } |
| 1164 | const failed = checks.filter((c) => !c.ok) |
| 1165 | console.log(`\n${checks.length - failed.length}/${checks.length} checks passed`) |
| 1166 | ws.close() |
| 1167 | process.exit(failed.length === 0 ? 0 : 1) |
| 1168 | } |
| 1169 | |
| 1170 | ws.onerror = () => { |
| 1171 | console.error('browser-drive: could not connect to the browser at ' + wsUrl) |
| 1172 | process.exit(2) |
| 1173 | } |
| 1174 | |