// fixtures/browser-drive.mjs — drive the real editor in a real // browser, and assert what a person would actually see. // // Spec §12 asks for "a browser drive of the editor itself, because a JS // syntax error takes the whole client down". This is the first one, and // its first run earned its place: the toolbar branch had made the // editor silently discard every edit — typing did nothing, bold did // nothing, no error anywhere — and the Go suite was green throughout, // because nothing in it runs the bundle. A screenshot did not show it // either: the bar renders correctly and the document looks untouched // because it IS untouched. // // Run it through scripts/browser-drive.sh, which brings up a scratch // server and a headless Chrome around it. This file only speaks the // DevTools protocol, over Node's own WebSocket — no puppeteer, no // playwright, nothing to install (the same "no build step, no new // dependency" constraint the editor bundle itself lives under). // // One limitation to know before extending this to two pages (a // cross-tab check wants one): headless Chromium refuses more than one // startup target — "Multiple targets are not supported in headless // mode" — so a second tab has to be opened through CDP // (Target.createTarget) or the /json/new endpoint, not by passing a // second URL on the command line. Found by the live-sync session. // // It uses REAL input events, not element.click() and not a synthetic // DOM Range. Both of those skip what the editor actually listens to: a // Range set by script is not the selection ProseMirror reads, and a // click() with no mousedown skips the preventDefault the toolbar // depends on to keep the selection alive while the button is pressed. // A drive that fakes its input tests the fake. const [, , wsUrl, base, signinLink] = process.argv if (!wsUrl || !base) { console.error('usage: node browser-drive.mjs [sign-in-link]') process.exit(2) } const ws = new WebSocket(wsUrl) let nextId = 0 const pending = new Map() const pageProblems = [] function send(method, params = {}) { const id = ++nextId return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }) ws.send(JSON.stringify({ id, method, params })) }) } ws.onmessage = (e) => { const msg = JSON.parse(e.data) if (msg.id && pending.has(msg.id)) { const { resolve, reject } = pending.get(msg.id) pending.delete(msg.id) if (msg.error) reject(new Error(JSON.stringify(msg.error))) else resolve(msg.result) return } // A JS error in the bundle is the failure §12 names first, so it is // collected here rather than left to show up as "nothing happened". if (msg.method === 'Runtime.exceptionThrown') { const d = msg.params.exceptionDetails pageProblems.push('uncaught: ' + (d.exception?.description || d.text || '').split('\n')[0]) } if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') { pageProblems.push('console.error: ' + msg.params.args.map((a) => a.description || a.value).join(' ')) } } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) async function evaluate(expression) { const res = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }) if (res.exceptionDetails) { throw new Error('page threw: ' + (res.exceptionDetails.exception?.description || '').split('\n')[0]) } return res.result.value } async function click(x, y, clickCount = 1) { await send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount, buttons: 1 }) await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount, buttons: 0 }) } // clickSelector clicks the centre of the first element matching a // selector — and refuses two things that used to fail much later and // much less clearly. // // A ZERO-SIZE TARGET IS AN ERROR, not a click at (0,0). This cost five // checks and an hour. The menu bar gave a second home to every // data-docs-command name, and menu items come before the toolbar in // document order, so `[data-docs-command="strong"]` started matching a // menu item inside a closed
— display: none, rect all zeros. // Every click went to the top-left corner of the page, and the checks // failed thirty seconds later saying "timed out waiting for bold to // apply", which is true and points nowhere near the cause. Refusing the // click names it instead. // // MORE THAN ONE MATCH IS ALSO AN ERROR, because that is the same bug // one step earlier: the selector stopped identifying one control and // nothing said so. A caller that genuinely wants the first of several // scopes its selector, which is what the toolbar's three now do. async function clickSelector(selector) { const at = await evaluate( `(()=>{const all=document.querySelectorAll(${JSON.stringify(selector)});` + `if(!all.length)return {none:true};` + `const e=all[0];const r=e.getBoundingClientRect();` + `return {count:all.length,w:r.width,h:r.height,x:r.left+r.width/2,y:r.top+r.height/2}})()`, ) if (at.none) throw new Error(`no element matching ${selector}`) if (at.count > 1) { throw new Error( `${at.count} elements match ${selector} — the selector no longer identifies one control, so this would click whichever comes first in the document`, ) } if (at.w === 0 || at.h === 0) { throw new Error( `${selector} matched an element with no size (${at.w}x${at.h}) — it is hidden, so clicking its centre would click the page corner`, ) } await click(at.x, at.y) } async function editorHTML() { return evaluate(`document.querySelector('[docs-editor]').innerHTML`) } // pressKey dispatches one key the way a keyboard does. // // rawKeyDown rather than keyDown for the non-printing keys: a keyDown // carrying no text is what CDP turns into a char event, and a stray // char is how an Escape ends up inserting nothing visible while the // handler that should have run never did. Printing keys pass `text` // and get the char event they need. // // modifiers is CDP's bitfield — 2 is Control, which is the one the // palette is bound to. async function pressKey(key, { code, vk = 0, modifiers = 0, text } = {}) { const base = { key, code: code || key, windowsVirtualKeyCode: vk, modifiers } await send('Input.dispatchKeyEvent', { type: text ? 'keyDown' : 'rawKeyDown', ...base, ...(text ? { text } : {}) }) await send('Input.dispatchKeyEvent', { type: 'keyUp', ...base }) } // typeText types a string one character at a time, with a real gap // between them. The palette debounces its query, so a burst delivered // instantly is not the input it was written for. async function typeText(text) { for (const ch of text) { await send('Input.dispatchKeyEvent', { type: 'keyDown', text: ch, key: ch }) await send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch }) await sleep(20) } } const CONTROL = 2 // The editor writes through the CRDT and re-renders from it, so a // change is not in the DOM the instant a command returns. settle waits // for a condition rather than for a fixed delay, which is what keeps // this from being flaky in one direction and slow in the other. async function settle(describe, predicate, timeoutMs = 5000) { const deadline = Date.now() + timeoutMs let last = '' for (;;) { last = await editorHTML() if (predicate(last)) return last if (Date.now() > deadline) { throw new Error(`timed out waiting for ${describe}\n document is: ${last.slice(0, 200)}`) } await sleep(150) } } // settleOn polls one probe until it satisfies a predicate. settle // above watches the whole document; this watches one value, which is // what an assertion about a single block needs. async function settleOn(describe, probe, predicate, timeoutMs = 5000) { const deadline = Date.now() + timeoutMs let last for (;;) { last = await probe() if (predicate(last)) return last if (Date.now() > deadline) { throw new Error(`timed out waiting for ${describe}\n it is: ${JSON.stringify(last)}`) } await sleep(150) } } // settleScrollToTop blurs the editor, scrolls to the top, and waits // until the position actually holds still. // // The page scrolls the window itself — verified, not assumed: nothing // between [docs-page] and the document element is a scroll container, // document.scrollingElement is the document element, and // window.scrollTo moves window.scrollY. That matters because the // no-yank check below asserts on window.scrollY, and on a layout where // some inner element did the scrolling that check would pass while // measuring nothing at all. async function settleScrollToTop() { await evaluate(`document.querySelector('[docs-editor]').blur();window.scrollTo(0,0)`) for (let i = 0; i < 20; i++) { await sleep(100) if ((await evaluate(`window.scrollY`)) === 0) { // Two consecutive readings, because a scroll still in flight // reads zero once on its way past. await sleep(100) if ((await evaluate(`window.scrollY`)) === 0) return } await evaluate(`window.scrollTo(0,0)`) } throw new Error('the page would not stay scrolled to the top') } // blockTag is the tag name of one top-level block of the document. async function blockTag(index) { return evaluate( `(()=>{const b=document.querySelectorAll('[docs-editor] > *')[${index}];return b?b.tagName:null})()`, ) } const checks = [] function check(name, ok, detail) { checks.push({ name, ok, detail }) console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok || !detail ? '' : ' — ' + detail}`) } // group runs a set of checks so that one throwing does not delete the // ones that come after it. // // Without this, a group that dies partway through simply stops, the // later groups never run, and the summary line reports a smaller total // that looks like a smaller suite rather than like missing coverage. // The abort becomes its own named failure instead. async function group(name, fn) { try { await fn() } catch (err) { check(`${name} ran to completion`, false, err.message) } } // caretInfo reports where the caret is, read from the DOM selection // rather than from ProseMirror's own state. // // The DOM is the honest probe: it is what a person actually sees, and // it catches the case where the selection is set correctly in the // editor's state but the DOM never followed. (Credited to the // caret-placement session, which learned it the hard way.) const CARET_INFO = `(()=>{ const sel = document.getSelection() const ed = document.querySelector('[docs-editor]') if (!sel || sel.rangeCount === 0 || !ed) return null const r = sel.getRangeAt(0) const blocks = Array.from(ed.children) const rect = r.getBoundingClientRect() // The caret can sit ON the editor element itself, between two // blocks, in which case startOffset is a child index rather than a // text offset. Walking up from there runs straight past the editor // and reports -1, which reads as "the caret went somewhere else // entirely" when it did not. if (r.startContainer === ed) { return { blockIndex: r.startOffset, blockCount: blocks.length, offset: 0, onBoundary: true, collapsed: r.collapsed, top: rect.top, bottom: rect.bottom } } let node = r.startContainer while (node && node.parentElement && node.parentElement !== ed) node = node.parentElement return { blockIndex: blocks.indexOf(node), blockCount: blocks.length, offset: r.startOffset, onBoundary: false, inEditor: ed.contains(r.startContainer), collapsed: r.collapsed, top: rect.top, bottom: rect.bottom, } })()` // pickLongestDocument opens the longest of the seeded documents. // // On a short one, the scroll-related checks below pass trivially // because the whole document is already on screen — which is exactly // how the caret session nearly convinced itself an unconditional focus // handler was fine. Measured by fetching each document's own // server-rendered page rather than by mounting the editor six times. async function pickLongestDocument() { return evaluate(`(async () => { const hrefs = Array.from(document.querySelectorAll('a[href^="/d/"]')).map(a => a.getAttribute('href')) let best = null, bestLen = -1 for (const href of hrefs) { const res = await fetch(href, { credentials: 'same-origin' }) const html = await res.text() if (html.length > bestLen) { bestLen = html.length; best = href } } return best })()`) } async function run() { await send('Page.enable') await send('Runtime.enable') // Sign in the way a person does: the emailed link opens the confirm // door, which spends nothing, and its one Sign in button posts it. if (signinLink) { await send('Page.navigate', { url: signinLink }) await sleep(1500) await clickSelector('form[action="/auth/verify"] button[type="submit"]') await sleep(1500) } await send('Page.navigate', { url: base + '/' }) await sleep(1500) const href = await pickLongestDocument() if (!href) throw new Error('the document list has no documents to open') await send('Page.navigate', { url: base + href }) await sleep(2500) // editorChecks is the toolbar and editing section: it mounted, you // can type, the buttons and selects act on a real selection, and undo // undoes. // // EXTRACTED INTO A GROUP, which is the follow-up the comment at the // call site used to describe and defer. It throws deliberately in two // places — there is no point continuing without a selection — and // while it was inline in run() those aborts took every group after it // down with them. That cost was paid for real: a selector that had // stopped being unique threw here, and the run reported 5/6 rather // than one failure plus 38 other checks. The output read like a // smaller suite instead of like missing coverage, which is the exact // shape this file exists to refuse. async function editorChecks() { check('the editor mounted', await evaluate(`!!document.querySelector('[docs-editor] p')`)) check('the formatting bar rendered', await evaluate(`!!document.querySelector('[docs-toolbar]')`)) check( 'the editor did not fall back to the read view', !(await evaluate(`document.querySelector('[docs-editor]').hasAttribute('data-editor-failed')`)), ) // Work on a paragraph chosen by its own type rather than on // whatever happens to be first: these documents open with a heading, // and a "make this a heading" check aimed at one is a no-op that // passes. const paragraphIndex = await evaluate( `Array.from(document.querySelectorAll('[docs-editor] > *')).findIndex(e => e.tagName === 'P')`, ) if (paragraphIndex < 0) throw new Error('the document has no paragraph to edit') // Double-click a word: the ordinary way a person selects one. // // Tried at a few offsets along the line rather than at one fixed // point. A double click that lands between words, on an indent, or // past the end of a short line selects nothing — which is a fact // about where the pointer went, not about the editor, and a check // that cannot tell those apart is worse than no check. let wordAt = null let word = '' for (const dx of [60, 120, 200, 300]) { const at = await evaluate( `(()=>{const e=document.querySelectorAll('[docs-editor] > *')[${paragraphIndex}];` + `const r=e.getBoundingClientRect();const t=document.querySelector('[docs-toolbar]');` + `const floor=t?t.getBoundingClientRect().bottom:0;const y=r.top+8;` + `if(ywindow.innerHeight-4||${dx}>r.width-20) return null;` + `return {x:r.left+${dx},y}})()`, ) if (!at) continue await click(at.x, at.y, 1) await click(at.x, at.y, 2) await sleep(400) word = await evaluate(`window.getSelection().toString()`) if (word.trim().length > 0) { wordAt = at break } } check('a double click selects a word', !!wordAt, `selected ${JSON.stringify(word)}`) if (!wordAt) throw new Error('could not select a word to format') // Typing. This is the one that was broken, and it is not a toolbar // feature at all — which is exactly why it is asserted here. await send('Input.dispatchKeyEvent', { type: 'keyDown', text: 'Z', key: 'Z', code: 'KeyZ', windowsVirtualKeyCode: 90 }) await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Z', code: 'KeyZ', windowsVirtualKeyCode: 90 }) try { await settle('the typed character to appear', (html) => html.includes('Z')) check('typing changes the document', true) } catch (err) { check('typing changes the document', false, err.message) } // Bold, through the toolbar, over a real selection. await click(wordAt.x, wordAt.y, 1) await click(wordAt.x, wordAt.y, 2) await sleep(300) await clickSelector('[docs-toolbar] [data-docs-command="strong"]') try { await settle('bold to apply', (html) => html.includes('')) check('the bold button bolds the selection', true) } catch (err) { check('the bold button bolds the selection', false, err.message) } check( 'the selection survives pressing a toolbar button', (await evaluate(`window.getSelection().toString()`)).length > 0, ) // A font mark, through the select, and the class it must produce — // the same class core/src/fragment.rs renders server-side. // // Serif, not sans, and that is not interchangeable. Sans is the // default now (fixtures/fonts.mjs's DEFAULT_FONT), and choosing the // default CLEARS the group rather than setting a mark — so a drive // that picked sans would wait for a class the control is correct never // to write, and report the font control broken. Any non-default face // makes the check honest; serif is the one this change made sayable. await evaluate( `(()=>{const s=document.querySelector('[data-docs-select="font"]');s.value='serif';` + `s.dispatchEvent(new Event('change',{bubbles:true}));return true})()`, ) try { await settle('the serif face to apply', (html) => html.includes('docs-font-serif')) check('the font control applies the serif mark', true) } catch (err) { check('the font control applies the serif mark', false, err.message) } // A heading, through the block select — asserted on the ONE block // being changed, not on the document's markup as a whole. A seeded // document that already contains an

makes a // "does the HTML contain

" check pass without the control having // done anything, and makes its undo impossible to observe. Both of // those happened here, on the second document this drive was pointed // at, which is a good argument for the longest fixture. await evaluate( `(()=>{const s=document.querySelector('[data-docs-select="block"]');s.value='heading-2';` + `s.dispatchEvent(new Event('change',{bubbles:true}));return true})()`, ) try { await settleOn(`the block to become a heading`, () => blockTag(paragraphIndex), (tag) => tag === 'H2') check('the style control makes a heading', true) } catch (err) { check('the style control makes a heading', false, err.message) } // Undo has to be available now, and has to walk the heading back. check('undo becomes available after an edit', !(await evaluate( `document.querySelector('[docs-toolbar] [data-docs-command="undo"]').disabled`, ))) await clickSelector('[docs-toolbar] [data-docs-command="undo"]') try { await settleOn(`the heading to be undone`, () => blockTag(paragraphIndex), (tag) => tag === 'P') check('undo reverses the last change', true) } catch (err) { check('undo reverses the last change', false, err.message) } } await group('the editor and toolbar checks', editorChecks) // Each of these is wrapped so that a failure inside one cannot take // the checks after it down with it. Proven necessary rather than // assumed: breaking the palette's Control-K binding threw on the // first null it produced, and the run ended at 31 checks instead of // 44 — with the 320px reflow checks never reached and simply absent // from the output rather than reported as anything. A check that // silently removes other checks when it fails is the same "cannot // tell no from I can't see it" shape this file exists to avoid, so // an aborted group is now itself a failed check that names the group. // // The toolbar section is a group too now. It used to be inline here // and this comment said extracting it was a follow-up, on the // reasoning that moving a hundred working checks while fixing them is // how a harness stops being trustworthy. That was right, and the // follow-up then demonstrated itself: an ambiguous selector threw // inside that section and the run reported 5/6 — one failure, and 38 // checks simply absent rather than failed or skipped. Moved on its // own, with no change to the checks themselves. await group('the caret-placement checks', caretChecks) await group('the jump-to palette checks', paletteChecks) await group('the 320px reflow checks', reflowChecks) check('the page reported no JavaScript errors', pageProblems.length === 0, pageProblems.join(' | ')) } // The jump-to palette (Control-K), and the plain search URL under it. // // These are the seventeen checks discussion #14 ran and could not // commit: the harness they belonged in was on another branch at the // time, and shipping a second harness to avoid waiting would have been // worse than waiting. Both branches have landed, so this is that fold — // branch task 22. What protected the palette's keyboard contract until // now was one person having run a scratch script once, which is exactly // what this repo does not count as a gate. // // Two things are different from the scratch version, both because a // check that depends on the fixture data is a check that fails for // reasons that have nothing to do with the code: // // - The queries are DERIVED from the titles actually on the list, // not typed in as "s" and "waterworks". Those worked against the // six seeded documents and would go red the day somebody edits // seeddata — reporting "the palette is broken" when it is not, // which is the failure this file's own header warns about. // - The single-result case queries a whole title, so the exact-match // rank guarantees which row lands first. Asserting on "the first // result" after a query that merely matches something is asserting // on the ranker's tie-break by accident. // // It runs before reflowChecks because that one leaves a phone-sized // viewport behind, and a 320px palette is a different check from this // one. async function paletteChecks() { // Where the palette matters most: inside a document, which is where // the run() above has already left us. A shortcut that works on the // list and not in the editor is the case worth catching, because the // editor is the page with a keymap of its own to collide with. await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL }) await sleep(400) check('Control-K opens the palette inside a document', await evaluate(`!document.querySelector('[docs-jumpto]').hidden`)) await pressKey('Escape', { vk: 27 }) await sleep(300) // Everything else from the list. await send('Page.navigate', { url: base + '/' }) await sleep(1500) // The titles this instance actually has, with the hrefs, so every // query below is derived rather than assumed. const docs = await evaluate(`(() => { return Array.from(document.querySelectorAll('a[href^="/d/"]')).map(a => ({ href: a.getAttribute('href'), title: a.textContent.trim(), })).filter(d => d.title.length > 0) })()`) if (!docs || docs.length === 0) throw new Error('the document list has no titles to search for') // A query that matches more than one, found rather than guessed: the // shortest single letter contained in at least two titles. The arrow // keys need two options to move between, and "there are two" is a // property of the data that this asks about instead of assuming. const multi = await evaluate(`(() => { const titles = ${JSON.stringify(docs.map((d) => d.title.toLowerCase()))} for (const ch of 'etaoinsrhldcumfpgwybvkxjqz') { if (titles.filter(t => t.includes(ch)).length >= 2) return ch } return null })()`) // --- The no-JS path. Search is a plain page at a plain URL, and // that is what lets the palette be an addition rather than the only // way in. Asserted with the palette's own bundle irrelevant. const one = docs[0] const others = docs.slice(1).map((d) => d.title) await send('Page.navigate', { url: base + '/?q=' + encodeURIComponent(one.title) }) await sleep(1200) const searched = await evaluate(`document.body.innerText`) check('a search URL renders the matching document', searched.includes(one.title), searched.slice(0, 160)) // Only meaningful if some other title would have shown on an // unfiltered list — with one document there is nothing to hide, and // a check that cannot fail is not a check. const hidden = others.filter((t) => !searched.includes(t)) check( 'and hides the ones that do not match', others.length === 0 || hidden.length > 0, `none of the other ${others.length} titles were filtered out`, ) await send('Page.navigate', { url: base + '/' }) await sleep(1500) check( 'the palette is on the page but hidden', await evaluate(`(()=>{const e=document.querySelector('[docs-jumpto]');return !!e && e.hidden})()`), ) await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL }) await sleep(400) check('Control-K opens the palette from the list', await evaluate(`!document.querySelector('[docs-jumpto]').hidden`)) check( 'and focus lands in the field', await evaluate(`document.activeElement === document.querySelector('[docs-jumpto-input]')`), ) check( 'aria-expanded says it is open', (await evaluate(`document.querySelector('[docs-jumpto-input]').getAttribute('aria-expanded')`)) === 'true', ) let options = 0 if (multi) { await typeText(multi) try { options = await settleOn( 'results', () => evaluate(`document.querySelectorAll('[docs-jumpto-list] li').length`), (n) => n > 0, ) check('typing produces results', true) } catch (err) { check('typing produces results', false, err.message) } } else { check('typing produces results', false, 'no letter is shared by two titles — cannot form a query') } // Read through a null-safe probe rather than dereferencing the first //
  • directly. With no results that dereference throws, and a throw // here used to end the whole run — so the failure of one check // deleted every check after it instead of reporting itself. const firstOption = await evaluate(`(() => { const li = document.querySelector('[docs-jumpto-list] li') const input = document.querySelector('[docs-jumpto-input]') return { exists: !!li, selected: li ? li.getAttribute('aria-selected') : null, id: li ? li.id : null, activeDescendant: input ? input.getAttribute('aria-activedescendant') : null, } })()`) check( 'the first result is the active option', firstOption.selected === 'true', firstOption.exists ? `aria-selected is ${firstOption.selected}` : 'there are no results at all', ) check( 'aria-activedescendant names the active option', firstOption.exists && firstOption.activeDescendant === firstOption.id, `aria-activedescendant is ${firstOption.activeDescendant}, the first option's id is ${firstOption.id}`, ) // Which option is selected, as an index — one probe, so a movement // is asserted as "it moved from here to there" rather than as two // separate facts about two separate elements. const selectedIndex = () => evaluate( `Array.from(document.querySelectorAll('[docs-jumpto-list] li')).findIndex(l => l.getAttribute('aria-selected') === 'true')`, ) if (options > 1) { await pressKey('ArrowDown', { vk: 40 }) await sleep(250) const afterDown = await selectedIndex() check('Down moves to the second result', afterDown === 1, `the selected option is ${afterDown}`) await pressKey('ArrowUp', { vk: 38 }) await sleep(250) const afterUp = await selectedIndex() // Guarded on Down having actually moved. "The first option is // selected" is true both when Up walked back to it and when // nothing moved at all, so on its own this check passes against an // arrow-key handler that does nothing — verified: breaking move() // turned Down red and left this one green. It is only evidence // when there was somewhere to come back FROM. check( 'Up moves back to the first', afterDown === 1 && afterUp === 0, afterDown !== 1 ? `Down never moved (selected ${afterDown}), so returning to 0 proves nothing` : `the selected option is ${afterUp}`, ) } else { check('Down moves to the second result', false, `only ${options} result(s) to move through`) check('Up moves back to the first', false, `only ${options} result(s) to move through`) } // Escape closes and hands focus back. Tab is deliberately NOT a // cycle here — the panel holds two focusable things, and a general // focus trap is where a palette usually strands somebody. await pressKey('Escape', { vk: 27 }) await sleep(400) check('Escape closes the palette', await evaluate(`document.querySelector('[docs-jumpto]').hidden`)) // Focus returns to WHERE IT CAME FROM, asserted by naming that // element rather than by observing that it is no longer in the // field. // // "activeElement is not the palette input" was the obvious way to // write this and it is worthless: closing the palette hides the // input, so focus leaves it whether or not anything put focus back, // and the check passes either way. Verified — deleting the // returnFocusTo.focus() call left it green at 44/44, which is a // check that cannot tell "no" from "I can't see it", and this file // exists to not do that. // // So focus a known element first, open from there, and assert that // Escape brings focus back to that exact element. A keyboard user // who opens the palette from the search field and dismisses it // should be back in the search field, not at the top of the page. const returned = await evaluate(`(() => { const f = document.querySelector('[docs-search-input]') if (!f) return null f.focus() return document.activeElement === f })()`) if (returned) { await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL }) await sleep(400) await pressKey('Escape', { vk: 27 }) await sleep(400) check( 'and focus goes back to whatever opened it', await evaluate(`document.activeElement === document.querySelector('[docs-search-input]')`), `focus is on ${await evaluate(`document.activeElement ? document.activeElement.tagName + (document.activeElement.className || '') : 'nothing'`)}`, ) } else { check('and focus goes back to whatever opened it', false, 'no [docs-search-input] to open the palette from') } // A query matching nothing says so rather than showing an empty box. // // Compared against the status element's own data-empty, NOT merely // "some text appeared". The palette writes two different strings // into that element — data-empty when nothing matched, data-failed // when the fetch threw — so "text is non-empty" passes just as well // when the search is BROKEN and the person is being told so. That is // the wrong answer under a check whose name claims to be about the // empty case. await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL }) await sleep(300) await typeText('zzzznothingmatchesthis') try { const said = await settleOn( 'the empty-result message', () => evaluate(`(()=>{const s=document.querySelector('[docs-jumpto-status]');return s.hidden?'':s.textContent})()`), (t) => t.length > 0, ) const expected = await evaluate( `document.querySelector('[docs-jumpto-status]').getAttribute('data-empty')`, ) check( 'a query matching nothing says so', said.trim() === (expected || '').trim(), `it says ${JSON.stringify(said)}; the empty-result copy is ${JSON.stringify(expected)}`, ) } catch (err) { check('a query matching nothing says so', false, err.message) } // Closing on that message and reopening must not still be saying it. // // This one is a regression check rather than a contract check: the // Fable review of #14 found the palette reopening over an EMPTY query // still showing "No documents match", and because the status is a // live region it announced the stale answer again to a screen reader // — an answer to a question nobody had asked yet. await pressKey('Escape', { vk: 27 }) await sleep(300) await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL }) await sleep(400) const stale = await evaluate( `(()=>{const s=document.querySelector('[docs-jumpto-status]');return s.hidden?'':s.textContent.trim()})()`, ) check('reopening it does not repeat the last empty-result message', stale === '', `it still says ${JSON.stringify(stale)}`) check( 'and it reopens with an empty field', (await evaluate(`document.querySelector('[docs-jumpto-input]').value`)) === '', ) // Enter opens the chosen document. Queried by a whole title, so the // exact-match rank decides which row is first rather than the // tie-break happening to. await typeText(one.title) try { await settleOn( 'a result for a whole title', () => evaluate(`document.querySelectorAll('[docs-jumpto-list] li').length`), (n) => n > 0, ) const target = await evaluate( `(()=>{const l=document.querySelector('[docs-jumpto-list] li');return l?l.dataset.href:null})()`, ) check('the exact title ranks first', target === one.href, `first result was ${target}, wanted ${one.href}`) await pressKey('Enter', { vk: 13 }) await sleep(2000) const url = await evaluate(`location.pathname`) check('Enter opens the chosen document', url === target, `went to ${url}, wanted ${target}`) } catch (err) { check('the exact title ranks first', false, err.message) check('Enter opens the chosen document', false, err.message) } } // 320px reflow — WCAG 2.2 SC 1.4.10, which AGENTS.md takes with no // exemptions. // // Last, and it changes the viewport, so nothing above it may depend on // the window size afterwards. The criterion is binary: six pixels of // horizontal scroll fails it exactly as a hundred would, and six is // what the formatting bar shipped with — three selects in a group that // could not break inside itself, ending at x=326 in a 320px viewport. // It reached main, because nothing here was looking. // // The check names the offending element rather than reporting a // number, because "something overflows" sends the next person hunting // through a stylesheet and "input[docs-search-input] ends at 356" does // not. // // Both pages, not just the document. The list page's own overflow — // 36px, from a search form that claimed a fixed field width inside a // row it had told not to wrap — was found only because this walks // every page rather than the one that was being worked on. async function reflowChecks() { await send('Emulation.setDeviceMetricsOverride', { width: 320, height: 720, deviceScaleFactor: 1, mobile: true, }) try { for (const [name, path] of [['the document list', '/'], ['a document', null]]) { if (path) { await send('Page.navigate', { url: base + path }) await sleep(1500) } else { const href = await evaluate( `(()=>{const a=document.querySelector('a[href^="/d/"]');return a?a.getAttribute('href'):null})()`, ) await send('Page.navigate', { url: base + href }) await sleep(2500) } const found = await evaluate(`(() => { const vw = document.documentElement.clientWidth const over = [] for (const el of document.querySelectorAll('*')) { const r = el.getBoundingClientRect() if (r.width === 0 && r.height === 0) continue if (r.right > vw + 0.5 || r.left < -0.5) { let depth = 0 for (let n = el; n.parentElement; n = n.parentElement) depth++ over.push({ d: depth, what: el.tagName.toLowerCase() + (el.getAttributeNames().filter(a => a.startsWith('docs-')).map(a => '[' + a + ']').join('') || ''), right: Math.round(r.right), }) } } over.sort((a, b) => b.d - a.d) return { scrollWidth: document.documentElement.scrollWidth, viewport: vw, worst: over.slice(0, 3) } })()`) // The viewport is asserted to BE 320, not merely used as the // yardstick. Everything here measures against // documentElement.clientWidth, and that reads 320 only because // rastrillo's vendored topbar layout emits // — nothing // in this app does. Lose that meta on a module upgrade and // Chrome's mobile emulation lays out at 980px, clientWidth reads // 980, and both of these checks pass while measuring a // desktop-width page: a green result that has stopped looking at // the thing it is named for. check( `${name} does not scroll sideways at 320px`, found.viewport === 320 && found.scrollWidth <= found.viewport && found.worst.length === 0, found.viewport !== 320 ? `the page laid out at ${found.viewport}px, not 320 — check the viewport meta tag; this check measured nothing` : `scrollWidth ${found.scrollWidth} vs viewport ${found.viewport}; ` + found.worst.map((o) => `${o.what} ends at ${o.right}`).join(', '), ) } } finally { // Always put the viewport back, even if a check threw — a later // reader running this by hand should not inherit a phone. await send('Emulation.clearDeviceMetricsOverride') } } // The caret-placement checks. // // These cover fd1d21a (clicking the page margins places the caret, and // the focus ring is gone from the editable surface), which shipped with // no regression coverage because it was verified with throwaway // scripts. The assertions are the ones that branch's author actually // ran, contributed to this harness afterwards; the reasoning in each // comment is theirs. async function caretChecks() { const page = '[docs-page]' const ed = '[docs-editor]' // 1. A click in the left margin, level with a block, puts the caret // at the START of that block. "Is the editor focused" is not the // assertion — that passes even when the caret went to the end of // the document, which is the bug. // Settle the scroll position BEFORE measuring. Every toolbar command // ends in view.focus(), and a focus the editor did not attribute to a // pointer scrolls the caret into view — so a coordinate measured // immediately after one can be stale by the time it is clicked, and // the click then lands on the sticky formatting bar instead of the // paper. That produced a failure that looked exactly like broken // caret placement and was not. await settleScrollToTop() // Aim at a block that is actually on screen and clear of the sticky // formatting bar, rather than at a fixed index. A point below the // viewport receives the click and does nothing at all — which looks // identical to the caret being placed wrongly, and is the trap this // check exists to avoid rather than to fall into. Which block it // lands on is not the point; that it lands at the START of the one // beside the pointer is. const target = await evaluate( `(()=>{const p=document.querySelector('${page}');const t=document.querySelector('[docs-toolbar]');` + `const floor=t?t.getBoundingClientRect().bottom:0;` + `const blocks=Array.from(document.querySelectorAll('${ed} > *'));` + `for(let i=0;ifloor+12 && br.bottom0){` + `const pr=p.getBoundingClientRect();return {index:i,x:pr.left+20,y:br.top+8}}}return null})()`, ) if (!target) throw new Error('no block is both on screen and clear of the formatting bar') await click(target.x, target.y) await sleep(400) const fromMargin = await evaluate(CARET_INFO) check( 'a click in the left margin puts the caret at the start of that line', !!fromMargin && fromMargin.blockIndex === target.index && fromMargin.offset === 0, `aimed at block ${target.index}, got ${JSON.stringify(fromMargin)}`, ) // 2. A click in the empty space below the last paragraph but still // inside the editable node. Only meaningful once scrolled to the // bottom — a click below the viewport lands on nothing at all, // which reads exactly like the bug it is supposed to catch. await evaluate(`window.scrollTo(0, document.body.scrollHeight)`) await sleep(400) const belowText = await evaluate( `(()=>{const e=document.querySelector('${ed}');const r=e.getBoundingClientRect();` + `const y=r.bottom-12;if(y<0||y>window.innerHeight) return null;return {x:r.left+40,y}})()`, ) if (belowText) { await click(belowText.x, belowText.y) await sleep(400) const c = await evaluate(CARET_INFO) check( 'a click below the last paragraph puts the caret in the last block', !!c && c.blockIndex === c.blockCount - 1, JSON.stringify(c), ) } else { check('a click below the last paragraph puts the caret in the last block', false, 'the point was off screen') } // 3. A click in the page's bottom margin, outside the editable node // entirely. This is the path that goes through placeCaretFromPage // rather than through ProseMirror's own hit testing. const bottomMargin = await evaluate( `(()=>{const p=document.querySelector('${page}');const e=document.querySelector('${ed}');` + `const pr=p.getBoundingClientRect();const er=e.getBoundingClientRect();` + `const y=(er.bottom+pr.bottom)/2;if(y<0||y>window.innerHeight) return null;return {x:pr.left+80,y}})()`, ) if (bottomMargin) { await click(bottomMargin.x, bottomMargin.y) await sleep(400) const c = await evaluate(CARET_INFO) check( 'a click in the bottom margin puts the caret in the last block', !!c && c.blockIndex === c.blockCount - 1, JSON.stringify(c), ) } else { check('a click in the bottom margin puts the caret in the last block', false, 'the point was off screen') } // 4. And typing then lands THERE, not merely somewhere. The last // element's own text, not the whole editor's, or a character that // went to the top of the document would still pass. await send('Input.dispatchKeyEvent', { type: 'keyDown', text: 'Q', key: 'Q', code: 'KeyQ', windowsVirtualKeyCode: 81 }) await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Q', code: 'KeyQ', windowsVirtualKeyCode: 81 }) await sleep(1200) const lastBlockText = await evaluate(`document.querySelector('${ed}').lastElementChild.textContent`) check('typing after that click lands in the last block', lastBlockText.includes('Q')) // 5. The no-yank regression, and the one most worth keeping. A focus // event fires BEFORE ProseMirror sets the selection a click asked // for, so a focus handler that scrolls unconditionally yanks the // view to wherever the selection was last left. With the selection // at the end of a long document, clicking in the first block must // leave the page where it is. await settleScrollToTop() const firstBlock = await evaluate( `(()=>{const b=document.querySelector('${ed} > *');const r=b.getBoundingClientRect();return {x:r.left+40,y:r.top+8}})()`, ) await click(firstBlock.x, firstBlock.y) await sleep(600) const scrolledTo = await evaluate(`window.scrollY`) check( 'clicking near the top does not yank the view to the old selection', scrolledTo < 40, `scrollY became ${scrolledTo}`, ) // 6. The other half of that trade: the editable surface has no focus // ring (views/show.html removes it, on the argument that a caret // says more), so a focus that did NOT come from a pointer has to // scroll the caret into view — otherwise focus lands with no // visible indicator anywhere on screen, which is a WCAG 2.2 AA // regression and AGENTS.md allows no exemptions. // // The caret session ran this as Tab from the sign-out button. That // no longer reaches the editor: the formatting bar sits between // the header and the paper, so Tab from sign-out now lands on the // toolbar. The guarantee being checked is not about the tab order // though — it is "a non-pointer focus scrolls the caret into // view" — so it is exercised directly here, and stays true however // the tab order changes next. await settleScrollToTop() await evaluate(`document.querySelector('${ed}').focus()`) await sleep(700) const caret = await evaluate(CARET_INFO) check( 'a keyboard focus scrolls the caret into view', !!caret && caret.top >= 0 && caret.bottom <= (await evaluate(`window.innerHeight`)), JSON.stringify(caret), ) check( 'the editable surface draws no focus ring (the caret is the indicator)', (await evaluate(`getComputedStyle(document.querySelector('${ed}')).outlineStyle`)) === 'none', ) // 7. And the document has to be REACHABLE by keyboard from outside // it. The check above focuses the editor programmatically, which // succeeds whether or not a person could ever have got there with // Tab — so if the formatting bar ever traps focus, every other // check here still passes while the document has become // unreachable to anyone not using a mouse. (The gap was spotted // by the caret-placement session; it is exactly the shape // AGENTS.md names, a check that cannot tell "no" from "I cannot // see it".) // // Bounded rather than exact: the bar is one tab stop today // (toolbar.mjs's roving tabindex), but asserting an exact number // would break the week someone adds a control outside the bar for // a reason that has nothing to do with this. Twelve is generous // and still fails loudly the day focus cannot get out of the // toolbar at all. // // Starts from the shell bar's brand link, and that is not a free // choice. It used to start from the sign-out button, which was in // this page's own header; sign-out is now inside the shell's // account dropdown, a CLOSED
    , whose content is in the // DOM but is not rendered — so querySelector still finds the // button, .focus() is a silent no-op, and the loop below counted // Tab presses from wherever focus happened to be already (the // editor, from check 6 above). It reported 8, which was a true // measurement of nothing. That is the failure shape AGENTS.md // names twice, so: the anchor must be something always rendered. // [estilo-bar-brand] is — the estilo bar's brand link, the first // focusable thing in the bar at every width (it was rastrillo's // [rst-shell-brand] until round 2 replaced the shell) — and the // assertion below fails if it ever stops being focusable, rather // than quietly measuring from somewhere else. const anchored = await evaluate( `(()=>{const b=document.querySelector('[estilo-bar-brand]');if(!b)return false;b.focus();` + `return document.activeElement===b})()`, ) check( 'the tab-order check starts where it thinks it does', anchored === true, 'could not focus [estilo-bar-brand] — the count below would measure from somewhere else', ) // How many tab stops the bar itself has after the brand, counted from // the live page rather than pinned: the title field and its Rename // button, Share…, the switcher when configured, the account trigger — // not the rows inside a closed panel, which Chrome still lays out. // Pinning a total is what made the old bound go stale when the bar // changed; what check 8 below holds is the claim that matters — the menu // bar and the formatting bar are ONE stop each. const barStops = await evaluate( `(()=>{const bar=document.querySelector('[estilo-bar]');const brand=bar.querySelector('[estilo-bar-brand]');` + `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})()`, ) await sleep(200) let reachedAfter = -1 for (let i = 1; i <= 12; i++) { await send('Input.dispatchKeyEvent', { type: 'rawKeyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }) await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }) await sleep(120) if (await evaluate(`document.activeElement === document.querySelector('${ed}')`)) { reachedAfter = i break } } check( 'the document is reachable by Tab from the shell bar', reachedAfter > 0, reachedAfter > 0 ? '' : 'twelve presses of Tab never reached the editor', ) // 8. And the bar is ONE stop on the way, not one per control. // role="toolbar" is a promise that the arrow keys move within it; // a bar that spends fourteen tab presses has made that promise // and broken it. // // Four, derived rather than chosen, at this drive's 1280px (above // the shell's own 800px breakpoint, so the bar is laid out wide: // the hamburger is hidden and the tail is display: contents). // (History: the paragraphs below derived a fixed four for the // rastrillo shell. Round 2 counts the bar's own stops from the page // instead; see barStops above and the check at the end.) // From [rst-shell-brand], in DOM order: the account summary (1), // the menu bar, entire, as one roving-tabindex stop (2), the // formatting bar, entire, as one roving-tabindex stop (3), the // editor (4). // // It was three when the path was sign-out -> bar -> editor and // this page drew its own header, then five when the two exports // moved into the bar's trailing end and the account control moved // into the shell. // // It is four now, and it went DOWN while the page gained a whole // menu bar, which is the reason to re-derive rather than nudge. A // menu bar adds one stop; the same change took the two export // links out of the tab order entirely, because they are File menu // items inside a closed
    and a closed disclosure's // contents are not focusable. Nudging 5 upward to admit the new // control would have left a bound that passes whether the order is // right or wrong by two. // // ONE STOP FOR THE MENU BAR IS THE ENHANCED NUMBER. With no // JavaScript the bar is six
    summaries and six stops, so // the count would be nine. This drive runs with scripts, so four // is what it should see — but the difference is real, this app // renders without JavaScript on purpose, and a reader of this // check is entitled to know which of the two numbers it pins. // // DERIVED, NOT MEASURED. Chromium cannot start in the sandbox this // was written in (scripts/browser-drive.sh's own note: "socket() // failed: Operation not permitted"), so the four above comes from // reading the DOM order in views/show.html rather than from a run. // The first real run of this check is therefore a check on this // derivation as much as on the page; if it reports five, the extra // stop is the thing to find, not the bound to raise. // Round 2 (estilo bar): the stops are the bar's own (barStops, // counted above from the page), then the menu bar as one stop, the // formatting bar as one stop, then the editor. So the editor is // exactly barStops + 3 presses away, and a menu bar or toolbar that // leaks a second stop makes it barStops + 4 or more. check( 'the menu bar and the formatting bar are one tab stop each', reachedAfter > 0 && reachedAfter === barStops + 3, `the editor was ${reachedAfter} tab presses from the brand; the bar has ${barStops} stops of its own, so ${barStops + 3} was expected`, ) // 9. ...and the arrow keys are what move inside it. await evaluate( `(()=>{const c=document.querySelector('[docs-toolbar] [data-docs-command],[docs-toolbar] [data-docs-select]');` + `const list=Array.from(document.querySelectorAll('[docs-toolbar] [data-docs-command],[docs-toolbar] [data-docs-select]'))` + `.filter(e=>!e.disabled);if(list[0])list[0].focus();return true})()`, ) await sleep(150) const before = await evaluate(`document.activeElement.getAttribute('data-docs-command')||document.activeElement.getAttribute('data-docs-select')`) await send('Input.dispatchKeyEvent', { type: 'rawKeyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }) await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }) await sleep(200) const after = await evaluate(`document.activeElement.getAttribute('data-docs-command')||document.activeElement.getAttribute('data-docs-select')`) check( 'the arrow keys move between the toolbar controls', !!before && !!after && before !== after, `focus went from ${before} to ${after}`, ) } ws.onopen = async () => { try { await run() } catch (err) { check('the drive ran to completion', false, err.message) } const failed = checks.filter((c) => !c.ok) console.log(`\n${checks.length - failed.length}/${checks.length} checks passed`) ws.close() process.exit(failed.length === 0 ? 0 : 1) } ws.onerror = () => { console.error('browser-drive: could not connect to the browser at ' + wsUrl) process.exit(2) }