| 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. |
| 42 | import { test } from 'node:test' |
| 43 | import assert from 'node:assert/strict' |
| 44 | import { readFileSync } from 'node:fs' |
| 45 | import { Schema } from 'prosemirror-model' |
| 46 | import { EditorState, TextSelection } from 'prosemirror-state' |
| 47 | import { schema as basic } from 'prosemirror-schema-basic' |
| 48 | import { addListNodes } from 'prosemirror-schema-list' |
| 49 | import { alignedParagraph } from './align.mjs' |
| 50 | import { withIndent } from './indent.mjs' |
| 51 | import { SIZE_MARKS, SIZES } from './sizes.mjs' |
| 52 | import { FONTS } from './fonts.mjs' |
| 53 | import { createToolbar } from './toolbar.mjs' |
| 54 | import { 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. |
| 62 | globalThis.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). |
| 70 | function spanMark(cls, group) { |
| 71 | return { |
| 72 | group, |
| 73 | excludes: group, |
| 74 | parseDOM: [{ tag: 'span.' + cls }], |
| 75 | toDOM: () => ['span', { class: cls }, 0], |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | const 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 | |
| 87 | const 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 | |
| 95 | const 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. |
| 135 | function 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). |
| 343 | function 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. |
| 374 | function 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 | |
| 384 | test('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 | |
| 411 | test('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. |
| 469 | const GUARDED = ['toolbar.mjs', 'menu.mjs'] |
| 470 | |
| 471 | const 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. |
| 483 | function 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 | |
| 504 | function sourceOf(file) { |
| 505 | return stripComments(readFileSync(new URL('./' + file, import.meta.url), 'utf8')) |
| 506 | } |
| 507 | |
| 508 | test('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 | |