// fixtures/dom-stub.mjs — the smallest DOM the tests need, and only // the tests: nothing in the bundle imports it (build-bundles.mjs walks // from editor-entry.js, which does not). // // It exists because two of the rules this project cares about are DOM // rules and neither can be checked without one: that the Blocks list // keeps the SAME button elements across a fill (so focus survives a // colleague's keystroke), and that a mount's listeners are actually // gone after destroy() (so a resync does not leave a second copy of // every handler on the page's own elements). Both are about node // identity and listener count, which is exactly what a stub can answer // and what a headless browser answers slowly. // // It is not a DOM. Selectors are matched by attribute presence only — // `[data-select-block]`, `[slides-blocks-buttons]` — because that is // all the code under test asks for. class StubElement { constructor (tag = 'div') { this.tagName = tag.toUpperCase() this.attrs = new Map() this.children = [] this.parentNode = null this.dataset = {} this.listeners = [] this.text = '' this.hidden = false this.type = '' } get textContent () { if (this.children.length) return this.children.map(c => c.textContent).join('') return this.text } set textContent (v) { // The real thing replaces every child with one text node — which // is why the code under test avoids writing it when it need not. this.children = [] this.text = String(v) } setAttribute (name, value) { this.attrs.set(name, String(value)) } getAttribute (name) { return this.attrs.has(name) ? this.attrs.get(name) : null } hasAttribute (name) { return this.attrs.has(name) } removeAttribute (name) { this.attrs.delete(name) } get firstElementChild () { return this.children[0] || null } get nextElementSibling () { if (!this.parentNode) return null const at = this.parentNode.children.indexOf(this) return this.parentNode.children[at + 1] || null } append (...nodes) { for (const n of nodes) { n.remove() n.parentNode = this this.children.push(n) } } insertBefore (node, ref) { node.remove() node.parentNode = this const at = ref ? this.children.indexOf(ref) : -1 if (at < 0) this.children.push(node) else this.children.splice(at, 0, node) return node } remove () { if (!this.parentNode) return // A browser blurs the focused element when it (or an ancestor) // leaves the tree — which is what moving a node does, and what // strip.mjs's sync has to put right afterwards. const doc = globalThis.document if (doc && doc.activeElement && this.contains(doc.activeElement)) doc.activeElement = null const at = this.parentNode.children.indexOf(this) if (at >= 0) this.parentNode.children.splice(at, 1) this.parentNode = null } focus () { if (globalThis.document) globalThis.document.activeElement = this } matches (selector) { const name = selector.replace(/^\[|\]$/g, '') if (name.startsWith('data-')) { const key = name.slice(5).replace(/-([a-z])/g, (m, c) => c.toUpperCase()) return this.dataset[key] !== undefined } return this.attrs.has(name) } descendants () { return this.children.flatMap(c => [c, ...c.descendants()]) } querySelectorAll (selector) { return this.descendants().filter(el => el.matches(selector)) } querySelector (selector) { return this.querySelectorAll(selector)[0] || null } contains (el) { return el === this || this.descendants().includes(el) } closest (selector) { for (let el = this; el; el = el.parentNode) if (el.matches(selector)) return el return null } // The whole point of the listener half: {signal} has to work, or a // destroy() built on AbortController would test as passing while // leaving every handler in place. addEventListener (type, fn, opts = {}) { const entry = { type, fn } this.listeners.push(entry) if (opts && opts.signal) { opts.signal.addEventListener('abort', () => { const at = this.listeners.indexOf(entry) if (at >= 0) this.listeners.splice(at, 1) }) } } removeEventListener (type, fn) { const at = this.listeners.findIndex(l => l.type === type && l.fn === fn) if (at >= 0) this.listeners.splice(at, 1) } countListeners (type) { return this.listeners.filter(l => l.type === type).length } } /** A stub element, optionally with attributes and children. */ export function el (tag = 'div', attrs = {}, children = []) { const node = new StubElement(tag) for (const [k, v] of Object.entries(attrs)) { if (k.startsWith('data-')) node.dataset[k.slice(5).replace(/-([a-z])/g, (m, c) => c.toUpperCase())] = String(v) node.setAttribute(k, v) } node.append(...children) return node } /** * Installs a stub `document` on globalThis for the duration of a test * and returns it plus the undo. Only what the modules under test reach * for: createElement, and activeElement (nothing focused). */ export function withDocument () { const before = globalThis.document const doc = el('html') doc.createElement = tag => new StubElement(tag) doc.activeElement = null globalThis.document = doc return { document: doc, restore () { globalThis.document = before } } }