@miaskiewicz/turbo-dom 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,14 +25,20 @@ npm install -D @miaskiewicz/turbo-dom
25
25
  ```ts
26
26
  // vitest.config.ts
27
27
  import { defineConfig } from 'vitest/config';
28
+ import { createRequire } from 'node:module';
29
+
30
+ const envPath = createRequire(import.meta.url).resolve('@miaskiewicz/turbo-dom/environment/vitest');
28
31
 
29
32
  export default defineConfig({
30
33
  test: {
31
- environment: '@miaskiewicz/turbo-dom/environment/vitest',
34
+ environment: envPath, // vitest resolves a bare name only for `vitest-environment-*`
35
+ // packages, so a scoped package is referenced by file path
32
36
  },
33
37
  });
34
38
  ```
35
39
 
40
+ Works on vitest 1–4.
41
+
36
42
  ### jest
37
43
 
38
44
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miaskiewicz/turbo-dom",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Faster, more spec-correct DOM for test runners — native html5ever (Rust/WASM) parser + lazy copy-on-write DOM. A drop-in-style alternative to jsdom/happy-dom for vitest & jest.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -64,7 +64,8 @@
64
64
  "bench:suite": "node bench/suite.mjs",
65
65
  "bench:wasm": "node bench/wasm.mjs",
66
66
  "bench:all": "node bench/parse.mjs && node bench/construct.mjs && node bench/suite.mjs && node bench/wasm.mjs",
67
- "prepublishOnly": "napi build --platform --release"
67
+ "prepublishOnly": "napi build --platform --release",
68
+ "test:vitest": "vitest run -c test-vitest/vitest.config.mjs"
68
69
  },
69
70
  "keywords": [
70
71
  "dom",
@@ -116,6 +117,7 @@
116
117
  "jsdom": "^29.1.1",
117
118
  "parse5": "^8.0.1",
118
119
  "react": "^19.2.7",
119
- "react-dom": "^19.2.7"
120
+ "react-dom": "^19.2.7",
121
+ "vitest": "^4.1.8"
120
122
  }
121
123
  }
@@ -6,29 +6,56 @@ import { createEnvironment } from '../runtime/index.mjs';
6
6
 
7
7
  const DEFAULT_HTML = '<!doctype html><html><head></head><body></body></html>';
8
8
 
9
- // Globals that point at the window itself.
10
- const SELF_KEYS = ['window', 'self', 'globalThis', 'parent', 'top', 'frames'];
9
+ // Globals that point at the window itself. Deliberately NOT `globalThis`/`global`
10
+ // redefining those breaks test runners (vitest builds its module-runner vm
11
+ // primitives off globalThis; pointing it at our window Proxy hides Symbol et al).
12
+ const SELF_KEYS = ['window', 'self', 'parent', 'top', 'frames'];
11
13
 
12
14
  export function installGlobals(target, { html = DEFAULT_HTML, url } = {}) {
13
15
  const env = createEnvironment(html, url ? { url } : {});
14
16
  const { window } = env;
15
17
 
16
- const define = (name, getter) => {
17
- Object.defineProperty(target, name, { configurable: true, get: getter, set(v) { Object.defineProperty(target, name, { configurable: true, writable: true, value: v }); } });
18
+ const installed = []; // keys we defined
19
+ const originals = new Map(); // prior descriptors to restore on teardown
20
+
21
+ const define = (name, descriptor) => {
22
+ const prior = Object.getOwnPropertyDescriptor(target, name);
23
+ if (prior) originals.set(name, prior);
24
+ Object.defineProperty(target, name, descriptor);
25
+ installed.push(name);
18
26
  };
19
27
 
20
28
  // window self-references
21
- for (const k of SELF_KEYS) define(k, () => window);
29
+ for (const k of SELF_KEYS) {
30
+ define(k, {
31
+ configurable: true,
32
+ get: () => window,
33
+ set(v) { Object.defineProperty(target, k, { configurable: true, writable: true, value: v }); },
34
+ });
35
+ }
22
36
  // document is eager + universal
23
- Object.defineProperty(target, 'document', { configurable: true, writable: true, value: env.document });
37
+ define('document', { configurable: true, writable: true, enumerable: true, value: env.document });
24
38
 
25
39
  // every other window global → lazy getter (materializes + traces on first read)
26
40
  for (const name of env.globalKeys) {
27
41
  if (name === 'document' || SELF_KEYS.includes(name)) continue;
28
- define(name, () => window[name]);
42
+ define(name, {
43
+ configurable: true,
44
+ get: () => window[name],
45
+ set(v) { Object.defineProperty(target, name, { configurable: true, writable: true, value: v }); },
46
+ });
29
47
  }
30
48
 
31
- // handy escape hatches for adapters / per-test reset
32
- target.__turboDom = env;
33
- return env;
49
+ // handy escape hatch
50
+ define('__turboDom', { configurable: true, writable: true, value: env });
51
+
52
+ const teardown = () => {
53
+ for (const name of installed) {
54
+ delete target[name];
55
+ const prior = originals.get(name);
56
+ if (prior) Object.defineProperty(target, name, prior);
57
+ }
58
+ };
59
+
60
+ return { env, window, teardown };
34
61
  }
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // or point directly at this file:
7
7
  //
8
- // testEnvironment: './node_modules/@miaskiewicz/turbo-dom/dist/environment/jest.cjs'
8
+ // testEnvironment: './node_modules/@miaskiewicz/turbo-dom/src/environment/jest.cjs'
9
9
  //
10
10
  // Per-file / project options:
11
11
  // testEnvironmentOptions: { html: '<!doctype html>...', url: 'http://localhost/' }
@@ -1,31 +1,36 @@
1
- // Vitest environment adapter. Use in vitest config:
1
+ // Vitest environment adapter (vitest 1–4). Use in vitest config:
2
2
  //
3
- // // vitest.config.ts
4
- // export default defineConfig({ test: { environment: 'turbo-dom' } })
3
+ // import TurboDom from '@miaskiewicz/turbo-dom/environment/vitest';
4
+ // export default defineConfig({ test: { environment: TurboDom } });
5
5
  //
6
- // (resolves to the package "vitest-environment-turbo-dom", which re-exports this),
7
- // or point directly at this file:
6
+ // or point at the file path:
8
7
  //
9
- // test: { environment: './node_modules/@miaskiewicz/turbo-dom/dist/environment/vitest.mjs' }
8
+ // test: { environment: './node_modules/@miaskiewicz/turbo-dom/src/environment/vitest.mjs' }
9
+ //
10
+ // (vitest's bare-name `environment: 'name'` only works for a package literally
11
+ // named `vitest-environment-<name>`; use the object or path form for a scoped pkg.)
10
12
  //
11
13
  // Per-file options via environmentOptions:
12
14
  // test: { environmentOptions: { turboDom: { html: '<!doctype html>...', url: 'http://localhost/' } } }
13
15
 
14
16
  import { installGlobals } from './install.mjs';
15
17
 
16
- export default {
18
+ const environment = {
17
19
  name: 'turbo-dom',
18
- // tests run as web/browser-style modules
20
+ // vitest 3/4 read `viteEnvironment`; older versions read `transformMode`.
21
+ viteEnvironment: 'client',
19
22
  transformMode: 'web',
20
23
 
21
24
  setup(global, options) {
22
25
  const opts = (options && options.turboDom) || {};
23
- const env = installGlobals(global, opts);
26
+ const { env, teardown } = installGlobals(global, opts);
24
27
  return {
25
- teardown() {
26
- // drop overlay + materialized globals; nothing leaks across files
28
+ teardown(g) {
29
+ teardown();
27
30
  env.reset();
28
31
  },
29
32
  };
30
33
  },
31
34
  };
35
+
36
+ export default environment;
@@ -133,6 +133,49 @@ export class Node extends EventTarget {
133
133
  return false;
134
134
  }
135
135
 
136
+ get isConnected() {
137
+ let n = this;
138
+ while (n.parentNode) n = n.parentNode;
139
+ return n.nodeType === DOCUMENT_NODE;
140
+ }
141
+ getRootNode() {
142
+ let n = this;
143
+ while (n.parentNode) n = n.parentNode;
144
+ return n;
145
+ }
146
+ normalize() {
147
+ const kids = this.__children();
148
+ for (let i = kids.length - 1; i > 0; i--) {
149
+ if (kids[i].nodeType === TEXT_NODE && kids[i - 1].nodeType === TEXT_NODE) {
150
+ kids[i - 1]._data += kids[i].data; kids[i].parentNode = null; kids.splice(i, 1);
151
+ }
152
+ }
153
+ for (let i = kids.length - 1; i >= 0; i--) {
154
+ if (kids[i].nodeType === TEXT_NODE && kids[i].data === '') { kids[i].parentNode = null; kids.splice(i, 1); }
155
+ else if (kids[i].nodeType === ELEMENT_NODE) kids[i].normalize();
156
+ }
157
+ }
158
+ replaceChildren(...nodes) { this.__kids = []; for (const n of nodes) this.appendChild(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n); }
159
+ // bitmask: 1 DISCONNECTED, 2 PRECEDING, 4 FOLLOWING, 8 CONTAINS, 16 CONTAINED_BY
160
+ compareDocumentPosition(other) {
161
+ if (other === this) return 0;
162
+ if (this.contains(other)) return 16 + 4;
163
+ if (other.contains(this)) return 8 + 2;
164
+ // document order via a flat walk from the common root
165
+ const root = this.getRootNode();
166
+ const order = [];
167
+ (function walk(n) { order.push(n); for (const c of (n.__children ? n.__children() : [])) walk(c); })(root);
168
+ const a = order.indexOf(this), b = order.indexOf(other);
169
+ if (a === -1 || b === -1) return 1;
170
+ return a < b ? 4 : 2;
171
+ }
172
+ cloneNode(deep = false) {
173
+ // base fallback; Element/Text/Comment override
174
+ const n = new this.constructor(this.ownerDocument);
175
+ if (deep) for (const c of this.__children()) n.appendChild(c.cloneNode(true));
176
+ return n;
177
+ }
178
+
136
179
  get textContent() {
137
180
  let s = '';
138
181
  for (const c of this.__children()) {
@@ -150,6 +193,18 @@ export class Node extends EventTarget {
150
193
  get __owner() { return null; }
151
194
  }
152
195
 
196
+ // nodeType + document-position constants on the prototype, so `node.TEXT_NODE`
197
+ // works on instances (dom-accessibility-api and others read them off the node).
198
+ Object.assign(Node.prototype, {
199
+ ELEMENT_NODE: 1, ATTRIBUTE_NODE: 2, TEXT_NODE: 3, CDATA_SECTION_NODE: 4,
200
+ ENTITY_REFERENCE_NODE: 5, ENTITY_NODE: 6, PROCESSING_INSTRUCTION_NODE: 7,
201
+ COMMENT_NODE: 8, DOCUMENT_NODE: 9, DOCUMENT_TYPE_NODE: 10,
202
+ DOCUMENT_FRAGMENT_NODE: 11, NOTATION_NODE: 12,
203
+ DOCUMENT_POSITION_DISCONNECTED: 1, DOCUMENT_POSITION_PRECEDING: 2,
204
+ DOCUMENT_POSITION_FOLLOWING: 4, DOCUMENT_POSITION_CONTAINS: 8,
205
+ DOCUMENT_POSITION_CONTAINED_BY: 16, DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC: 32,
206
+ });
207
+
153
208
  // ----------------------------------------------------- CharacterData ----
154
209
  class CharacterData extends Node {
155
210
  constructor(ownerDocument, data) { super(ownerDocument); this._data = data ?? ''; }
@@ -160,11 +215,33 @@ class CharacterData extends Node {
160
215
  get length() { return this._data.length; }
161
216
  get textContent() { return this._data; }
162
217
  set textContent(v) { this.data = v; }
218
+ substringData(offset, count) { return this._data.slice(offset, offset + count); }
219
+ appendData(s) { this.data = this._data + s; }
220
+ insertData(offset, s) { this.data = this._data.slice(0, offset) + s + this._data.slice(offset); }
221
+ deleteData(offset, count) { this.data = this._data.slice(0, offset) + this._data.slice(offset + count); }
222
+ replaceData(offset, count, s) { this.data = this._data.slice(0, offset) + s + this._data.slice(offset + count); }
223
+ before(...nodes) { const p = this.parentNode; if (p) for (const n of nodes) p.insertBefore(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n, this); }
224
+ after(...nodes) { const p = this.parentNode; if (!p) return; const ref = this.nextSibling; for (const n of nodes) p.insertBefore(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n, ref); }
225
+ replaceWith(...nodes) { const p = this.parentNode; if (!p) return; const ref = this.nextSibling; this.remove(); for (const n of nodes) p.insertBefore(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n, ref); }
163
226
  }
164
227
 
165
228
  export class Text extends CharacterData {
166
229
  get nodeType() { return TEXT_NODE; }
167
230
  get nodeName() { return '#text'; }
231
+ get wholeText() {
232
+ let s = this._data, n = this.previousSibling;
233
+ while (n && n.nodeType === TEXT_NODE) { s = n.data + s; n = n.previousSibling; }
234
+ n = this.nextSibling;
235
+ while (n && n.nodeType === TEXT_NODE) { s += n.data; n = n.nextSibling; }
236
+ return s;
237
+ }
238
+ splitText(offset) {
239
+ const rest = this._data.slice(offset);
240
+ this.data = this._data.slice(0, offset);
241
+ const node = new Text(this.ownerDocument, rest);
242
+ if (this.parentNode) this.parentNode.insertBefore(node, this.nextSibling);
243
+ return node;
244
+ }
168
245
  cloneNode() { return new Text(this.ownerDocument, this._data); }
169
246
  }
170
247
 
@@ -462,6 +539,54 @@ export class Element extends Node {
462
539
  getBoundingClientRect() { return zeroRect(); }
463
540
  getClientRects() { return []; }
464
541
  scrollIntoView() {}
542
+ scroll() {} scrollTo() {} scrollBy() {}
543
+ // honest zero geometry (no layout)
544
+ get offsetWidth() { return 0; } get offsetHeight() { return 0; }
545
+ get offsetTop() { return 0; } get offsetLeft() { return 0; }
546
+ get offsetParent() { return null; }
547
+ get clientWidth() { return 0; } get clientHeight() { return 0; }
548
+ get clientTop() { return 0; } get clientLeft() { return 0; }
549
+ get scrollWidth() { return 0; } get scrollHeight() { return 0; }
550
+ get scrollTop() { return 0; } set scrollTop(_v) {} get scrollLeft() { return 0; } set scrollLeft(_v) {}
551
+
552
+ // namespaced attributes
553
+ getAttributeNS(_ns, name) { return this.getAttribute(name); }
554
+ setAttributeNS(_ns, name, value) { this.setAttribute(name, value); }
555
+ hasAttributeNS(_ns, name) { return this.hasAttribute(name); }
556
+ removeAttributeNS(_ns, name) { this.removeAttribute(name); }
557
+ getAttributeNode(name) { const a = this.__attrs.find((x) => x.name === name); return a ? { name: a.name, value: a.value, ownerElement: this } : null; }
558
+
559
+ // adjacency
560
+ insertAdjacentElement(position, el) {
561
+ const p = this.parentNode;
562
+ switch (position) {
563
+ case 'beforebegin': if (p) p.insertBefore(el, this); break;
564
+ case 'afterbegin': this.insertBefore(el, this.firstChild); break;
565
+ case 'beforeend': this.appendChild(el); break;
566
+ case 'afterend': if (p) p.insertBefore(el, this.nextSibling); break;
567
+ }
568
+ return el;
569
+ }
570
+ insertAdjacentText(position, text) { this.insertAdjacentElement(position, this.ownerDocument.createTextNode(text)); }
571
+
572
+ // commonly-reflected properties
573
+ get tabIndex() { return this.hasAttribute('tabindex') ? parseInt(this.getAttribute('tabindex'), 10) || 0 : (/^(a|button|input|select|textarea)$/.test(this.localName) ? 0 : -1); }
574
+ set tabIndex(v) { this.setAttribute('tabindex', String(v)); }
575
+ get title() { return this.getAttribute('title') ?? ''; } set title(v) { this.setAttribute('title', v); }
576
+ get lang() { return this.getAttribute('lang') ?? ''; } set lang(v) { this.setAttribute('lang', v); }
577
+ get dir() { return this.getAttribute('dir') ?? ''; } set dir(v) { this.setAttribute('dir', v); }
578
+ get hidden() { return this.hasAttribute('hidden'); } set hidden(v) { if (v) this.setAttribute('hidden', ''); else this.removeAttribute('hidden'); }
579
+ get role() { return this.getAttribute('role'); } set role(v) { this.setAttribute('role', v); }
580
+ get contentEditable() { return this.getAttribute('contenteditable') ?? 'inherit'; } set contentEditable(v) { this.setAttribute('contenteditable', v); }
581
+ get isContentEditable() { return this.getAttribute('contenteditable') === 'true' || this.getAttribute('contenteditable') === ''; }
582
+ get hreflang() { return this.getAttribute('hreflang') ?? ''; }
583
+ get target() { return this.getAttribute('target') ?? ''; } set target(v) { this.setAttribute('target', v); }
584
+
585
+ // pointer capture + animations (no-op honest stubs)
586
+ setPointerCapture() {} releasePointerCapture() {} hasPointerCapture() { return false; }
587
+ animate() { return { play() {}, pause() {}, cancel() {}, finish() {}, finished: Promise.resolve(), onfinish: null, cancel_: null }; }
588
+ getAnimations() { return []; }
589
+ requestFullscreen() { return Promise.resolve(); }
465
590
 
466
591
  // canvas (no raster backend — honest no-op context)
467
592
  getContext(type) { return this.localName === 'canvas' ? (this.__ctx ||= makeCanvasStub()) : null; }
@@ -586,6 +711,54 @@ function makeSelection() {
586
711
  };
587
712
  }
588
713
 
714
+ // TreeWalker / NodeIterator over the DOM (doubles for both — common subset).
715
+ class TreeWalker {
716
+ constructor(root, whatToShow, filter) {
717
+ this.root = root; this.whatToShow = whatToShow >>> 0; this.filter = filter; this.currentNode = root;
718
+ this.referenceNode = root; this.pointerBeforeReferenceNode = true;
719
+ }
720
+ __show(node) {
721
+ const bit = node.nodeType === ELEMENT_NODE ? 1 : node.nodeType === TEXT_NODE ? 4 : node.nodeType === COMMENT_NODE ? 128 : 0xffffffff;
722
+ if (!(this.whatToShow & bit) && this.whatToShow !== 0xffffffff) return 3; // FILTER_SKIP
723
+ if (this.filter) {
724
+ const fn = typeof this.filter === 'function' ? this.filter : this.filter.acceptNode;
725
+ return fn.call(this.filter, node);
726
+ }
727
+ return 1; // FILTER_ACCEPT
728
+ }
729
+ __flat() {
730
+ const out = [];
731
+ (function walk(n) { for (const c of (n.__children ? n.__children() : [])) { out.push(c); walk(c); } })(this.root);
732
+ return out.filter((n) => this.__show(n) === 1);
733
+ }
734
+ nextNode() { const all = this.__flat(); const i = all.indexOf(this.currentNode); const next = all[i + 1] ?? (this.currentNode === this.root ? all[0] : null); this.currentNode = next; this.referenceNode = next; return next ?? null; }
735
+ previousNode() { const all = this.__flat(); const i = all.indexOf(this.currentNode); const prev = i > 0 ? all[i - 1] : null; if (prev) { this.currentNode = prev; this.referenceNode = prev; } return prev ?? null; }
736
+ firstChild() { const k = (this.currentNode.__children ? this.currentNode.__children() : []).filter((n) => this.__show(n) === 1); if (k[0]) { this.currentNode = k[0]; return k[0]; } return null; }
737
+ lastChild() { const k = (this.currentNode.__children ? this.currentNode.__children() : []).filter((n) => this.__show(n) === 1); const last = k[k.length - 1]; if (last) { this.currentNode = last; return last; } return null; }
738
+ parentNode() { const p = this.currentNode.parentNode; if (p && p !== this.root.parentNode) { this.currentNode = p; return p; } return null; }
739
+ nextSibling() { let n = this.currentNode.nextSibling; while (n && this.__show(n) !== 1) n = n.nextSibling; if (n) this.currentNode = n; return n ?? null; }
740
+ previousSibling() { let n = this.currentNode.previousSibling; while (n && this.__show(n) !== 1) n = n.previousSibling; if (n) this.currentNode = n; return n ?? null; }
741
+ detach() {}
742
+ }
743
+
744
+ // DOMParser — parses a string into a real Document via the native parser.
745
+ export class DOMParser {
746
+ parseFromString(str, type = 'text/html') {
747
+ if (type === 'text/html') return parseDocument(String(str));
748
+ // XML-ish: parse as a fragment wrapped in a document
749
+ const doc = parseDocument(`<!doctype html><html><body>${str}</body></html>`);
750
+ return doc;
751
+ }
752
+ }
753
+
754
+ // XMLSerializer — serializes a node back to markup.
755
+ export class XMLSerializer {
756
+ serializeToString(node) {
757
+ if (node.nodeType === DOCUMENT_NODE || node.nodeType === DOCUMENT_FRAGMENT_NODE) return serializeInner(node);
758
+ return serializeOuter(node);
759
+ }
760
+ }
761
+
589
762
  // minimal inline-style CSSOM (honest: only inline + explicitly set props)
590
763
  function makeStyle(el) {
591
764
  const parse = () => {
@@ -817,10 +990,44 @@ export class Document extends Node {
817
990
  createDocumentFragment() { return new DocumentFragment(this); }
818
991
  createEvent() { return new Event(''); }
819
992
  createRange() { return new Range(this); }
993
+ createAttribute(name) { return { name, value: '', ownerElement: null }; }
994
+ createComment(data) { return new Comment(this, String(data)); }
820
995
  getSelection() { if (!this.__selection) this.__selection = makeSelection(); return this.__selection; }
821
996
  importNode(node, deep) { return node.cloneNode(deep); }
822
997
  adoptNode(node) { if (node.parentNode) node.parentNode.removeChild(node); node.ownerDocument = this; return node; }
823
998
 
999
+ // TreeWalker / NodeIterator (whatToShow: 1 elements, 4 text, 0xFFFFFFFF all)
1000
+ createTreeWalker(root, whatToShow = 0xffffffff, filter = null) { return new TreeWalker(root, whatToShow, filter); }
1001
+ createNodeIterator(root, whatToShow = 0xffffffff, filter = null) { return new TreeWalker(root, whatToShow, filter); }
1002
+
1003
+ getElementsByName(name) {
1004
+ const self = this; return liveHTMLCollection(() => collectByTag(self, '*').filter((e) => e.getAttribute('name') === name));
1005
+ }
1006
+ elementFromPoint() { return null; }
1007
+ elementsFromPoint() { return []; }
1008
+ execCommand() { return false; }
1009
+ queryCommandSupported() { return false; }
1010
+ queryCommandEnabled() { return false; }
1011
+ hasFocus() { return true; }
1012
+ write() {} writeln() {} open() { return this; } close() {}
1013
+
1014
+ get title() { const t = this.querySelector('title'); return t ? t.textContent : (this.__title || ''); }
1015
+ set title(v) { const t = this.querySelector('title'); if (t) t.textContent = v; else this.__title = String(v); }
1016
+ get location() { return this.defaultView ? this.defaultView.location : null; }
1017
+ get baseURI() { return (this.defaultView && this.defaultView.location && this.defaultView.location.href) || 'about:blank'; }
1018
+ get URL() { return this.baseURI; }
1019
+ get documentURI() { return this.baseURI; }
1020
+ get scrollingElement() { return this.documentElement; }
1021
+ get fullscreenElement() { return null; }
1022
+ get implementation() {
1023
+ const self = this;
1024
+ return {
1025
+ createHTMLDocument(title) { const d = parseDocument(`<!doctype html><html><head><title>${title || ''}</title></head><body></body></html>`); return d; },
1026
+ createDocumentType: (name, pub, sys) => new DocumentType(self, name, pub, sys),
1027
+ hasFeature: () => true,
1028
+ };
1029
+ }
1030
+
824
1031
  // ---- queries ----
825
1032
  getElementById(id) {
826
1033
  let found = null;
@@ -12,7 +12,7 @@ import {
12
12
  } from './stubs.mjs';
13
13
  import {
14
14
  Node, Element, Text, Comment, Document, DocumentFragment, DocumentType, Event, CustomEvent,
15
- MutationObserver,
15
+ MutationObserver, DOMParser, XMLSerializer,
16
16
  } from './dom.mjs';
17
17
  import {
18
18
  EventTarget,
@@ -69,12 +69,29 @@ export function createWindow(document, { url = 'http://localhost/' } = {}) {
69
69
  HTMLSpanElement: Element, HTMLParagraphElement: Element, HTMLUListElement: Element,
70
70
  HTMLLIElement: Element, HTMLHeadingElement: Element, HTMLBodyElement: Element,
71
71
  HTMLDocument: Document, DocumentFragment, ShadowRoot: DocumentFragment,
72
- MutationObserver,
72
+ MutationObserver, DOMParser, XMLSerializer,
73
73
  URL: makeURL(), URLSearchParams,
74
74
  Blob: globalThis.Blob, File: makeFile(), FileReader,
75
75
  customElements: makeCustomElements(),
76
76
  AbortController: globalThis.AbortController, AbortSignal: globalThis.AbortSignal,
77
77
  TextEncoder: globalThis.TextEncoder, TextDecoder: globalThis.TextDecoder,
78
+ // web platform globals Node already provides
79
+ fetch: globalThis.fetch ? (...a) => globalThis.fetch(...a) : undefined,
80
+ Headers: globalThis.Headers, Request: globalThis.Request, Response: globalThis.Response,
81
+ FormData: globalThis.FormData, ReadableStream: globalThis.ReadableStream,
82
+ crypto: globalThis.crypto, Crypto: globalThis.Crypto, SubtleCrypto: globalThis.SubtleCrypto,
83
+ btoa: (s) => Buffer.from(String(s), 'binary').toString('base64'),
84
+ atob: (s) => Buffer.from(String(s), 'base64').toString('binary'),
85
+ MessageChannel: globalThis.MessageChannel, MessagePort: globalThis.MessagePort,
86
+ BroadcastChannel: globalThis.BroadcastChannel, EventSource: globalThis.EventSource,
87
+ reportError: (e) => { /* swallow; tests assert via handlers */ void e; },
88
+ requestIdleCallback: (cb) => hostSetTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0),
89
+ cancelIdleCallback: (id) => hostClearTimeout(id),
90
+ CSS: { supports: () => true, escape: (s) => String(s).replace(/[^a-zA-Z0-9_-]/g, (c) => '\\' + c) },
91
+ XMLHttpRequest: makeXHR(),
92
+ Image: function Image(w, h) { const img = document.createElement('img'); if (w != null) img.setAttribute('width', w); if (h != null) img.setAttribute('height', h); return img; },
93
+ Audio: function Audio(src) { const a = document.createElement('audio'); if (src) a.setAttribute('src', src); return a; },
94
+ Worker: class Worker { constructor() {} postMessage() {} terminate() {} addEventListener() {} removeEventListener() {} },
78
95
  // timers delegate to the captured host fns (NOT the bare names — once these
79
96
  // are installed on globalThis the bare names resolve back here → recursion)
80
97
  setTimeout: (...a) => hostSetTimeout(...a),
@@ -104,12 +121,26 @@ export function createWindow(document, { url = 'http://localhost/' } = {}) {
104
121
  // subsystem grouping: history co-materializes with (and shares) location
105
122
  location: () => makeLocation(url),
106
123
  history: () => makeHistory(windowProxy.location),
107
- navigator: () => ({ userAgent: 'turbo-dom/0.0.1', platform: 'turbo-dom', language: 'en-US', languages: ['en-US'], onLine: true }),
108
- performance: () => ({ now: performanceNow, timeOrigin: 0, mark() {}, measure() {} }),
124
+ navigator: () => ({
125
+ userAgent: 'Mozilla/5.0 (turbo-dom) AppleWebKit/537.36',
126
+ platform: 'turbo-dom', vendor: '', language: 'en-US', languages: ['en-US'],
127
+ onLine: true, cookieEnabled: true, doNotTrack: null, maxTouchPoints: 0,
128
+ hardwareConcurrency: 4, deviceMemory: 8, webdriver: false,
129
+ clipboard: { readText: async () => '', writeText: async () => {}, read: async () => [], write: async () => {} },
130
+ permissions: { query: async () => ({ state: 'prompt', addEventListener() {}, removeEventListener() {} }) },
131
+ sendBeacon: () => true, vibrate: () => false,
132
+ }),
133
+ performance: () => ({ now: performanceNow, timeOrigin: 0, mark() {}, measure() {}, getEntriesByName: () => [], getEntriesByType: () => [], clearMarks() {}, clearMeasures() {} }),
109
134
  Storage: () => Storage,
110
135
  devicePixelRatio: () => 1,
111
136
  innerWidth: () => 1024,
112
137
  innerHeight: () => 768,
138
+ outerWidth: () => 1024,
139
+ outerHeight: () => 768,
140
+ scrollX: () => 0, scrollY: () => 0, pageXOffset: () => 0, pageYOffset: () => 0,
141
+ screenX: () => 0, screenY: () => 0, screenLeft: () => 0, screenTop: () => 0,
142
+ screen: () => ({ width: 1024, height: 768, availWidth: 1024, availHeight: 768, colorDepth: 24, pixelDepth: 24, orientation: { type: 'landscape-primary', angle: 0, addEventListener() {}, removeEventListener() {} } }),
143
+ visualViewport: () => ({ width: 1024, height: 768, scale: 1, offsetLeft: 0, offsetTop: 0, pageLeft: 0, pageTop: 0, addEventListener() {}, removeEventListener() {} }),
113
144
  };
114
145
 
115
146
  windowProxy = new Proxy(base, {
@@ -166,3 +197,32 @@ function makeFile() {
166
197
  constructor(bits = [], name = 'file', opts = {}) { super(bits, opts); this.name = String(name); this.lastModified = opts.lastModified || 0; }
167
198
  };
168
199
  }
200
+
201
+ // Minimal XMLHttpRequest backed by fetch — enough that libraries that construct
202
+ // one and issue a request don't crash. No-network setups still get a clean object.
203
+ function makeXHR() {
204
+ return class XMLHttpRequest {
205
+ constructor() {
206
+ this.readyState = 0; this.status = 0; this.statusText = ''; this.response = ''; this.responseText = '';
207
+ this.responseType = ''; this.timeout = 0; this.withCredentials = false;
208
+ this.onreadystatechange = null; this.onload = null; this.onerror = null; this.onabort = null;
209
+ this.__headers = {}; this.__method = 'GET'; this.__url = ''; this.__listeners = new Map(); this.__aborted = false;
210
+ }
211
+ open(method, url) { this.__method = method; this.__url = url; this.readyState = 1; this.__fire('readystatechange'); }
212
+ setRequestHeader(k, v) { this.__headers[k] = v; }
213
+ getResponseHeader() { return null; }
214
+ getAllResponseHeaders() { return ''; }
215
+ addEventListener(t, cb) { const l = this.__listeners.get(t) || []; l.push(cb); this.__listeners.set(t, l); }
216
+ removeEventListener(t, cb) { const l = this.__listeners.get(t); if (l) this.__listeners.set(t, l.filter((x) => x !== cb)); }
217
+ __fire(type) { const ev = { type, target: this }; if (typeof this['on' + type] === 'function') this['on' + type](ev); for (const cb of this.__listeners.get(type) || []) cb(ev); }
218
+ abort() { this.__aborted = true; this.readyState = 0; this.__fire('abort'); }
219
+ send(body) {
220
+ if (!globalThis.fetch) { this.readyState = 4; this.status = 0; this.__fire('error'); this.__fire('loadend'); return; }
221
+ globalThis.fetch(this.__url, { method: this.__method, headers: this.__headers, body }).then(async (res) => {
222
+ if (this.__aborted) return;
223
+ this.status = res.status; this.statusText = res.statusText; this.responseText = await res.text(); this.response = this.responseText;
224
+ this.readyState = 4; this.__fire('readystatechange'); this.__fire('load'); this.__fire('loadend');
225
+ }).catch(() => { if (this.__aborted) return; this.readyState = 4; this.status = 0; this.__fire('error'); this.__fire('loadend'); });
226
+ }
227
+ };
228
+ }