oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1// fixtures/toolbar.test.mjs — the editor's chrome must not write to the
2// DOM when the DOM is already right.
3//
4// Named for the toolbar because the toolbar's plugin is the entry
5// point, but it covers both bars: editor-entry.js gives the whole
6// chrome ONE ProseMirror plugin, and the menu bar rides on it through
7// createToolbar's onSync. So this file wires them the same way and
8// holds one property across both.
9//
10// THIS FILE EXISTS BECAUSE ITS ABSENCE SHIPPED A VISIBLE FLASH. Driven
11// in a real browser with one genuinely hidden tab, switching back to a
12// document produced 23 DOM mutations on reveal, 21 of them attribute
13// writes on chrome that was already on screen — and recording each
14// write's old value against its new one, 21 of 21 were redundant. Zero
15// changed anything. Every one restated what was already there:
16// aria-pressed "false" -> "false", disabled "" -> "". setAttribute with
17// an identical value still invalidates style and still records a
18// mutation, and so does setting a reflected boolean property to the
19// value it already holds, so the browser spent a frame repainting
20// chrome nothing had changed.
21//
22// The tab switch was only where it became visible. Both bars sync on
23// EVERY ProseMirror state update, so this was the cost of every
24// keystroke as well; tabbing back merely gathered a page of it into one
25// frame. And it grows: the same measurement gave 12 mutations before
26// the toolbar redesign and 23 after, because each control added brought
27// its own unconditional pair of writes. The menu bar, written after the
28// toolbar by a different session, reproduced the defect from scratch —
29// which is the argument for holding this against the source and not
30// only against the controls that exist today.
31//
32// So the property is not "few mutations on a tab switch", which is a
33// symptom at one moment. It is: AFTER A STATE UPDATE THAT CHANGES
34// NOTHING, THE CHROME PERFORMS ZERO WRITES.
35//
36// No DOM and no browser, the same arrangement menu.test.mjs uses: a
37// stand-in for the handful of DOM surface these modules touch, which
38// records every write WHETHER OR NOT IT CHANGES ANYTHING. Recording the
39// call rather than the change is the entire seam — a check that
40// compared the bars before and after would see nothing wrong, because
41// nothing IS different afterwards.
42import { test } from 'node:test'
43import assert from 'node:assert/strict'
44import { readFileSync } from 'node:fs'
45import { Schema } from 'prosemirror-model'
46import { EditorState, TextSelection } from 'prosemirror-state'
47import { schema as basic } from 'prosemirror-schema-basic'
48import { addListNodes } from 'prosemirror-schema-list'
49import { alignedParagraph } from './align.mjs'
50import { withIndent } from './indent.mjs'
51import { SIZE_MARKS, SIZES } from './sizes.mjs'
52import { FONTS } from './fonts.mjs'
53import { createToolbar } from './toolbar.mjs'
54import { mountMenu } from './menu.mjs'
55
56// toolbar.mjs asks the document which element has focus, so that
57// showInCombo can decline to overwrite a filter box somebody is typing
58// into; menu.mjs listens on the document for a click-away close. Those
59// are the two reaches for a global in these modules, and under Node
60// there is nothing to reach for. A stand-in with nothing focused is
61// what a chrome syncing while the caret is in the document sees anyway.
62globalThis.document = { activeElement: null, addEventListener() {} }
63
64// The schema editor-entry.js builds, assembled the same way from the
65// same modules. Written out rather than imported because
66// editor-entry.js is a program, not a module surface — it mounts an
67// EditorView on import — but every piece of the vocabulary here comes
68// from the file that owns it, so the two cannot drift in the parts this
69// test reads (the font marks, the size marks, the block types).
70function spanMark(cls, group) {
71 return {
72 group,
73 excludes: group,
74 parseDOM: [{ tag: 'span.' + cls }],
75 toDOM: () => ['span', { class: cls }, 0],
76 }
77}
78
79const marks = SIZE_MARKS.reduce(
80 (spec, mark) => spec.addToStart(mark, spanMark('docs-' + mark.replace('_', '-'), 'fontSize')),
81 basic.spec.marks,
82)
83 .addToStart('font_mono', spanMark('docs-font-mono', 'fontFamily'))
84 .addToStart('font_sans', spanMark('docs-font-sans', 'fontFamily'))
85 .addToStart('font_serif', spanMark('docs-font-serif', 'fontFamily'))
86
87const nodes = addListNodes(
88 basic.spec.nodes
89 .update('paragraph', withIndent(alignedParagraph(basic.spec.nodes.get('paragraph')), 'p'))
90 .update('heading', withIndent(basic.spec.nodes.get('heading'), 'h1')),
91 'paragraph block*',
92 'block',
93)
94
95const schema = new Schema({ nodes, marks })
96
97// A recording stand-in for the DOM surface the chrome touches.
98//
99// Every attribute write and every property write goes into one log,
100// with its old value beside its new one, whether or not the two differ.
101// Nothing here is a DOM implementation and it must not grow into one —
102// the moment a test needs layout or real focus it belongs in the
103// browser drive (fixtures/browser-drive.mjs).
104//
105// EVERY property write, through a Proxy, and not just the three this
106// file bothered to define accessors for. That distinction was a real
107// hole, found in review with a probe planted on menu.mjs's
108// every-state-update path:
109//
110// if (!name.includes('https://')) el.title = 'seen'
111//
112// which all three tests passed. `title` is attribute-reflected, so an
113// identical-value write invalidates style and records a mutation on
114// every keystroke — the exact defect this file exists to stop — and it
115// was invisible twice over. The structural check below missed it
116// because stripComments does not parse string literals, so the `//`
117// inside 'https://' blanked the rest of the line; and this fake missed
118// it because an assignment to a property with no accessor landed on a
119// plain object without a sound. Two nets, one hole in each, and the
120// holes overlapped.
121//
122// The Proxy closes this one, and closes it for a class rather than for
123// a name: el.title, el.textContent, el.className, el.checked,
124// el.hidden, el.innerHTML, el['disabled'] written through a computed
125// key, and compound forms like `||=` that the structural check's
126// bare-`=` shape does not match either. The three accessors stay for
127// their semantics — a select refusing a value no option carries — and
128// no longer log, because the trap already did.
129//
130// Two roots, not one, because that is how the page is built: the
131// formatting bar is [docs-toolbar] and the menu bar is [estilo-menubar],
132// and both hold elements carrying data-docs-command. A single root
133// would hand the toolbar the menu's items and test something the
134// browser never does.
135function makeDOM() {
136 const log = []
137 const bar = []
138 const menu = []
139
140 // `extra` is applied to the raw node BEFORE the Proxy wraps it: the
141 // fixture's own setup (a select's options, its combobox wrapper) is
142 // not a write by the code under test and must not be logged as one.
143 function element(tag, id, attrs, into, extra = {}) {
144 const own = { ...attrs }
145 let disabled = 'disabled' in own
146 let tabIndex = 'tabindex' in own ? Number(own.tabindex) : -1
147 let value = ''
148 const node = {
149 tag,
150 id,
151 options: [],
152 selectedIndex: -1,
153 children: [],
154 previousElementSibling: null,
155 getAttribute: (k) => (k in own ? own[k] : null),
156 hasAttribute: (k) => k in own,
157 setAttribute(k, v) {
158 log.push({ id, name: k, from: node.getAttribute(k), to: String(v) })
159 own[k] = String(v)
160 },
161 removeAttribute(k) {
162 log.push({ id, name: k, from: node.getAttribute(k), to: null })
163 delete own[k]
164 },
165 // Two selector forms, which is all these modules use: a bare tag
166 // name, and one or more [attribute] clauses that must all match.
167 matches(sel) {
168 if (sel === tag) return true
169 const parts = sel.match(/\[[^\]]+\]/g)
170 return parts ? parts.every((p) => p.slice(1, -1) in own) : false
171 },
172 querySelector: (sel) => node.children.find((c) => c.matches(sel)) || null,
173 querySelectorAll: (sel) => node.children.filter((c) => c.matches(sel)),
174 closest: () => null,
175 addEventListener() {},
176 focus() {},
177 }
178 // The three properties that are not plain storage. They keep their
179 // accessors for the behaviour, and do no logging of their own — the
180 // Proxy below logs every write, these included, so a value here
181 // would be counted twice.
182 Object.defineProperty(node, 'disabled', {
183 get: () => disabled,
184 set: (v) => {
185 disabled = v
186 },
187 })
188 Object.defineProperty(node, 'tabIndex', {
189 get: () => tabIndex,
190 set: (v) => {
191 tabIndex = v
192 },
193 })
194 Object.defineProperty(node, 'value', {
195 get: () => value,
196 set: (v) => {
197 // A real <select> refuses a value no option carries and reports
198 // "" afterwards. Emulated because it is the one way a write
199 // guarded by a comparison can still fire on every update, and a
200 // fake that quietly accepted any string would hide it.
201 //
202 // selectedIndex is moved on the raw node, not through the
203 // Proxy: it is a consequence of this write, not a second one.
204 if (tag === 'select') {
205 const at = node.options.findIndex((o) => o.value === String(v))
206 node.selectedIndex = at
207 value = at < 0 ? '' : String(v)
208 } else {
209 value = String(v)
210 }
211 },
212 })
213 Object.assign(node, extra)
214
215 const el = new Proxy(node, {
216 set(target, key, v) {
217 log.push({ id, name: String(key), from: target[key], to: v })
218 target[key] = v
219 return true
220 },
221 })
222 into.push(el)
223 return el
224 }
225
226 // views/show.html renders every control disabled, and the toolbar
227 // buttons with aria-pressed="false" except undo and redo, which have
228 // no pressed state to render. Reproduced exactly: what the first sync
229 // has to change depends on it.
230 function button(name, pressed = true) {
231 return element(
232 'button',
233 name,
234 {
235 'data-docs-command': name,
236 tabindex: '-1',
237 disabled: '',
238 ...(pressed ? { 'aria-pressed': 'false' } : {}),
239 },
240 bar,
241 )
242 }
243
244 // A select as ui/select.js leaves it: the native control still there,
245 // with the enhancement's [rst-combo] wrapper inserted immediately
246 // before it, holding the text input a person actually uses.
247 function selectControl(kind, options) {
248 const wrap = element('div', kind + '-wrap', { 'rst-combo': '' }, bar)
249 const input = element('input', kind + '-combo', { tabindex: '-1', disabled: '' }, bar)
250 wrap.children.push(input)
251 return element(
252 'select',
253 'docs-select-' + kind,
254 {
255 'data-docs-select': kind,
256 'aria-label': 'The ' + kind + ' control',
257 tabindex: '-1',
258 disabled: '',
259 },
260 bar,
261 {
262 previousElementSibling: wrap,
263 options: options.map(([value, text]) => ({ value: String(value), text })),
264 },
265 )
266 }
267
268 // A menu row, as menu.go renders it: disabled from the server, and
269 // named by the same data-docs-command vocabulary the toolbar uses.
270 function menuItem(name, extra = {}) {
271 return element(
272 'button',
273 'menu-' + name,
274 { 'estilo-menu-item': '', 'data-docs-command': name, disabled: '', ...extra },
275 menu,
276 )
277 }
278
279 button('undo', false)
280 button('redo', false)
281 selectControl('block', [
282 ['paragraph', 'Paragraph'],
283 ['heading-1', 'Heading 1'],
284 ['heading-2', 'Heading 2'],
285 ['heading-3', 'Heading 3'],
286 ])
287 selectControl(
288 'font',
289 FONTS.map((f) => [f, f[0].toUpperCase() + f.slice(1)]),
290 )
291 selectControl(
292 'size',
293 SIZES.map((pt) => [pt, String(pt)]),
294 )
295 for (const name of ['strong', 'em', 'code']) button(name)
296 for (const name of ['align_left', 'align_center', 'align_right']) button(name)
297 for (const name of ['bullet_list', 'ordered_list', 'blockquote']) button(name)
298
299 // The menu bar. One title, because the roving tabindex needs at least
300 // one and this file is not testing the roving; and the three shapes
301 // of row that reach different lines of menu.mjs's sync — an editor
302 // command, a LOCAL_COMMANDS one (Print, available whenever the page
303 // is), and a checkable one, whose aria-pressed sync also writes.
304 element('summary', 'menu-title', { 'estilo-menubar-title': '', tabindex: '-1' }, menu)
305 menuItem('strong')
306 menuItem('undo')
307 menuItem('clear_formatting')
308 menuItem('print')
309 menuItem('block_paragraph', { 'aria-pressed': 'false' })
310 menuItem('block_heading-1', { 'aria-pressed': 'false' })
311
312 function rootOver(list) {
313 return {
314 addEventListener() {},
315 contains: (el) => list.includes(el),
316 querySelector: (sel) => list.find((n) => n.matches(sel)) || null,
317 querySelectorAll: (sel) => list.filter((n) => n.matches(sel)),
318 }
319 }
320
321 const everything = () => bar.concat(menu)
322
323 return {
324 toolbarRoot: rootOver(bar),
325 menuRoot: rootOver(menu),
326 log,
327 byId: (id) => everything().find((n) => n.id === id),
328 writesTo: (id, name) => log.filter((w) => w.id === id && w.name === name).map((w) => w.to),
329 }
330}
331
332// Both bars, wired exactly as editor-entry.js wires them: ONE plugin,
333// the toolbar's, with the menu riding on its onSync. `menu` is assigned
334// after createToolbar and read through a closure, which is what lets
335// the very first sync reach it — the same shape editor-entry.js has,
336// and worth copying rather than tidying, because a menu mounted before
337// the toolbar would be driven by a code path the browser never takes.
338//
339// The view is a stand-in. sync reads view.state and hands the view on
340// to commands called with a null dispatch, which is a contract every
341// command in the table honours (buildCommands' own comment has the
342// evening that cost).
343function mount(state) {
344 const dom = makeDOM()
345 const view = { state, focus() {}, dispatch() {} }
346 let menu = null
347 const toolbar = createToolbar({
348 root: dom.toolbarRoot,
349 schema,
350 getView: () => view,
351 onSync: (v) => {
352 if (menu) menu.sync(v)
353 },
354 })
355 menu = mountMenu(dom.menuRoot, {
356 getView: () => view,
357 commands: toolbar.commands,
358 isPressed: (name, v) => toolbar.isPressed(v.state, name),
359 })
360 assert.ok(menu, 'the menu bar should have mounted')
361 const pluginView = toolbar.plugin.spec.view(view)
362 return {
363 dom,
364 to(next) {
365 view.state = next
366 pluginView.update(view)
367 },
368 }
369}
370
371// A document in a monospace 24pt italic heading, with the selection
372// across it — several of the chrome's controls saying something
373// different from what they say over an empty paragraph.
374function headingState() {
375 const text = schema.text('Title', [
376 schema.marks.em.create(),
377 schema.marks.font_mono.create(),
378 schema.marks.size_24.create(),
379 ])
380 const doc = schema.node('doc', null, [schema.node('heading', { level: 1 }, [text])])
381 return EditorState.create({ doc, selection: TextSelection.create(doc, 1, 6) })
382}
383
384test('a state update that changes nothing writes nothing to the chrome', () => {
385 const state = EditorState.create({ schema })
386 const bar = mount(state)
387
388 // The mount itself must write: the chrome arrives disabled and
389 // unpressed and the first sync is what turns it on. Asserted, on both
390 // bars, so this test cannot pass by the recorder seeing nothing at
391 // all, or by one of the two bars never having been driven.
392 assert.ok(bar.dom.log.length > 0, 'the first sync should have written to the chrome')
393 assert.equal(bar.dom.byId('strong').disabled, false)
394 assert.equal(bar.dom.byId('menu-strong').disabled, false)
395 assert.equal(bar.dom.byId('menu-print').disabled, false)
396 // The current paragraph style is checked and enabled.
397 assert.equal(bar.dom.byId('menu-block_paragraph').getAttribute('aria-pressed'), 'true')
398 assert.equal(bar.dom.byId('menu-block_paragraph').disabled, false)
399
400 bar.dom.log.length = 0
401
402 // An empty transaction: a new EditorState with the same document, the
403 // same selection and the same stored marks. ProseMirror delivers one
404 // of these for every meta-only change — and delivers a whole run of
405 // updates whose visible answer is identical while somebody types.
406 bar.to(state.apply(state.tr))
407
408 assert.deepEqual(bar.dom.log, [])
409})
410
411test('a state update that changes something still writes it', () => {
412 // The counterweight to the test above, which "write nothing, ever"
413 // would also pass. Whatever the chrome stops writing, it must go on
414 // writing everything a person can see change.
415 const bar = mount(EditorState.create({ schema }))
416 bar.dom.log.length = 0
417 bar.to(headingState())
418
419 assert.deepEqual(bar.dom.writesTo('em', 'aria-pressed'), ['true'])
420 // And the menu's check column moves with the selection.
421 assert.deepEqual(bar.dom.writesTo('menu-block_heading-1', 'aria-pressed'), ['true'])
422 assert.deepEqual(bar.dom.writesTo('menu-block_paragraph', 'aria-pressed'), ['false'])
423 assert.deepEqual(bar.dom.writesTo('docs-select-block', 'value'), ['heading-1'])
424 assert.deepEqual(bar.dom.writesTo('docs-select-font', 'value'), ['mono'])
425 assert.deepEqual(bar.dom.writesTo('docs-select-size', 'value'), ['24'])
426 // The combobox is the control a person actually reads. select.value
427 // moves no pixel on its own — see showInCombo.
428 assert.deepEqual(bar.dom.writesTo('block-combo', 'value'), ['Heading 1'])
429 assert.deepEqual(bar.dom.writesTo('font-combo', 'value'), ['Mono'])
430 assert.deepEqual(bar.dom.writesTo('size-combo', 'value'), ['24'])
431 // The menu bar too: Clear formatting has nothing to strip over an
432 // empty selection and becomes available once there is one.
433 assert.deepEqual(bar.dom.writesTo('menu-clear_formatting', 'disabled'), [false])
434
435 // And the chrome afterwards says so, which is the thing the redundant
436 // writes were never changing.
437 assert.equal(bar.dom.byId('em').getAttribute('aria-pressed'), 'true')
438 assert.equal(bar.dom.byId('strong').getAttribute('aria-pressed'), 'false')
439 assert.equal(bar.dom.byId('docs-select-block').value, 'heading-1')
440 assert.equal(bar.dom.byId('size-combo').value, '24')
441 assert.equal(bar.dom.byId('menu-clear_formatting').disabled, false)
442})
443
444// The two tests above drive the controls views/show.html renders today.
445// A control added later that this fake does not carry would slip past
446// them — which is the failure mode that took the count from 12 to 23,
447// and then reproduced the whole defect in a second module. So the rule
448// is held against the source as well: neither bar writes to the DOM
449// directly. Every write goes through fixtures/dom-writes.mjs.
450//
451// Two named files rather than a rule over every module that touches the
452// DOM, deliberately. Plenty of modules here write to elements and
453// should — pages.mjs stamps page numbers, wordcount.mjs writes a count,
454// jump-to.mjs builds a palette — and none of them runs on every editor
455// state update. These two do, both from the one plugin, which is what
456// makes a redundant write here cost a frame. Add a third surface to
457// that plugin and add it to this list.
458//
459// THIS CHECK IS THE SECOND NET, NOT THE FIRST. It reads text, and a
460// text rule over JavaScript is approximate in a way the recording fake
461// is not: the stripper below does not parse string literals, so a `//`
462// inside a string blanks the rest of that line and any write after it
463// on the same line becomes invisible here. That is a false PASS, not
464// merely a false positive, and it was found in review by a probe that
465// hid a write behind 'https://' — see makeDOM's own header. The fake's
466// Proxy is what catches that class; this check earns its place on a
467// different one, the control added later that the fake does not carry.
468// Neither is sufficient alone.
469const GUARDED = ['toolbar.mjs', 'menu.mjs']
470
471const WRITE =
472 /\.(?:setAttribute|removeAttribute)\s*\(|\.(?:disabled|value|checked|textContent|innerHTML|className|tabIndex|hidden|title)[ \t]*=(?!=)/g
473
474// Comments go to spaces rather than away, so offsets and line numbers
475// still point at the real file. Both modules' comments discuss the very
476// writes being looked for, at length and by name.
477//
478// It handles // and /* */ and nothing else — in particular it does not
479// know a string literal from code, which is the hole described above.
480// Left as it is deliberately: a stripper that got strings, template
481// literals and regex literals right would be a small parser, and the
482// recording fake already covers what this misses.
483function stripComments(src) {
484 let out = ''
485 let i = 0
486 while (i < src.length) {
487 if (src.startsWith('//', i)) {
488 while (i < src.length && src[i] !== '\n') {
489 out += ' '
490 i++
491 }
492 } else if (src.startsWith('/*', i)) {
493 const end = src.indexOf('*/', i + 2)
494 const stop = end < 0 ? src.length : end + 2
495 for (; i < stop; i++) out += src[i] === '\n' ? '\n' : ' '
496 } else {
497 out += src[i]
498 i++
499 }
500 }
501 return out
502}
503
504function sourceOf(file) {
505 return stripComments(readFileSync(new URL('./' + file, import.meta.url), 'utf8'))
506}
507
508test('no DOM write in either bar bypasses dom-writes.mjs', () => {
509 for (const file of GUARDED) {
510 const code = sourceOf(file)
511 // ok() rather than match(): a failing match() prints the whole
512 // stripped module, which buries the one line that matters.
513 assert.ok(
514 code.includes("from './dom-writes.mjs'"),
515 `${file} should get its writers from dom-writes.mjs`,
516 )
517 const stray = []
518 for (const m of code.matchAll(WRITE)) {
519 stray.push(
520 `${file}:${code.slice(0, m.index).split('\n').length}: ${code
521 .slice(m.index, m.index + 40)
522 .trim()}`,
523 )
524 }
525 assert.deepEqual(stray, [])
526 }
527})
528