| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629 |
- /**
- * 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-document.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) });
- if (this.__stmObservers) {
- this.__stmObservers.forEach(function (o) { o.cb([]); });
- }
- };
- el.removeAttribute = function (n) {
- this.attributes = this.attributes.filter(function (a) { return a.name !== n; });
- if (this.__stmObservers) {
- this.__stmObservers.forEach(function (o) { o.cb([]); });
- }
- };
- 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);
- };
- // --- MutationObserver stub ---------------------------------------------
- // Real browsers deliver attribute mutations asynchronously; the shim runs
- // them SYNCHRONOUSLY inside setAttribute/removeAttribute (patched below
- // once, when the first element is created — the patch lives on the
- // prototype-less makeElement factory by wrapping every element's methods
- // would be too invasive, so we wrap at creation time instead: see
- // makeElement's setAttribute/removeAttribute above — they call
- // notifyObservers(el) after mutating the attribute list).
- var observers = new WeakMap(); // element -> [{filter, cb}]
- function notifyObservers(el) {
- if (el.__stmObservers) {
- el.__stmObservers.forEach(function (o) { o.cb([]); });
- }
- }
- globalThis.MutationObserver = function (callback) {
- this._cb = callback;
- this._targets = [];
- };
- globalThis.MutationObserver.prototype.observe = function (target, opts) {
- var list = target.__stmObservers || (target.__stmObservers = []);
- var self = this;
- list.push({ cb: function () { self._cb([]); } });
- this._targets.push(target);
- };
- globalThis.MutationObserver.prototype.disconnect = function () {
- var self = this;
- this._targets.forEach(function (t) {
- if (t.__stmObservers) {
- t.__stmObservers = t.__stmObservers.filter(function (o) {
- return o.cb._owner !== self;
- });
- }
- });
- };
- globalThis.__shim = {
- log: log,
- body: body,
- makeElement: makeElement,
- makeEvent: makeEvent,
- fetchCalls: fetchCalls,
- setFetch: function (fn) { fetchHandler = fn; },
- okResponse: okResponse,
- failResponse: failResponse,
- flushObservers: notifyObservers
- };
- globalThis.__STATUM_TEST__ = true;
- }
|