/** * Minimal DOM shim for the tests/dom suite. * * Unlike the split/markdown shims (which only need statum to LOAD), this one * supports driving the render walk and the forms module end to end: * * - elements with ORDERED attributes, get/set/has/removeAttribute, a real * classList backed by the `class` attribute, children/parent hierarchy, * appendChild/insertBefore/removeChild, nextElementSibling, * cloneNode(true) with FRESH node identity (listeners and live control * state are NOT copied; attributes and children are), * - querySelectorAll for tag, `.class`, `[attr]`, `[attr="v"]`, * `[attr~="v"]`, `*` and comma lists of those (everything the walk and * the forms module ask for), * - an EventTarget model on document AND elements with CAPTURE and bubble * phases and event.target set — statum-forms.js relies on document-level * capture listeners, * - form semantics: el.form (owner form), form.reset() (fires a cancelable * `reset` event, then restores control defaults from attributes), * computed select.value/select.multiple/option.value, input.type, * - the fetch-adjacent globals QuickJS lacks (Headers, URLSearchParams, * FormData, btoa/TextEncoder) plus a scriptable fetch stub, and in-memory * localStorage/sessionStorage. * * document.readyState stays 'loading' so statum-core.js only registers a * DOMContentLoaded listener; tests drive renderAll() directly. */ export function installShim() { var log = { errors: [], warns: [], messages: [] }; var fetchCalls = []; var fetchHandler = null; // ------------------------------------------------------------------------- // Events // ------------------------------------------------------------------------- function makeEvent(type, opts) { return { type: type, target: null, currentTarget: null, eventPhase: '', cancelable: !opts || opts.cancelable !== false, defaultPrevented: false, preventDefault: function () { this.defaultPrevented = true; } }; } function addEventTarget(obj) { obj._listeners = { capture: {}, bubble: {} }; obj.addEventListener = function (type, fn, capture) { var bag = capture ? this._listeners.capture : this._listeners.bubble; (bag[type] = bag[type] || []).push(fn); }; obj.removeEventListener = function () {}; obj.dispatchEvent = function (evt) { evt.target = this; // Path from the target's parent up to (and including) document. var path = []; var n = this.parentNode; while (n) { path.push(n); n = n.parentNode; } function run(node, bag) { var list = bag[evt.type]; if (!list) return; list = list.slice(); for (var i = 0; i < list.length; i++) list[i].call(node, evt); } // Capture: outermost ancestor (document) down to the parent. evt.eventPhase = 'capture'; for (var i = path.length - 1; i >= 0; i--) { evt.currentTarget = path[i]; run(path[i], path[i]._listeners.capture); } // Target: both listener kinds. evt.eventPhase = 'target'; evt.currentTarget = this; run(this, this._listeners.capture); run(this, this._listeners.bubble); // Bubble: parent up to document. evt.eventPhase = 'bubble'; for (var j = 0; j < path.length; j++) { evt.currentTarget = path[j]; run(path[j], path[j]._listeners.bubble); } return !evt.defaultPrevented; }; } // ------------------------------------------------------------------------- // Selector matching (tag / .class / [attr] / [attr=v] / [attr~=v] / *) // ------------------------------------------------------------------------- function parseComplex(sel) { var preds = []; var s = sel.replace(/^\s+|\s+$/g, ''); while (s) { var m; if (s.charAt(0) === '*') { preds.push(function () { return true; }); s = s.slice(1); } else if ((m = s.match(/^([a-zA-Z][a-zA-Z0-9]*)/))) { var tag = m[1].toUpperCase(); preds.push(function (el) { return el.tagName === tag; }); s = s.slice(m[1].length); } else if ((m = s.match(/^\.([a-zA-Z0-9_-]+)/))) { var cls = m[1]; preds.push(function (el) { return hasClass(el, cls); }); s = s.slice(m[0].length); } else if ((m = s.match(/^\[([a-zA-Z0-9_-]+)(?:([~^|*]?=)("([^"]*)"|'([^']*)'|([^\]\s]+)))?\]/))) { var name = m[1], op = m[2] || null, val = m[4] != null ? m[4] : (m[5] != null ? m[5] : m[6]); preds.push(function (el) { var cur = el.getAttribute(name); if (cur === null) return false; if (!op) return true; if (op === '=') return cur === val; if (op === '~=') return (' ' + cur + ' ').split(/\s+/).indexOf(val) !== -1; if (op === '^=') return cur.indexOf(val) === 0; if (op === '$=') return cur.indexOf(val) === cur.length - val.length; if (op === '*=') return cur.indexOf(val) !== -1; if (op === '|=') return cur === val || cur.indexOf(val + '-') === 0; return false; }); s = s.slice(m[0].length); } else { return null; // unsupported selector: matches nothing } s = s.replace(/^\s+/, ''); } return preds.length ? preds : null; } function matches(el, complex) { for (var i = 0; i < complex.length; i++) { if (!complex[i](el)) return false; } return true; } function descendants(root, out) { var kids = root.children || []; for (var i = 0; i < kids.length; i++) { out.push(kids[i]); descendants(kids[i], out); } return out; } function qsa(root, selector) { var complexes = String(selector).split(',').map(function (c) { return parseComplex(c); }); var all = descendants(root, []); var hits = []; for (var i = 0; i < all.length; i++) { for (var j = 0; j < complexes.length; j++) { if (complexes[j] && matches(all[i], complexes[j])) { hits.push(all[i]); break; } } } return hits; } // ------------------------------------------------------------------------- // Elements // ------------------------------------------------------------------------- function hasClass(el, cls) { var cur = el.getAttribute('class') || ''; return cur.split(/\s+/).indexOf(cls) !== -1; } function makeElement(tag) { if (String(tag || '').toLowerCase() === 'form') return makeForm(); var el = { nodeType: 1, tagName: (tag || 'div').toUpperCase(), /** Ordered {name, value} pairs; re-setting keeps the original slot. */ attributes: [], children: [], parentNode: null, innerHTML: '', textContent: '', style: {}, value: undefined, checked: undefined, selected: undefined }; // ----- attributes ----- el.setAttribute = function (n, v) { var hit = null; for (var i = 0; i < this.attributes.length; i++) { if (this.attributes[i].name === n) { hit = this.attributes[i]; break; } } if (hit) hit.value = String(v); else this.attributes.push({ name: n, value: String(v) }); }; el.removeAttribute = function (n) { this.attributes = this.attributes.filter(function (a) { return a.name !== n; }); }; el.hasAttribute = function (n) { for (var i = 0; i < this.attributes.length; i++) { if (this.attributes[i].name === n) return true; } return false; }; el.getAttribute = function (n) { for (var i = 0; i < this.attributes.length; i++) { if (this.attributes[i].name === n) return this.attributes[i].value; } return null; }; // ----- classList (backed by the `class` attribute) ----- el.classList = { add: function (c) { var cur = el.getAttribute('class') || ''; var parts = cur ? cur.split(/\s+/).filter(Boolean) : []; if (parts.indexOf(c) === -1) { parts.push(c); el.setAttribute('class', parts.join(' ')); } }, remove: function (c) { var cur = el.getAttribute('class') || ''; var parts = cur ? cur.split(/\s+/).filter(function (p) { return p && p !== c; }) : []; if (parts.length) el.setAttribute('class', parts.join(' ')); else el.removeAttribute('class'); }, contains: function (c) { return hasClass(el, c); } }; // ----- tree ----- el.appendChild = function (c) { if (c.parentNode) c.parentNode.removeChild(c); c.parentNode = this; this.children.push(c); return c; }; el.insertBefore = function (c, ref) { if (c.parentNode) c.parentNode.removeChild(c); c.parentNode = this; var i = ref ? this.children.indexOf(ref) : this.children.length; if (i < 0) i = this.children.length; this.children.splice(i, 0, c); return c; }; el.removeChild = function (c) { var i = this.children.indexOf(c); if (i >= 0) this.children.splice(i, 1); c.parentNode = null; return c; }; el.remove = function () { if (this.parentNode) this.parentNode.removeChild(this); }; Object.defineProperty(el, 'nextElementSibling', { get: function () { if (!this.parentNode) return null; var i = this.parentNode.children.indexOf(this); return this.parentNode.children[i + 1] || null; } }); Object.defineProperty(el, 'nextSibling', { get: function () { return this.nextElementSibling; } }); el.contains = function (other) { while (other) { if (other === this) return true; other = other.parentNode; } return false; }; Object.defineProperty(el, 'firstChild', { get: function () { return this.children[0] || null; } }); el.cloneNode = function (deep) { var copy = makeElement(this.tagName); copy.attributes = this.attributes.map(function (a) { return { name: a.name, value: a.value }; }); copy.innerHTML = this.innerHTML; if (this.textContent !== undefined && this.textContent !== '') copy.textContent = this.textContent; // value/checked/selected are live state, not attributes: NOT copied // (fresh controls read as clean, exactly like the browser). if (deep) { for (var i = 0; i < this.children.length; i++) copy.appendChild(this.children[i].cloneNode(true)); } return copy; }; el.querySelectorAll = function (sel) { return qsa(this, sel); }; el.querySelector = function (sel) { return qsa(this, sel)[0] || null; }; el.closest = function (sel) { var complexes = String(sel).split(',').map(function (c) { return parseComplex(c); }); var n = this; while (n && n.nodeType === 1) { for (var j = 0; j < complexes.length; j++) { if (complexes[j] && matches(n, complexes[j])) return n; } n = n.parentNode; } return null; }; Object.defineProperty(el, 'form', { get: function () { var n = this.parentNode; while (n) { if (n.tagName === 'FORM') return n; n = n.parentNode; } return null; } }); // ----- control-type property semantics ----- if (el.tagName === 'INPUT') { var live = { value: false, checked: false, _v: '', _c: false }; Object.defineProperty(el, 'type', { get: function () { return this.getAttribute('type') || 'text'; }, set: function (v) { this.setAttribute('type', v); } }); Object.defineProperty(el, 'value', { get: function () { if (live.value) return live._v; var a = this.getAttribute('value'); return a !== null ? a : ''; }, set: function (v) { live.value = true; live._v = v; } }); Object.defineProperty(el, 'checked', { get: function () { return live.checked ? live._c : this.hasAttribute('checked'); }, set: function (v) { live.checked = true; live._c = !!v; } }); } else if (el.tagName === 'TEXTAREA') { var ta = { value: false, _v: '' }; Object.defineProperty(el, 'value', { get: function () { return ta.value ? ta._v : (this.textContent || ''); }, set: function (v) { ta.value = true; ta._v = v; } }); } else if (el.tagName === 'SELECT') { Object.defineProperty(el, 'type', { get: function () { return this.multiple ? 'select-multiple' : 'select-one'; } }); Object.defineProperty(el, 'multiple', { get: function () { return this.hasAttribute('multiple'); }, set: function (v) { if (v) this.setAttribute('multiple', ''); else this.removeAttribute('multiple'); } }); Object.defineProperty(el, 'value', { get: function () { var opts = qsa(this, 'option'); for (var i = 0; i < opts.length; i++) { if (opts[i].selected) return opts[i].value; } return ''; }, set: function (v) { var opts = qsa(this, 'option'); var s = v == null ? '' : String(v); for (var i = 0; i < opts.length; i++) opts[i].selected = opts[i].value === s; } }); } else if (el.tagName === 'OPTION') { Object.defineProperty(el, 'value', { get: function () { var a = this.getAttribute('value'); return a !== null ? a : (this.textContent || ''); }, set: function (v) { this.setAttribute('value', v); } }); } addEventTarget(el); return el; } /** Restore a form's controls to their attribute-declared defaults. */ function restoreDefaults(form) { var controls = qsa(form, 'input,select,textarea'); for (var i = 0; i < controls.length; i++) { var c = controls[i]; if (c.tagName === 'INPUT') { var t = c.getAttribute('type') || 'text'; if (t === 'checkbox' || t === 'radio') { c.checked = c.hasAttribute('checked'); } else if (t !== 'file') { c.value = c.getAttribute('value') !== null ? c.getAttribute('value') : ''; } } else if (c.tagName === 'TEXTAREA') { c.value = c.getAttribute('value') !== null ? c.getAttribute('value') : (c.textContent || ''); } else if (c.tagName === 'SELECT') { var opts = qsa(c, 'option'); for (var j = 0; j < opts.length; j++) opts[j].selected = opts[j].hasAttribute('selected'); } } } function makeForm() { var f = makeElement('_form'); // generic path; '_form' is not special-cased f.tagName = 'FORM'; f.reset = function () { var evt = makeEvent('reset'); this.dispatchEvent(evt); if (evt.defaultPrevented) return; restoreDefaults(this); }; f.submit = function () {}; return f; } // ------------------------------------------------------------------------- // FormData / Headers / URLSearchParams // ------------------------------------------------------------------------- function FormData(form) { this._pairs = []; if (!form) return; var controls = qsa(form, 'input,select,textarea'); for (var i = 0; i < controls.length; i++) { var c = controls[i]; var name = c.getAttribute('name'); if (!name) continue; if (c.tagName === 'INPUT') { var t = c.getAttribute('type') || 'text'; if (t === 'checkbox' || t === 'radio') { if (c.checked) this._pairs.push([name, c.value == null ? '' : String(c.value)]); } else if (t === 'file') { /* no file state in the shim */ } else this._pairs.push([name, c.value == null ? '' : String(c.value)]); } else if (c.tagName === 'TEXTAREA') { this._pairs.push([name, c.value == null ? '' : String(c.value)]); } else if (c.tagName === 'SELECT') { var opts = qsa(c, 'option'); for (var j = 0; j < opts.length; j++) { if (opts[j].selected) this._pairs.push([name, opts[j].value]); } } } } FormData.prototype.forEach = function (fn) { for (var i = 0; i < this._pairs.length; i++) fn(this._pairs[i][1], this._pairs[i][0]); }; /** Minimal File stand-in so `value instanceof File` checks in core pass. */ function File() {} globalThis.File = File; function Headers(init) { this._h = {}; if (init && typeof init.forEach === 'function') { var self = this; init.forEach(function (v, k) { self._h[String(k).toLowerCase()] = String(v); }); } } Headers.prototype.set = function (n, v) { this._h[String(n).toLowerCase()] = String(v); }; Headers.prototype.append = function (n, v) { var k = String(n).toLowerCase(); this._h[k] = this._h[k] ? this._h[k] + ', ' + v : String(v); }; Headers.prototype.get = function (n) { var k = Object.keys(this._h).filter(function (x) { return x === String(n).toLowerCase(); })[0]; return k !== undefined ? this._h[k] : null; }; function URLSearchParams(params) { this._pairs = []; if (params && typeof params === 'object') { var self = this; Object.keys(params).forEach(function (k) { var v = params[k]; self._pairs.push([k, v == null ? '' : String(v)]); }); } } URLSearchParams.prototype.toString = function () { return this._pairs.map(function (p) { return encodeURIComponent(p[0]) + '=' + encodeURIComponent(p[1]); }).join('&'); }; function makeStorage() { var m = {}; return { get length() { return Object.keys(m).length; }, key: function (i) { return Object.keys(m)[i] || null; }, getItem: function (k) { return k in m ? m[k] : null; }, setItem: function (k, v) { m[k] = String(v); }, removeItem: function (k) { delete m[k]; } }; } // ------------------------------------------------------------------------- // Globals // ------------------------------------------------------------------------- globalThis.console = { log: function () { log.messages.push(['log', Array.prototype.slice.call(arguments)]); }, info: function () { log.messages.push(['info', Array.prototype.slice.call(arguments)]); }, debug: function () { log.messages.push(['debug', Array.prototype.slice.call(arguments)]); }, warn: function () { log.warns.push(Array.prototype.slice.call(arguments).join(' ')); log.messages.push(['warn', Array.prototype.slice.call(arguments)]); }, error: function () { log.errors.push(Array.prototype.slice.call(arguments).join(' ')); log.messages.push(['error', Array.prototype.slice.call(arguments)]); } }; var body = makeElement('body'); var documentElement = makeElement('html'); documentElement.appendChild(body); var doc = { nodeType: 9, readyState: 'loading', cookie: '', body: body, head: makeElement('head'), documentElement: documentElement, createElement: function (tag) { return tag.toLowerCase() === 'form' ? makeForm() : makeElement(tag); }, createTextNode: function (t) { return { nodeType: 3, textContent: t }; }, getElementById: function (id) { return qsa(documentElement, '*').filter(function (el) { return el.getAttribute('id') === id; })[0] || null; }, querySelectorAll: function (sel) { return qsa(documentElement, sel); }, querySelector: function (sel) { return qsa(documentElement, sel)[0] || null; } }; addEventTarget(doc); globalThis.document = doc; // Event propagation continues through the document node (browsers run // document-level capture listeners before the target), so the tree root's // parent IS the document. qsa/closest must stop at element boundaries. documentElement.parentNode = doc; globalThis.window = globalThis; globalThis.location = { href: 'http://localhost/', protocol: 'http:', addEventListener: function () {} }; globalThis.navigator = { userAgent: 'qjs' }; globalThis.addEventListener = function () {}; globalThis.removeEventListener = function () {}; globalThis.alert = function () {}; globalThis.confirm = function () { return true; }; globalThis.Headers = Headers; globalThis.URLSearchParams = URLSearchParams; globalThis.FormData = FormData; globalThis.localStorage = makeStorage(); globalThis.sessionStorage = makeStorage(); globalThis.TextEncoder = function () { this.encode = function (s) { var out = []; for (var i = 0; i < s.length; i++) out.push(s.charCodeAt(i) & 0xff); return out; }; }; globalThis.btoa = function (s) { var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; var out = ''; for (var i = 0; i < s.length; i += 3) { var b1 = s.charCodeAt(i), b2 = s.charCodeAt(i + 1), b3 = s.charCodeAt(i + 2); out += chars.charAt(b1 >> 2); out += chars.charAt(((b1 & 3) << 4) | ((b2 || 0) >> 4)); out += b2 !== undefined ? chars.charAt(((b2 & 15) << 2) | ((b3 || 0) >> 6)) : '='; out += b3 !== undefined ? chars.charAt(b3 & 63) : '='; } return out; }; /** Canned responses for the fetch stub. */ function okResponse(directives) { return { ok: true, status: 200, headers: { get: function (n) { return String(n).toLowerCase() === 'content-type' ? 'application/vnd.statum+json' : null; } }, json: function () { return Promise.resolve(directives || []); } }; } function failResponse(status) { return { ok: false, status: status || 500, headers: { get: function () { return null; } }, json: function () { return Promise.resolve([]); } }; } globalThis.fetch = function (url, init) { fetchCalls.push({ url: String(url), method: (init && init.method) || 'GET', body: init && init.body }); var res = fetchHandler ? fetchHandler(String(url), init || {}) : okResponse([]); return Promise.resolve(res); }; globalThis.__shim = { log: log, body: body, makeElement: makeElement, makeEvent: makeEvent, fetchCalls: fetchCalls, setFetch: function (fn) { fetchHandler = fn; }, okResponse: okResponse, failResponse: failResponse }; globalThis.__STATUM_TEST__ = true; }