oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

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
33const [, , wsUrl, base, signinLink] = process.argv
34if (!wsUrl || !base) {
35 console.error('usage: node browser-drive.mjs <devtools-ws-url> <base-url> [sign-in-link]')
36 process.exit(2)
37}
38
39const ws = new WebSocket(wsUrl)
40let nextId = 0
41const pending = new Map()
42const pageProblems = []
43
44function 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
52ws.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
72const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
73
74async 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
82async 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.
105async 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
126async 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.
140async 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.
149async 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
157const 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.
163async 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.
179async 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.
202async 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.
218async function blockTag(index) {
219 return evaluate(
220 `(()=>{const b=document.querySelectorAll('[docs-editor] > *')[${index}];return b?b.tagName:null})()`,
221 )
222}
223
224const checks = []
225function 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.
237async 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.)
252const 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.
289async 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
302async 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.
334async 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.
514async 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.
803async 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.
873async 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
1158ws.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
1170ws.onerror = () => {
1171 console.error('browser-drive: could not connect to the browser at ' + wsUrl)
1172 process.exit(2)
1173}
1174