buninu-browser 0.5.0 → 0.5.1

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.
@@ -0,0 +1,26 @@
1
+ import * as PropertySymbol from '../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts';
2
+
3
+ // Happy DOM shares HTMLLinkElement.prototype between windows. Install one
4
+ // dispatch hook, but bypass its loader only for windows owned by our renderer.
5
+ const inertWindows = new WeakSet();
6
+ let installed = false;
7
+ export function makeLinksInert(window) {
8
+ inertWindows.add(window);
9
+ if (installed) return;
10
+ installed = true;
11
+ const base = window.HTMLElement.prototype;
12
+ const prototype = window.HTMLLinkElement.prototype;
13
+ const connected = prototype[PropertySymbol.connectedToDocument];
14
+ const setAttribute = prototype[PropertySymbol.onSetAttribute];
15
+ prototype[PropertySymbol.connectedToDocument] = function() {
16
+ if (!inertWindows.has(this[PropertySymbol.window])) return connected.call(this);
17
+ // The renderer owns load/error dispatch; the disabled parser loader must
18
+ // not emit a premature error when a dynamic stylesheet is connected.
19
+ base[PropertySymbol.connectedToDocument].call(this);
20
+ this.ownerDocument[PropertySymbol.clearComputedStyleCache]();
21
+ };
22
+ prototype[PropertySymbol.onSetAttribute] = function(attribute, replacedAttribute) {
23
+ if (!inertWindows.has(this[PropertySymbol.window])) return setAttribute.call(this, attribute, replacedAttribute);
24
+ base[PropertySymbol.onSetAttribute].call(this, attribute, replacedAttribute);
25
+ };
26
+ }
@@ -0,0 +1,36 @@
1
+ import Node from '../../vendor/happy-dom/packages/happy-dom/src/nodes/node/Node.ts';
2
+ import * as PropertySymbol from '../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts';
3
+
4
+ // NodeIterator's pre-removing steps must run synchronously, including removals
5
+ // by innerHTML, normalize and reparenting. All reach Happy DOM's internal hook.
6
+ const roots = new WeakMap();
7
+ const cleanup = new FinalizationRegistry(({ entries, ref }) => entries.delete(ref));
8
+ let installed = false;
9
+ export function observeIteratorRemoval(root, iterator) {
10
+ let entries = roots.get(root);
11
+ if (!entries) roots.set(root, entries = new Set());
12
+ const ref = new WeakRef(iterator);
13
+ entries.add(ref);
14
+ cleanup.register(iterator, { entries, ref });
15
+ if (installed) return;
16
+ installed = true;
17
+ const remove = Node.prototype[PropertySymbol.removeChild];
18
+ Node.prototype[PropertySymbol.removeChild] = function(node) {
19
+ node = node?.[PropertySymbol.proxy] ?? node;
20
+ // select/form methods are bound to the raw target, while parentNode and
21
+ // iterator roots expose its proxy. Compare and walk the public identity.
22
+ const parent = this[PropertySymbol.proxy] ?? this;
23
+ if (node?.parentNode === parent) {
24
+ for (let ancestor = parent; ancestor; ancestor = ancestor.parentNode) {
25
+ const entries = roots.get(ancestor);
26
+ if (!entries) continue;
27
+ for (const ref of entries) {
28
+ const iterator = ref.deref();
29
+ if (iterator) iterator.preRemove(node);
30
+ else entries.delete(ref);
31
+ }
32
+ }
33
+ }
34
+ return remove.call(this, node);
35
+ };
36
+ }
@@ -0,0 +1,60 @@
1
+ /** DOM replace: validate first, detach the incoming node, remove the outgoing
2
+ * node, then insert. Happy DOM's insert-then-remove order skips replacements
3
+ * in live NodeIterators. Keep this on the renderer bindings' mutation path.
4
+ * https://dom.spec.whatwg.org/#concept-node-replace */
5
+ function validate(parent, node, child) {
6
+ const fail = (name) => { throw new DOMException('Invalid node replacement', name); };
7
+ if (![1,9,11].includes(parent.nodeType)) fail('HierarchyRequestError');
8
+ for (let ancestor = parent; ancestor; ancestor = ancestor.parentNode ?? ancestor.host) {
9
+ if (ancestor === node) fail('HierarchyRequestError');
10
+ }
11
+ if (child.parentNode !== parent) fail('NotFoundError');
12
+ if (![1,3,4,7,8,10,11].includes(node.nodeType)) fail('HierarchyRequestError');
13
+ if (node.nodeType === 10 && parent.nodeType !== 9) fail('HierarchyRequestError');
14
+ if (parent.nodeType !== 9) return;
15
+ // Validate the final document, allowing the outgoing child and an incoming
16
+ // sibling to be replaced/moved without counting either twice.
17
+ const added = node.nodeType === 11 ? [...node.childNodes] : [node];
18
+ const final = [];
19
+ for (const current of parent.childNodes) {
20
+ if (current === child) final.push(...added);
21
+ else if (current !== node) final.push(current);
22
+ }
23
+ let element = false, doctype = false;
24
+ for (const current of final) {
25
+ if (current.nodeType === 3 || current.nodeType === 4) fail('HierarchyRequestError');
26
+ if (current.nodeType === 1) {
27
+ if (element) fail('HierarchyRequestError');
28
+ element = true;
29
+ } else if (current.nodeType === 10) {
30
+ if (doctype || element) fail('HierarchyRequestError');
31
+ doctype = true;
32
+ }
33
+ }
34
+ }
35
+
36
+ export function replaceNode(parent, node, child) {
37
+ validate(parent, node, child);
38
+ let reference = child.nextSibling;
39
+ if (reference === node) reference = node.nextSibling;
40
+ if (node.parentNode) node.parentNode.removeChild(node);
41
+ if (child.parentNode) child.parentNode.removeChild(child);
42
+ parent.insertBefore(node, reference);
43
+ return child;
44
+ }
45
+
46
+ export function replaceWithNodes(child, nodes) {
47
+ const parent = child.parentNode;
48
+ if (!parent) return;
49
+ let reference = child.nextSibling;
50
+ while (reference && nodes.includes(reference)) reference = reference.nextSibling;
51
+ // DOM's convert-nodes algorithm moves multiple arguments into a fragment
52
+ // first, including the receiver if it occurs among the arguments.
53
+ let node = nodes[0];
54
+ if (nodes.length !== 1) {
55
+ node = child.ownerDocument.createDocumentFragment();
56
+ for (const item of nodes) node.appendChild(item);
57
+ }
58
+ if (child.parentNode === parent) replaceNode(parent, node, child);
59
+ else parent.insertBefore(node, reference);
60
+ }
@@ -1,3 +1,4 @@
1
+ import { makeLinksInert } from './inert-link.js';
1
2
  import HTMLParser from "../../vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts";
2
3
  import Window from "../../vendor/happy-dom/packages/happy-dom/src/window/Window.ts";
3
4
  import * as PropertySymbol from "../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts";
@@ -26,7 +27,7 @@ export function parseHTMLDocument(html, url = "about:blank", referrer = "") {
26
27
 
27
28
  /** An isolated parser context: no script execution, resource loads or page custom elements. */
28
29
  export function createInertWindow(url = "about:blank") {
29
- return new Window({
30
+ const window = new Window({
30
31
  url,
31
32
  settings: {
32
33
  disableJavaScriptEvaluation: true,
@@ -40,6 +41,8 @@ export function createInertWindow(url = "about:blank") {
40
41
  navigation: { disableFallbackToSetURL: true },
41
42
  },
42
43
  });
44
+ makeLinksInert(window);
45
+ return window;
43
46
  }
44
47
 
45
48
  /**
@@ -1858,8 +1858,10 @@ function resolveHorizontal(ctx, box, containingX, containingWidth, shrink, perce
1858
1858
  return { x: containingX + marginLeft, width, marginLeft, marginRight };
1859
1859
  }
1860
1860
 
1861
+ // Anonymous flex/grid items share their parent RenderNode, but measure only
1862
+ // their own text run. Never reuse the parent's intrinsic width for these boxes.
1861
1863
  function maxContentWidth(ctx, box) {
1862
- const cacheKey = box.node ?? box;
1864
+ const cacheKey = box.anonymous ? box : box.node ?? box;
1863
1865
  const cached = ctx.maxContent.get(cacheKey);
1864
1866
  if (cached != null) return cached;
1865
1867
  const style = box.style;
@@ -1902,7 +1904,7 @@ function maxContentWidth(ctx, box) {
1902
1904
  }
1903
1905
 
1904
1906
  function minContentWidth(ctx, box) {
1905
- const cacheKey = box.node ?? box;
1907
+ const cacheKey = box.anonymous ? box : box.node ?? box;
1906
1908
  const cached = ctx.minContent.get(cacheKey);
1907
1909
  if (cached != null) return cached;
1908
1910
  const style = box.style;
@@ -10,6 +10,8 @@
10
10
  *
11
11
  * Coverage is the commonly used subset of DOM, not the full WebIDL surface.
12
12
  */
13
+ import { replaceNode, replaceWithNodes } from '../happy-dom/node-replacement.js';
14
+ import { observeIteratorRemoval } from '../happy-dom/node-iterator-removal.js';
13
15
  import { renderedText, setRenderedText } from "./rendered-text.js";
14
16
  import { assignedSlot, slotAssignedNodes, assignSlotNodes } from "./slot-assignment.js";
15
17
  import { scrollIntoViewOptions, scrollAlignmentDelta } from "./scroll-into-view.js";
@@ -503,7 +505,7 @@ class NodeBinding {
503
505
  replaceChild(child, old) {
504
506
  const node = requireNode(child);
505
507
  const list = insertionList([node]);
506
- this.#node.replaceChild(node, requireNode(old));
508
+ replaceNode(this.#node, node, requireNode(old));
507
509
  inserted(this, list);
508
510
  return old;
509
511
  }
@@ -1053,7 +1055,7 @@ class ElementBinding extends ParentBinding {
1053
1055
  replaceWith(...values) {
1054
1056
  const nodes = values.map((value) => toNode(this, value));
1055
1057
  const list = insertionList(nodes);
1056
- nodeOf(this).replaceWith(...nodes);
1058
+ replaceWithNodes(nodeOf(this), nodes);
1057
1059
  inserted(this, list);
1058
1060
  }
1059
1061
 
@@ -1690,6 +1692,10 @@ class DocumentBinding extends ParentBinding {
1690
1692
  return realmOf(this).wrap(nodeOf(this).createComment(String(text)));
1691
1693
  }
1692
1694
 
1695
+ createNodeIterator(root, whatToShow = NodeFilter.SHOW_ALL, filter = null) {
1696
+ return new NodeIteratorBinding(CONSTRUCT, realmOf(this), requireNode(root), whatToShow, filter);
1697
+ }
1698
+
1693
1699
  createTreeWalker(root, whatToShow = NodeFilter.SHOW_ALL, filter = null) {
1694
1700
  return new TreeWalkerBinding(CONSTRUCT, realmOf(this), requireNode(root), whatToShow, filter);
1695
1701
  }
@@ -2033,6 +2039,101 @@ export const NodeFilter = Object.freeze({
2033
2039
  });
2034
2040
  const { FILTER_ACCEPT, FILTER_REJECT, FILTER_SKIP } = NodeFilter;
2035
2041
 
2042
+ /** DOM §6.1: a cursor between nodes, including the root; REJECT skips
2043
+ * only that node, unlike TreeWalker where it also prunes descendants. */
2044
+ class NodeIteratorBinding {
2045
+ #realm; #root; #reference; #before = true; #whatToShow; #filter; #active = false;
2046
+ #candidate = null;
2047
+ constructor(key, realm, root, whatToShow, filter) {
2048
+ if (key !== CONSTRUCT) throw new TypeError("Illegal constructor");
2049
+ this.#realm = realm; this.#root = this.#reference = root;
2050
+ this.#whatToShow = +whatToShow >>> 0; this.#filter = filter ?? null;
2051
+ // WebIDL callback interfaces accept objects/functions or null, but no
2052
+ // primitives; validate even when whatToShow would skip every node.
2053
+ if (this.#filter !== null && typeof filter !== "object" && typeof filter !== "function") {
2054
+ throw new TypeError("NodeIterator filter must be an object or null");
2055
+ }
2056
+ // Keep the internal removal callback off the page-visible interface.
2057
+ observeIteratorRemoval(root, this.#removal);
2058
+ }
2059
+ #removal = { preRemove: node => this.#preRemove(node) };
2060
+ get root() { return this.#realm.wrap(this.#root); }
2061
+ get referenceNode() { return this.#realm.wrap(this.#reference); }
2062
+ get pointerBeforeReferenceNode() { return this.#before; }
2063
+ get whatToShow() { return this.#whatToShow; }
2064
+ get filter() { return this.#filter; }
2065
+ detach() {} // Legacy no-op, as required by DOM.
2066
+ nextNode() { return this.#traverse(true); }
2067
+ previousNode() { return this.#traverse(false); }
2068
+ #following(node, children = true) {
2069
+ if (children && node.firstChild) return node.firstChild;
2070
+ while (node && node !== this.#root) {
2071
+ if (node.nextSibling) return node.nextSibling;
2072
+ node = node.parentNode;
2073
+ }
2074
+ return null;
2075
+ }
2076
+ #preceding(node) {
2077
+ if (node === this.#root) return null;
2078
+ if (!node.previousSibling) return node.parentNode;
2079
+ node = node.previousSibling;
2080
+ while (node.lastChild) node = node.lastChild;
2081
+ return node;
2082
+ }
2083
+ #accept(node) {
2084
+ if (this.#active) throw new DOMException("The iterator filter is already active", "InvalidStateError");
2085
+ if (!(this.#whatToShow & (1 << (node.nodeType - 1)))) return FILTER_SKIP;
2086
+ if (this.#filter === null) return FILTER_ACCEPT;
2087
+ this.#active = true;
2088
+ try {
2089
+ const filter = this.#filter, wrapper = this.#realm.wrap(node);
2090
+ return +(typeof filter === "function" ? Reflect.apply(filter, undefined, [wrapper])
2091
+ : Reflect.apply(filter.acceptNode, filter, [wrapper])) & 0xffff;
2092
+ } finally { this.#active = false; }
2093
+ }
2094
+ #traverse(forward) {
2095
+ if (this.#active) throw new DOMException("The iterator filter is already active", "InvalidStateError");
2096
+ // A filter can remove its candidate or an ancestor synchronously. Track
2097
+ // the traversal cursor separately so pre-removal adjusts both positions.
2098
+ const cursor = this.#candidate = { node: this.#reference, before: this.#before };
2099
+ try {
2100
+ for (;;) {
2101
+ if (forward) {
2102
+ if (cursor.before) cursor.before = false;
2103
+ else cursor.node = this.#following(cursor.node);
2104
+ } else {
2105
+ if (!cursor.before) cursor.before = true;
2106
+ else cursor.node = this.#preceding(cursor.node);
2107
+ }
2108
+ const node = cursor.node;
2109
+ if (!node) return null;
2110
+ if (this.#accept(node) === FILTER_ACCEPT) {
2111
+ this.#reference = cursor.node; this.#before = cursor.before;
2112
+ // Return the accepted node even if the filter removed it; the next
2113
+ // traversal resumes at the repaired position in the remaining tree.
2114
+ return this.#realm.wrap(node);
2115
+ }
2116
+ }
2117
+ } finally { this.#candidate = null; }
2118
+ }
2119
+ #preRemove(node) {
2120
+ if (node === this.#root) return;
2121
+ const adjust = cursor => {
2122
+ if (!cursor?.node || !node.contains(cursor.node)) return;
2123
+ if (cursor.before) {
2124
+ const next = this.#following(node, false);
2125
+ if (next) { cursor.node = next; return; }
2126
+ cursor.before = false;
2127
+ }
2128
+ cursor.node = this.#preceding(node);
2129
+ };
2130
+ const reference = { node: this.#reference, before: this.#before };
2131
+ adjust(reference);
2132
+ this.#reference = reference.node; this.#before = reference.before;
2133
+ adjust(this.#candidate);
2134
+ }
2135
+ }
2136
+
2036
2137
  /**
2037
2138
  * DOM §6.2 TreeWalker over the real nodes. The page's filter (a function or
2038
2139
  * an object with acceptNode) sees node wrappers, and its exceptions reach the
@@ -2833,7 +2934,7 @@ function cssEscape(value) {
2833
2934
  * Must run after SES lockdown() (which provides `harden`).
2834
2935
  */
2835
2936
  const BINDINGS = [NodeBinding, CharacterDataBinding, TextBinding, ParentBinding, FragmentBinding, ShadowRootBinding, ElementBinding,
2836
- DocumentBinding, EventBinding, ClassListBinding, TreeWalkerBinding, RangeBinding];
2937
+ DocumentBinding, EventBinding, ClassListBinding, TreeWalkerBinding, NodeIteratorBinding, RangeBinding];
2837
2938
 
2838
2939
  // Members defined with Object.defineProperty({ get() {...} }) are named
2839
2940
  // "get"; Web IDL names an attribute's accessors "get x" / "set x" and an
@@ -2869,5 +2970,6 @@ export const INTERFACES = Object.freeze({
2869
2970
  DocumentFragment: FragmentBinding,
2870
2971
  ShadowRoot: ShadowRootBinding,
2871
2972
  TreeWalker: TreeWalkerBinding,
2973
+ NodeIterator: NodeIteratorBinding,
2872
2974
  DOMTokenList: ClassListBinding,
2873
2975
  });
@@ -438,6 +438,8 @@ function buildPageRenderer({
438
438
  [...page.renderTree.nodesById.values()].filter(node => node.type === "text"));
439
439
  }
440
440
 
441
+ // Keep fractional CSS sizes through measurement and painting. Rounding each
442
+ // scale independently can widen painted glyphs beyond the measured line.
441
443
  function makeParagraph(text, computedStyle = null, fontSize = computedStyle?.fontSize ?? 16, scale = 1, foregroundPaint = null) {
442
444
  const color = canvasColor(CanvasKit, computedStyle?.color ?? "rgba(0, 0, 0, 1)");
443
445
  const weight = computedStyle?.fontWeight ?? 400;
@@ -460,7 +462,7 @@ function buildPageRenderer({
460
462
  textStyle: {
461
463
  color,
462
464
  fontFamilies: families,
463
- fontSize: Math.max(1, Math.round(fontSize * scale)),
465
+ fontSize: Math.max(1, fontSize) * scale,
464
466
  fontStyle: { weight, slant, width },
465
467
  // Roboto is a variable font; static fonts ignore the axis and match by weight.
466
468
  fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }],
@@ -475,7 +477,7 @@ function buildPageRenderer({
475
477
  const pushTextStyle = foregroundPaint
476
478
  ? value => builder.pushPaintStyle(value, foregroundPaint, backgroundPaint) : value => builder.pushStyle(value);
477
479
  const textStyle = (fontFamilies) => CanvasKit.TextStyle({ color, ...decoration, fontFamilies,
478
- fontSize: Math.max(1, Math.round(fontSize * scale)), fontStyle: { weight, slant, width },
480
+ fontSize: Math.max(1, fontSize) * scale, fontStyle: { weight, slant, width },
479
481
  fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }] });
480
482
  function append(text, fonts) {
481
483
  let emojiStyle;
@@ -518,7 +520,7 @@ function buildPageRenderer({
518
520
  return paragraph;
519
521
  }
520
522
 
521
- // Text widths depend only on the font (families, rounded size, weight),
523
+ // Text widths depend only on the font (families, fractional size, weight),
522
524
  // not on the element, so they are cached per font across elements,
523
525
  // relayouts and documents. The cache is dropped when it grows too large.
524
526
  const MAX_MEASURED_TEXTS = 200_000;
@@ -557,7 +559,7 @@ function buildPageRenderer({
557
559
  const weight = computedStyle?.fontWeight ?? 400;
558
560
  const fontStyle = computedStyle?.fontStyle ?? "normal";
559
561
  const stretch = computedStyle?.fontStretch ?? 100;
560
- const key = `${Math.max(1, Math.round(computedStyle?.fontSize ?? 16))}/${weight}/${fontStyle}/${stretch}/${
562
+ const key = `${Math.max(1, computedStyle?.fontSize ?? 16)}/${weight}/${fontStyle}/${stretch}/${
561
563
  resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, null, stretch).join(",")}`;
562
564
  if (computedStyle && typeof computedStyle === "object") fontKeys.set(computedStyle, key);
563
565
  return key;
@@ -2786,6 +2788,10 @@ function buildPageRenderer({
2786
2788
  ? deviceScaleFactor
2787
2789
  : 1;
2788
2790
  viewport.resize(width, height);
2791
+ // Viewport dimensions are layout input even when DOM/stylesheets did
2792
+ // not mutate (including CSS resizes that keep the same pixel size by
2793
+ // changing DPR). Bypass retained-layout shortcuts in that case.
2794
+ if (resized) page.resourceLayoutDirty = true;
2789
2795
  rebuildRenderTree();
2790
2796
  // Listeners must observe the new viewport and layout. A repeated metrics
2791
2797
  // request (or DPR-only change) does not resize the CSS viewport.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu-browser",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Buninu Browser is a browser engine written in JavaScript and WebAssembly for Bun.",
5
5
  "keywords": [
6
6
  "buninu",
@@ -0,0 +1,42 @@
1
+ // Manual black-box reference check (no Chromium source code copied):
2
+ // bun test/compare-node-iterator.js /usr/local/bin/chromium-headless-shell
3
+ // Optional --snapshot writes Chromium's raw results for the regression test.
4
+ import { mkdtemp, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { RendererHost } from '../lib/renderer/host.js';
9
+ import { nodeIteratorCases } from './fixtures/node-iterator-cases.js';
10
+
11
+ const binary = process.argv[2] || Bun.which('chromium-headless-shell');
12
+ if (!binary) throw new Error('Pass the Chromium Headless Shell executable as the first argument');
13
+ const dir = await mkdtemp(join(tmpdir(), 'node-iterator-'));
14
+ const host = new RendererHost({timeout:20000,fetch:async()=>new Response('')});
15
+ let child;
16
+ try {
17
+ const expression = `JSON.stringify((${nodeIteratorCases})())`;
18
+ const path = join(dir, 'cases.html');
19
+ await Bun.write(path, `<body><script>document.body.textContent=${expression}</script>`);
20
+ child = Bun.spawn([binary,'--no-sandbox','--disable-gpu','--dump-dom',pathToFileURL(path).href],
21
+ {stdout:'pipe',stderr:'ignore'});
22
+ const html = await new Response(child.stdout).text();
23
+ if (await child.exited) throw new Error('Chromium failed');
24
+ const body = html.match(/<body>(.*?)<\/body>/s)?.[1];
25
+ const chromium = JSON.parse(body?.replaceAll('&lt;','<').replaceAll('&gt;','>').replaceAll('&amp;','&'));
26
+ await host.call('loadDocument',{url:'https://iterator.test/',source:'<body></body>',contentType:'text/html',status:200});
27
+ await host.call('whenLoaded');
28
+ const browser = JSON.parse(await host.call('evaluate',expression));
29
+ let differences = 0;
30
+ for (let i = 0; i < chromium.length; i++) {
31
+ if (!Bun.deepEquals(browser[i], chromium[i])) { differences++;console.log({browser:browser[i],chromium:chromium[i]}); }
32
+ }
33
+ console.log(`${chromium.length} cases; ${differences} differences (see node-iterator.test.js for documented DOM-spec exceptions)`);
34
+ if (process.argv.includes('--snapshot')) {
35
+ await Bun.write(new URL('./fixtures/node-iterator-chromium.json',import.meta.url),
36
+ '[\n'+chromium.map(row=>' '+JSON.stringify(row)).join(',\n')+'\n]\n');
37
+ }
38
+ } finally {
39
+ child?.kill();
40
+ host.close();
41
+ await rm(dir,{recursive:true,force:true});
42
+ }
@@ -0,0 +1,38 @@
1
+ import {strict as assert} from 'node:assert';
2
+ import {createPageRenderer,loadGraphics} from '../../lib/renderer/page-renderer.js';
3
+ const {CanvasKit}=await loadGraphics();
4
+ const original=CanvasKit.ParagraphBuilder.MakeFromFontCollection;
5
+ let checked=0;const missing=[];
6
+ CanvasKit.ParagraphBuilder.MakeFromFontCollection=function(...args){
7
+ const builder=original.apply(this,args),add=builder.addText.bind(builder),build=builder.build.bind(builder);
8
+ let text='';
9
+ builder.addText=value=>{text+=value;return add(value)};
10
+ builder.build=()=>{
11
+ const paragraph=build(),layout=paragraph.layout.bind(paragraph);
12
+ paragraph.layout=width=>{
13
+ layout(width);
14
+ if(width<10000 && text.trim()){
15
+ const last=text.trimEnd().length-1;
16
+ const rects=paragraph.getRectsForRange(last,last+1,CanvasKit.RectHeightStyle.Max,CanvasKit.RectWidthStyle.Tight);
17
+ if(!rects.length)missing.push({text,width});
18
+ checked++;
19
+ }
20
+ };
21
+ return paragraph;
22
+ };
23
+ return builder;
24
+ };
25
+ const r=await createPageRenderer({});
26
+ try{
27
+ for(const scale of [1,1.125,1.25,1.5,2]){
28
+ r.resize(900,300,scale);
29
+ await r.loadDocument({url:'https://text.test/',contentType:'text/html',source:`<body style="margin:0">
30
+ <div style="display:flex;gap:24px;font-size:15.2px"><span>Contact</span><span>Ecosystem</span><span>Contributors</span></div>
31
+ <div style="display:flex;gap:24px;font-size:14.4px"><span>Buninu Linux</span><span>Long navigation label</span></div>
32
+ <button style="font:14.4px monospace">Show email address</button>
33
+ </body>`});
34
+ await r.whenLoaded();await r.screenshot();
35
+ }
36
+ assert.deepEqual(missing,[],'All painted lines must retain their final character');
37
+ assert.ok(checked>=30,'All scales must exercise painted paragraphs');
38
+ }finally{r.dispose();CanvasKit.ParagraphBuilder.MakeFromFontCollection=original}
@@ -0,0 +1,59 @@
1
+ // Runs unchanged in the renderer and Chromium; keep this function standalone.
2
+ export function nodeIteratorCases() {
3
+ const results = [];
4
+ const label = n => n ? n.id || n.nodeName : null;
5
+ for (const tag of ['div', 'form', 'select'])
6
+ for (const operation of ['remove', 'replace', 'replaceWith', 'self', 'sibling', 'fragment', 'descendant', 'multiple'])
7
+ for (const position of [0, 1, 2, 3]) for (const before of [false, true]) {
8
+ const root = document.createElement(tag);root.id = 'root';
9
+ for (const id of ['a', 'b']) {
10
+ const node = document.createElement('option');node.id = id;root.append(node);
11
+ }
12
+ const nested = document.createElement('span');nested.id = 'nested';root.firstChild.append(nested);
13
+ const it = document.createNodeIterator(root, 1);
14
+ for (let i = 0; i <= position; i++) it.nextNode();
15
+ if (before) it.previousNode();
16
+ const old = root.firstChild, x = document.createElement('em');x.id = 'x';
17
+ if (operation === 'remove') old.remove();
18
+ if (operation === 'replace') root.replaceChild(x, old);
19
+ if (operation === 'replaceWith') old.replaceWith(x);
20
+ if (operation === 'self') root.replaceChild(old, old);
21
+ if (operation === 'sibling') root.replaceChild(root.lastChild, old);
22
+ if (operation === 'descendant') root.replaceChild(nested, old);
23
+ if (operation === 'fragment') {
24
+ const fragment = document.createDocumentFragment();fragment.append(x);
25
+ const y = document.createElement('em');y.id = 'y';fragment.append(y);
26
+ root.replaceChild(fragment, old);
27
+ }
28
+ if (operation === 'multiple') old.replaceWith(x, old, root.lastChild);
29
+ results.push([tag, operation, position, before, label(it.referenceNode),
30
+ it.pointerBeforeReferenceNode, label(it.nextNode()), label(it.previousNode()),
31
+ [...root.childNodes].map(label).join(',')]);
32
+ }
33
+ for (const whatToShow of [0, 1, -1, 4294967297, NaN, Infinity, '1', null, 1n, Object(1n), Symbol('x')]) {
34
+ try { results.push(['mask', String(whatToShow), document.createNodeIterator(document.body, whatToShow).whatToShow]); }
35
+ catch (e) { results.push(['mask', String(whatToShow), e.name]); }
36
+ }
37
+ for (const verdict of [1, 65537, -65535, NaN, Infinity, '1', null, 1n, Object(1n), Symbol('x')]) {
38
+ try { results.push(['filter', String(verdict), !!document.createNodeIterator(document.body, 1, () => verdict).nextNode()]); }
39
+ catch (e) { results.push(['filter', String(verdict), e.name]); }
40
+ }
41
+ // Filtering and root types: rejection must not prune descendants.
42
+ for (const rootKind of ['element', 'fragment', 'text', 'comment', 'doctype'])
43
+ for (const mask of [0, 1, 4, 128, 0xffffffff]) for (const mode of ['accept', 'reject', 'skip', 'alternating']) {
44
+ const root = rootKind === 'fragment' ? document.createDocumentFragment()
45
+ : rootKind === 'text' ? document.createTextNode('text')
46
+ : rootKind === 'comment' ? document.createComment('comment')
47
+ : rootKind === 'doctype' ? document.implementation.createDocumentType('html', '', '') : document.createElement('div');
48
+ if (rootKind === 'element' || rootKind === 'fragment') {
49
+ const child = document.createElement('b');child.append('text');root.append(child, document.createComment('comment'));
50
+ }
51
+ const it = document.createNodeIterator(root, mask, n => mode === 'accept' ? 1
52
+ : mode === 'reject' ? 2 : mode === 'skip' ? 3 : n.nodeType === 1 ? 2 : 1);
53
+ const forward = [], backward = [];
54
+ for (let n; (n = it.nextNode());) forward.push(n.nodeType);
55
+ for (let n; (n = it.previousNode());) backward.push(n.nodeType);
56
+ results.push(['walk', rootKind, mask, mode, forward.join(','), backward.join(',')]);
57
+ }
58
+ return results;
59
+ }
@@ -0,0 +1,315 @@
1
+ [
2
+ ["div","remove",0,false,"root",false,"b","b","b"],
3
+ ["div","remove",0,true,"root",true,"root","root","b"],
4
+ ["div","remove",1,false,"root",false,"b","b","b"],
5
+ ["div","remove",1,true,"b",true,"b","b","b"],
6
+ ["div","remove",2,false,"root",false,"b","b","b"],
7
+ ["div","remove",2,true,"b",true,"b","b","b"],
8
+ ["div","remove",3,false,"b",false,null,"b","b"],
9
+ ["div","remove",3,true,"b",true,"b","b","b"],
10
+ ["div","replace",0,false,"root",false,"x","x","x,b"],
11
+ ["div","replace",0,true,"root",true,"root","root","x,b"],
12
+ ["div","replace",1,false,"root",false,"x","x","x,b"],
13
+ ["div","replace",1,true,"b",true,"b","b","x,b"],
14
+ ["div","replace",2,false,"root",false,"x","x","x,b"],
15
+ ["div","replace",2,true,"b",true,"b","b","x,b"],
16
+ ["div","replace",3,false,"b",false,null,"b","x,b"],
17
+ ["div","replace",3,true,"b",true,"b","b","x,b"],
18
+ ["div","replaceWith",0,false,"root",false,"x","x","x,b"],
19
+ ["div","replaceWith",0,true,"root",true,"root","root","x,b"],
20
+ ["div","replaceWith",1,false,"root",false,"x","x","x,b"],
21
+ ["div","replaceWith",1,true,"b",true,"b","b","x,b"],
22
+ ["div","replaceWith",2,false,"root",false,"x","x","x,b"],
23
+ ["div","replaceWith",2,true,"b",true,"b","b","x,b"],
24
+ ["div","replaceWith",3,false,"b",false,null,"b","x,b"],
25
+ ["div","replaceWith",3,true,"b",true,"b","b","x,b"],
26
+ ["div","self",0,false,"root",false,"a","a","a,b"],
27
+ ["div","self",0,true,"root",true,"root","root","a,b"],
28
+ ["div","self",1,false,"root",false,"a","a","a,b"],
29
+ ["div","self",1,true,"b",true,"b","b","a,b"],
30
+ ["div","self",2,false,"root",false,"a","a","a,b"],
31
+ ["div","self",2,true,"b",true,"b","b","a,b"],
32
+ ["div","self",3,false,"b",false,null,"b","a,b"],
33
+ ["div","self",3,true,"b",true,"b","b","a,b"],
34
+ ["div","sibling",0,false,"root",false,"b","b","b"],
35
+ ["div","sibling",0,true,"root",true,"root","root","b"],
36
+ ["div","sibling",1,false,"root",false,"b","b","b"],
37
+ ["div","sibling",1,true,"a",true,"a","a","b"],
38
+ ["div","sibling",2,false,"root",false,"b","b","b"],
39
+ ["div","sibling",2,true,"nested",true,"nested","nested","b"],
40
+ ["div","sibling",3,false,"root",false,"b","b","b"],
41
+ ["div","sibling",3,true,"root",false,"b","b","b"],
42
+ ["div","fragment",0,false,"root",false,"x","x","x,y,b"],
43
+ ["div","fragment",0,true,"root",true,"root","root","x,y,b"],
44
+ ["div","fragment",1,false,"root",false,"x","x","x,y,b"],
45
+ ["div","fragment",1,true,"b",true,"b","b","x,y,b"],
46
+ ["div","fragment",2,false,"root",false,"x","x","x,y,b"],
47
+ ["div","fragment",2,true,"b",true,"b","b","x,y,b"],
48
+ ["div","fragment",3,false,"b",false,null,"b","x,y,b"],
49
+ ["div","fragment",3,true,"b",true,"b","b","x,y,b"],
50
+ ["div","descendant",0,false,"root",false,"nested","nested","nested,b"],
51
+ ["div","descendant",0,true,"root",true,"root","root","nested,b"],
52
+ ["div","descendant",1,false,"root",false,"nested","nested","nested,b"],
53
+ ["div","descendant",1,true,"b",true,"b","b","nested,b"],
54
+ ["div","descendant",2,false,"root",false,"nested","nested","nested,b"],
55
+ ["div","descendant",2,true,"b",true,"b","b","nested,b"],
56
+ ["div","descendant",3,false,"b",false,null,"b","nested,b"],
57
+ ["div","descendant",3,true,"b",true,"b","b","nested,b"],
58
+ ["div","multiple",0,false,"root",false,"x","x","x,a,b"],
59
+ ["div","multiple",0,true,"root",true,"root","root","x,a,b"],
60
+ ["div","multiple",1,false,"root",false,"x","x","x,a,b"],
61
+ ["div","multiple",1,true,"root",false,"x","x","x,a,b"],
62
+ ["div","multiple",2,false,"root",false,"x","x","x,a,b"],
63
+ ["div","multiple",2,true,"root",false,"x","x","x,a,b"],
64
+ ["div","multiple",3,false,"root",false,"x","x","x,a,b"],
65
+ ["div","multiple",3,true,"root",false,"x","x","x,a,b"],
66
+ ["form","remove",0,false,"root",false,"b","b","b"],
67
+ ["form","remove",0,true,"root",true,"root","root","b"],
68
+ ["form","remove",1,false,"root",false,"b","b","b"],
69
+ ["form","remove",1,true,"b",true,"b","b","b"],
70
+ ["form","remove",2,false,"root",false,"b","b","b"],
71
+ ["form","remove",2,true,"b",true,"b","b","b"],
72
+ ["form","remove",3,false,"b",false,null,"b","b"],
73
+ ["form","remove",3,true,"b",true,"b","b","b"],
74
+ ["form","replace",0,false,"root",false,"x","x","x,b"],
75
+ ["form","replace",0,true,"root",true,"root","root","x,b"],
76
+ ["form","replace",1,false,"root",false,"x","x","x,b"],
77
+ ["form","replace",1,true,"b",true,"b","b","x,b"],
78
+ ["form","replace",2,false,"root",false,"x","x","x,b"],
79
+ ["form","replace",2,true,"b",true,"b","b","x,b"],
80
+ ["form","replace",3,false,"b",false,null,"b","x,b"],
81
+ ["form","replace",3,true,"b",true,"b","b","x,b"],
82
+ ["form","replaceWith",0,false,"root",false,"x","x","x,b"],
83
+ ["form","replaceWith",0,true,"root",true,"root","root","x,b"],
84
+ ["form","replaceWith",1,false,"root",false,"x","x","x,b"],
85
+ ["form","replaceWith",1,true,"b",true,"b","b","x,b"],
86
+ ["form","replaceWith",2,false,"root",false,"x","x","x,b"],
87
+ ["form","replaceWith",2,true,"b",true,"b","b","x,b"],
88
+ ["form","replaceWith",3,false,"b",false,null,"b","x,b"],
89
+ ["form","replaceWith",3,true,"b",true,"b","b","x,b"],
90
+ ["form","self",0,false,"root",false,"a","a","a,b"],
91
+ ["form","self",0,true,"root",true,"root","root","a,b"],
92
+ ["form","self",1,false,"root",false,"a","a","a,b"],
93
+ ["form","self",1,true,"b",true,"b","b","a,b"],
94
+ ["form","self",2,false,"root",false,"a","a","a,b"],
95
+ ["form","self",2,true,"b",true,"b","b","a,b"],
96
+ ["form","self",3,false,"b",false,null,"b","a,b"],
97
+ ["form","self",3,true,"b",true,"b","b","a,b"],
98
+ ["form","sibling",0,false,"root",false,"b","b","b"],
99
+ ["form","sibling",0,true,"root",true,"root","root","b"],
100
+ ["form","sibling",1,false,"root",false,"b","b","b"],
101
+ ["form","sibling",1,true,"a",true,"a","a","b"],
102
+ ["form","sibling",2,false,"root",false,"b","b","b"],
103
+ ["form","sibling",2,true,"nested",true,"nested","nested","b"],
104
+ ["form","sibling",3,false,"root",false,"b","b","b"],
105
+ ["form","sibling",3,true,"root",false,"b","b","b"],
106
+ ["form","fragment",0,false,"root",false,"x","x","x,y,b"],
107
+ ["form","fragment",0,true,"root",true,"root","root","x,y,b"],
108
+ ["form","fragment",1,false,"root",false,"x","x","x,y,b"],
109
+ ["form","fragment",1,true,"b",true,"b","b","x,y,b"],
110
+ ["form","fragment",2,false,"root",false,"x","x","x,y,b"],
111
+ ["form","fragment",2,true,"b",true,"b","b","x,y,b"],
112
+ ["form","fragment",3,false,"b",false,null,"b","x,y,b"],
113
+ ["form","fragment",3,true,"b",true,"b","b","x,y,b"],
114
+ ["form","descendant",0,false,"root",false,"nested","nested","nested,b"],
115
+ ["form","descendant",0,true,"root",true,"root","root","nested,b"],
116
+ ["form","descendant",1,false,"root",false,"nested","nested","nested,b"],
117
+ ["form","descendant",1,true,"b",true,"b","b","nested,b"],
118
+ ["form","descendant",2,false,"root",false,"nested","nested","nested,b"],
119
+ ["form","descendant",2,true,"b",true,"b","b","nested,b"],
120
+ ["form","descendant",3,false,"b",false,null,"b","nested,b"],
121
+ ["form","descendant",3,true,"b",true,"b","b","nested,b"],
122
+ ["form","multiple",0,false,"root",false,"x","x","x,a,b"],
123
+ ["form","multiple",0,true,"root",true,"root","root","x,a,b"],
124
+ ["form","multiple",1,false,"root",false,"x","x","x,a,b"],
125
+ ["form","multiple",1,true,"root",false,"x","x","x,a,b"],
126
+ ["form","multiple",2,false,"root",false,"x","x","x,a,b"],
127
+ ["form","multiple",2,true,"root",false,"x","x","x,a,b"],
128
+ ["form","multiple",3,false,"root",false,"x","x","x,a,b"],
129
+ ["form","multiple",3,true,"root",false,"x","x","x,a,b"],
130
+ ["select","remove",0,false,"root",false,"b","b","b"],
131
+ ["select","remove",0,true,"root",true,"root","root","b"],
132
+ ["select","remove",1,false,"root",false,"b","b","b"],
133
+ ["select","remove",1,true,"b",true,"b","b","b"],
134
+ ["select","remove",2,false,"root",false,"b","b","b"],
135
+ ["select","remove",2,true,"b",true,"b","b","b"],
136
+ ["select","remove",3,false,"b",false,null,"b","b"],
137
+ ["select","remove",3,true,"b",true,"b","b","b"],
138
+ ["select","replace",0,false,"root",false,"x","x","x,b"],
139
+ ["select","replace",0,true,"root",true,"root","root","x,b"],
140
+ ["select","replace",1,false,"root",false,"x","x","x,b"],
141
+ ["select","replace",1,true,"b",true,"b","b","x,b"],
142
+ ["select","replace",2,false,"root",false,"x","x","x,b"],
143
+ ["select","replace",2,true,"b",true,"b","b","x,b"],
144
+ ["select","replace",3,false,"b",false,null,"b","x,b"],
145
+ ["select","replace",3,true,"b",true,"b","b","x,b"],
146
+ ["select","replaceWith",0,false,"root",false,"x","x","x,b"],
147
+ ["select","replaceWith",0,true,"root",true,"root","root","x,b"],
148
+ ["select","replaceWith",1,false,"root",false,"x","x","x,b"],
149
+ ["select","replaceWith",1,true,"b",true,"b","b","x,b"],
150
+ ["select","replaceWith",2,false,"root",false,"x","x","x,b"],
151
+ ["select","replaceWith",2,true,"b",true,"b","b","x,b"],
152
+ ["select","replaceWith",3,false,"b",false,null,"b","x,b"],
153
+ ["select","replaceWith",3,true,"b",true,"b","b","x,b"],
154
+ ["select","self",0,false,"root",false,"a","a","a,b"],
155
+ ["select","self",0,true,"root",true,"root","root","a,b"],
156
+ ["select","self",1,false,"root",false,"a","a","a,b"],
157
+ ["select","self",1,true,"b",true,"b","b","a,b"],
158
+ ["select","self",2,false,"root",false,"a","a","a,b"],
159
+ ["select","self",2,true,"b",true,"b","b","a,b"],
160
+ ["select","self",3,false,"b",false,null,"b","a,b"],
161
+ ["select","self",3,true,"b",true,"b","b","a,b"],
162
+ ["select","sibling",0,false,"root",false,"b","b","b"],
163
+ ["select","sibling",0,true,"root",true,"root","root","b"],
164
+ ["select","sibling",1,false,"root",false,"b","b","b"],
165
+ ["select","sibling",1,true,"a",true,"a","a","b"],
166
+ ["select","sibling",2,false,"root",false,"b","b","b"],
167
+ ["select","sibling",2,true,"nested",true,"nested","nested","b"],
168
+ ["select","sibling",3,false,"root",false,"b","b","b"],
169
+ ["select","sibling",3,true,"root",false,"b","b","b"],
170
+ ["select","fragment",0,false,"root",false,"x","x","x,y,b"],
171
+ ["select","fragment",0,true,"root",true,"root","root","x,y,b"],
172
+ ["select","fragment",1,false,"root",false,"x","x","x,y,b"],
173
+ ["select","fragment",1,true,"b",true,"b","b","x,y,b"],
174
+ ["select","fragment",2,false,"root",false,"x","x","x,y,b"],
175
+ ["select","fragment",2,true,"b",true,"b","b","x,y,b"],
176
+ ["select","fragment",3,false,"b",false,null,"b","x,y,b"],
177
+ ["select","fragment",3,true,"b",true,"b","b","x,y,b"],
178
+ ["select","descendant",0,false,"root",false,"nested","nested","nested,b"],
179
+ ["select","descendant",0,true,"root",true,"root","root","nested,b"],
180
+ ["select","descendant",1,false,"root",false,"nested","nested","nested,b"],
181
+ ["select","descendant",1,true,"b",true,"b","b","nested,b"],
182
+ ["select","descendant",2,false,"root",false,"nested","nested","nested,b"],
183
+ ["select","descendant",2,true,"b",true,"b","b","nested,b"],
184
+ ["select","descendant",3,false,"b",false,null,"b","nested,b"],
185
+ ["select","descendant",3,true,"b",true,"b","b","nested,b"],
186
+ ["select","multiple",0,false,"root",false,"x","x","x,a,b"],
187
+ ["select","multiple",0,true,"root",true,"root","root","x,a,b"],
188
+ ["select","multiple",1,false,"root",false,"x","x","x,a,b"],
189
+ ["select","multiple",1,true,"root",false,"x","x","x,a,b"],
190
+ ["select","multiple",2,false,"root",false,"x","x","x,a,b"],
191
+ ["select","multiple",2,true,"root",false,"x","x","x,a,b"],
192
+ ["select","multiple",3,false,"root",false,"x","x","x,a,b"],
193
+ ["select","multiple",3,true,"root",false,"x","x","x,a,b"],
194
+ ["mask","0",0],
195
+ ["mask","1",1],
196
+ ["mask","-1",4294967295],
197
+ ["mask","4294967297",1],
198
+ ["mask","NaN",0],
199
+ ["mask","Infinity",0],
200
+ ["mask","1",1],
201
+ ["mask","null",0],
202
+ ["mask","1","TypeError"],
203
+ ["mask","1","TypeError"],
204
+ ["mask","Symbol(x)","TypeError"],
205
+ ["filter","1",true],
206
+ ["filter","65537",true],
207
+ ["filter","-65535",true],
208
+ ["filter","NaN",false],
209
+ ["filter","Infinity",false],
210
+ ["filter","1",true],
211
+ ["filter","null",false],
212
+ ["filter","1","TypeError"],
213
+ ["filter","1","TypeError"],
214
+ ["filter","Symbol(x)","TypeError"],
215
+ ["walk","element",0,"accept","",""],
216
+ ["walk","element",0,"reject","",""],
217
+ ["walk","element",0,"skip","",""],
218
+ ["walk","element",0,"alternating","",""],
219
+ ["walk","element",1,"accept","1,1","1,1"],
220
+ ["walk","element",1,"reject","",""],
221
+ ["walk","element",1,"skip","",""],
222
+ ["walk","element",1,"alternating","",""],
223
+ ["walk","element",4,"accept","3","3"],
224
+ ["walk","element",4,"reject","",""],
225
+ ["walk","element",4,"skip","",""],
226
+ ["walk","element",4,"alternating","3","3"],
227
+ ["walk","element",128,"accept","8","8"],
228
+ ["walk","element",128,"reject","",""],
229
+ ["walk","element",128,"skip","",""],
230
+ ["walk","element",128,"alternating","8","8"],
231
+ ["walk","element",4294967295,"accept","1,1,3,8","8,3,1,1"],
232
+ ["walk","element",4294967295,"reject","",""],
233
+ ["walk","element",4294967295,"skip","",""],
234
+ ["walk","element",4294967295,"alternating","3,8","8,3"],
235
+ ["walk","fragment",0,"accept","",""],
236
+ ["walk","fragment",0,"reject","",""],
237
+ ["walk","fragment",0,"skip","",""],
238
+ ["walk","fragment",0,"alternating","",""],
239
+ ["walk","fragment",1,"accept","1","1"],
240
+ ["walk","fragment",1,"reject","",""],
241
+ ["walk","fragment",1,"skip","",""],
242
+ ["walk","fragment",1,"alternating","",""],
243
+ ["walk","fragment",4,"accept","3","3"],
244
+ ["walk","fragment",4,"reject","",""],
245
+ ["walk","fragment",4,"skip","",""],
246
+ ["walk","fragment",4,"alternating","3","3"],
247
+ ["walk","fragment",128,"accept","8","8"],
248
+ ["walk","fragment",128,"reject","",""],
249
+ ["walk","fragment",128,"skip","",""],
250
+ ["walk","fragment",128,"alternating","8","8"],
251
+ ["walk","fragment",4294967295,"accept","11,1,3,8","8,3,1,11"],
252
+ ["walk","fragment",4294967295,"reject","",""],
253
+ ["walk","fragment",4294967295,"skip","",""],
254
+ ["walk","fragment",4294967295,"alternating","11,3,8","8,3,11"],
255
+ ["walk","text",0,"accept","",""],
256
+ ["walk","text",0,"reject","",""],
257
+ ["walk","text",0,"skip","",""],
258
+ ["walk","text",0,"alternating","",""],
259
+ ["walk","text",1,"accept","",""],
260
+ ["walk","text",1,"reject","",""],
261
+ ["walk","text",1,"skip","",""],
262
+ ["walk","text",1,"alternating","",""],
263
+ ["walk","text",4,"accept","3","3"],
264
+ ["walk","text",4,"reject","",""],
265
+ ["walk","text",4,"skip","",""],
266
+ ["walk","text",4,"alternating","3","3"],
267
+ ["walk","text",128,"accept","",""],
268
+ ["walk","text",128,"reject","",""],
269
+ ["walk","text",128,"skip","",""],
270
+ ["walk","text",128,"alternating","",""],
271
+ ["walk","text",4294967295,"accept","3","3"],
272
+ ["walk","text",4294967295,"reject","",""],
273
+ ["walk","text",4294967295,"skip","",""],
274
+ ["walk","text",4294967295,"alternating","3","3"],
275
+ ["walk","comment",0,"accept","",""],
276
+ ["walk","comment",0,"reject","",""],
277
+ ["walk","comment",0,"skip","",""],
278
+ ["walk","comment",0,"alternating","",""],
279
+ ["walk","comment",1,"accept","",""],
280
+ ["walk","comment",1,"reject","",""],
281
+ ["walk","comment",1,"skip","",""],
282
+ ["walk","comment",1,"alternating","",""],
283
+ ["walk","comment",4,"accept","",""],
284
+ ["walk","comment",4,"reject","",""],
285
+ ["walk","comment",4,"skip","",""],
286
+ ["walk","comment",4,"alternating","",""],
287
+ ["walk","comment",128,"accept","8","8"],
288
+ ["walk","comment",128,"reject","",""],
289
+ ["walk","comment",128,"skip","",""],
290
+ ["walk","comment",128,"alternating","8","8"],
291
+ ["walk","comment",4294967295,"accept","8","8"],
292
+ ["walk","comment",4294967295,"reject","",""],
293
+ ["walk","comment",4294967295,"skip","",""],
294
+ ["walk","comment",4294967295,"alternating","8","8"],
295
+ ["walk","doctype",0,"accept","",""],
296
+ ["walk","doctype",0,"reject","",""],
297
+ ["walk","doctype",0,"skip","",""],
298
+ ["walk","doctype",0,"alternating","",""],
299
+ ["walk","doctype",1,"accept","",""],
300
+ ["walk","doctype",1,"reject","",""],
301
+ ["walk","doctype",1,"skip","",""],
302
+ ["walk","doctype",1,"alternating","",""],
303
+ ["walk","doctype",4,"accept","",""],
304
+ ["walk","doctype",4,"reject","",""],
305
+ ["walk","doctype",4,"skip","",""],
306
+ ["walk","doctype",4,"alternating","",""],
307
+ ["walk","doctype",128,"accept","",""],
308
+ ["walk","doctype",128,"reject","",""],
309
+ ["walk","doctype",128,"skip","",""],
310
+ ["walk","doctype",128,"alternating","",""],
311
+ ["walk","doctype",4294967295,"accept","10","10"],
312
+ ["walk","doctype",4294967295,"reject","",""],
313
+ ["walk","doctype",4294967295,"skip","",""],
314
+ ["walk","doctype",4294967295,"alternating","10","10"]
315
+ ]
@@ -0,0 +1,6 @@
1
+ import {test,expect} from 'bun:test';
2
+ test('fractional font sizes retain the final glyph at different device scales',async()=>{
3
+ const child=Bun.spawn([process.execPath,new URL('./fixtures/fractional-text-scale.js',import.meta.url).pathname],{stdout:'pipe',stderr:'pipe'});
4
+ const [exit,stderr]=await Promise.all([child.exited,new Response(child.stderr).text()]);
5
+ expect({exit,stderr}).toEqual({exit:0,stderr:''});
6
+ },30000);
@@ -0,0 +1,37 @@
1
+ import {test, expect} from 'bun:test';
2
+ import Window from '../vendor/happy-dom/packages/happy-dom/src/window/Window.ts';
3
+ import {createInertWindow} from '../lib/happy-dom/parser.js';
4
+
5
+ test('inert links leave existing and subsequent ordinary windows resource loading intact', async () => {
6
+ const requests=[];
7
+ const server=Bun.serve({port:0,fetch(req){requests.push(new URL(req.url).pathname);return new Response('body{color:red}',{headers:{'content-type':'text/css'}})}});
8
+ const windows=[];
9
+ const ordinary=()=>{const w=new Window({url:server.url.href});windows.push(w);return w};
10
+ const addLink=(w,path)=>{
11
+ const link=w.document.createElement('link'),events=[];
12
+ link.rel='stylesheet';link.href=path;
13
+ link.onload=()=>events.push('load');link.onerror=()=>events.push('error');
14
+ w.document.head.append(link);return {link,events};
15
+ };
16
+ try {
17
+ const before=ordinary();
18
+ const inert=createInertWindow(server.url.href);windows.push(inert);
19
+ const after=ordinary();
20
+ // Repeated setup must not replace the saved native hooks with wrappers.
21
+ windows.push(createInertWindow(server.url.href));
22
+ for(const [w,path] of [[before,'/before.css'],[after,'/after.css']]){
23
+ const {link,events}=addLink(w,path);
24
+ await w.happyDOM.waitUntilComplete();
25
+ expect(events).toEqual(['load']);expect(link.sheet).not.toBeNull();
26
+ link.href=path+'?changed';await w.happyDOM.waitUntilComplete();
27
+ expect(events).toEqual(['load','load']);
28
+ }
29
+ const {link,events}=addLink(inert,'/inert.css');
30
+ link.href='/inert-changed.css';
31
+ await inert.happyDOM.waitUntilComplete();
32
+ expect(events).toEqual([]);expect(link.sheet).toBeNull();
33
+ expect(requests).toEqual(['/before.css','/before.css','/after.css','/after.css']);
34
+ } finally {
35
+ await Promise.all(windows.map(w=>w.happyDOM.abort()));server.stop(true);
36
+ }
37
+ });
@@ -0,0 +1,81 @@
1
+ import { afterAll, beforeAll, expect, test } from 'bun:test';
2
+ import { RendererHost } from '../lib/renderer/host.js';
3
+ import { nodeIteratorCases } from './fixtures/node-iterator-cases.js';
4
+ import chromium from './fixtures/node-iterator-chromium.json';
5
+
6
+ const host = new RendererHost({timeout: 20000, fetch: async () => new Response('')});
7
+ beforeAll(async () => {
8
+ await host.call('loadDocument', {url:'https://iterator.test/',source:'<body></body>',contentType:'text/html',status:200});
9
+ await host.call('whenLoaded');
10
+ });
11
+ afterAll(() => host.close());
12
+ const evaluate = async expression => JSON.parse(await host.call('evaluate', `JSON.stringify(${expression})`));
13
+
14
+ test('NodeIterator traversal, replacement and WebIDL conversion match Chromium and DOM', async () => {
15
+ const actual = await evaluate(`(${nodeIteratorCases})()`);
16
+ const expected = chromium.map(row => {
17
+ // Chromium retains a detached reference when replacing a node with its
18
+ // following sibling while the pointer is before it. DOM replace adopts
19
+ // (detaches) that sibling first, then removes the old node: the repaired
20
+ // reference must be root, not the now-detached old node/descendant.
21
+ // https://dom.spec.whatwg.org/#concept-node-replace steps 6–7
22
+ if (row[1] === 'sibling' && [1,2].includes(row[2]) && row[3]) {
23
+ return [...row.slice(0,4), 'root', false, 'b', 'b', 'b'];
24
+ }
25
+ return row;
26
+ });
27
+ expect(actual).toEqual(expected);
28
+ });
29
+
30
+ test('failed replacements leave the tree and iterator position unchanged', async () => {
31
+ expect(await evaluate(`(() => {
32
+ const results=[];
33
+ for(const kind of ['ancestor','missing','document','textDocument','twoElements']) {
34
+ const root=document.createElement('div');root.id='root';root.innerHTML='<b id="a"></b><i id="b"></i>';
35
+ const doc=document.implementation.createHTMLDocument('');
36
+ const parent=kind==='textDocument'||kind==='twoElements'?doc:root;
37
+ const old=parent.firstChild;
38
+ const it=document.createNodeIterator(parent);it.nextNode();it.nextNode();
39
+ const reference=it.referenceNode,before=it.pointerBeforeReferenceNode;
40
+ const children=[...parent.childNodes];
41
+ let incoming=kind==='ancestor'?root:kind==='document'?document:document.createElement('em');
42
+ if(kind==='textDocument')incoming=document.createTextNode('invalid');
43
+ if(kind==='twoElements'){incoming=document.createDocumentFragment();incoming.append(document.createElement('i'),document.createElement('b'));}
44
+ let error;
45
+ try{parent.replaceChild(incoming,kind==='missing'?document.createElement('b'):old)}catch(e){error=e.name}
46
+ results.push([kind,error,it.referenceNode===reference,it.pointerBeforeReferenceNode===before,
47
+ children.length===parent.childNodes.length&&children.every((n,i)=>n===parent.childNodes[i])]);
48
+ }
49
+ return results;
50
+ })()`)).toEqual([
51
+ ['ancestor','HierarchyRequestError',true,true,true],['missing','NotFoundError',true,true,true],
52
+ ['document','HierarchyRequestError',true,true,true],['textDocument','HierarchyRequestError',true,true,true],
53
+ ['twoElements','HierarchyRequestError',true,true,true]
54
+ ]);
55
+ });
56
+
57
+ test('filter replacement repairs both active and stored iterator cursors', async () => {
58
+ expect(await evaluate(`(() => {
59
+ const root=document.createElement('div');root.id='root';root.innerHTML='<b id="a"></b><i id="b"></i>';
60
+ const it=document.createNodeIterator(root,1,n=>{
61
+ if(n.id==='a'){const replacement=document.createElement('em');replacement.id='x';root.replaceChild(replacement,n)}
62
+ return 1;
63
+ });
64
+ const got=[];for(let n,i=0;i<8&&(n=it.nextNode());i++)got.push(n.id);
65
+ return got;
66
+ })()`)).toEqual(['root','a','x','b']);
67
+ });
68
+
69
+ test('batch removal follows DOM tree order and keeps references attached', async () => {
70
+ expect(await evaluate(`(() => {
71
+ const results=[];
72
+ for(const operation of ['replaceChildren','innerHTML','textContent'])for(const before of [false,true]) {
73
+ const root=document.createElement('div');root.innerHTML='<i>a</i><i>b</i>';
74
+ const it=document.createNodeIterator(root);it.nextNode();it.nextNode();it.nextNode();
75
+ if(before)it.previousNode();
76
+ if(operation==='replaceChildren')root.replaceChildren();else root[operation]='';
77
+ results.push([it.referenceNode===root,it.pointerBeforeReferenceNode,it.nextNode()]);
78
+ }
79
+ return results;
80
+ })()`)).toEqual(Array.from({length:6},()=>[true,false,null]));
81
+ });
@@ -1997,14 +1997,24 @@ test("a click inside an iframe goes to the element under it in the frame's docum
1997
1997
  test("dynamic linked stylesheets fire load after their rules become available", async () => {
1998
1998
  await load(`<div id="target"></div><script>
1999
1999
  window.sheetLoaded = false;
2000
+ window.sheetEvents = [];
2000
2001
  const link = document.createElement('link');
2001
2002
  link.rel = 'stylesheet';
2002
2003
  link.href = '/slow.css';
2003
- link.onload = () => { window.sheetLoaded = getComputedStyle(document.body).color; };
2004
+ window.styleTestLink = link;
2005
+ link.onerror = () => sheetEvents.push('error');
2006
+ link.onload = () => { sheetEvents.push('load'); window.sheetLoaded = getComputedStyle(document.body).color; };
2004
2007
  document.head.appendChild(link);
2005
2008
  </script>`);
2006
2009
  for (let i = 0; i < 60 && await evaluate('sheetLoaded') === false; i++) await Bun.sleep(25);
2007
2010
  expect(await evaluate('sheetLoaded')).toBe('rgba(255, 0, 0, 1)');
2011
+ expect(await evaluate('sheetEvents.join()')).toBe('load');
2012
+ await evaluate("styleTestLink.href='/missing.css'; window.eventsAfterHref=sheetEvents.join()");
2013
+ expect(await evaluate('eventsAfterHref')).toBe('load');
2014
+ expect(await waitFor("sheetEvents.join() === 'load,error'")).toBe(true);
2015
+ await evaluate("styleTestLink.href='/slow.css'; window.eventsAfterHref=sheetEvents.join()");
2016
+ expect(await evaluate('eventsAfterHref')).toBe('load,error');
2017
+ expect(await waitFor("sheetEvents.join() === 'load,error,load'")).toBe(true);
2008
2018
  });
2009
2019
 
2010
2020
  test("decoded dynamic images report natural dimensions and fire load", async () => {
@@ -2154,3 +2164,108 @@ test("the page scrolls over content that overflows a 100vh layout, not over clip
2154
2164
  expect(await evaluate(`(scrollTo(0, 99999), [scrollY, Math.round(document.getElementById("end").getBoundingClientRect().bottom)])`))
2155
2165
  .toEqual([2090 - 300, 300]);
2156
2166
  });
2167
+
2168
+ test("NodeIterator includes its root, preserves direction, and skips rejected nodes without pruning", async () => {
2169
+ await load('<div id="root"><p id="a"><b id="child"></b></p><!--note--><p id="b"></p></div>');
2170
+ expect(await evaluate(`(() => {
2171
+ const root = document.getElementById('root');
2172
+ const filter = {acceptNode(n) {return n.id === 'a' ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT}};
2173
+ const it = document.createNodeIterator(root, NodeFilter.SHOW_ELEMENT, filter);
2174
+ const initial = [it instanceof NodeIterator,it.root === root,it.referenceNode === root,it.pointerBeforeReferenceNode,it.filter === filter];
2175
+ const forward=[];let n;while(n=it.nextNode())forward.push(n.id);
2176
+ const reverse=[];while(n=it.previousNode())reverse.push(n.id);
2177
+ it.detach();
2178
+ return JSON.stringify([initial,forward,reverse,it.nextNode().id]);
2179
+ })()`)).toBe(JSON.stringify([[true,true,true,true,true],['root','child','b'],['b','child','root'],'root']));
2180
+ });
2181
+
2182
+ test("NodeIterator survives sanitizer removals, reparenting, and rejected reentrant filters", async () => {
2183
+ await load('<div id="root"><i id="a"><b id="child"></b></i><i id="b"></i><i id="c"></i></div>');
2184
+ expect(await evaluate(`(() => {
2185
+ const root=document.getElementById('root'),it=document.createNodeIterator(root,1);
2186
+ it.nextNode();it.nextNode();it.nextNode();
2187
+ root.firstChild.remove();
2188
+ const removed=[it.referenceNode.id,it.nextNode().id];
2189
+ it.previousNode();root.firstChild.remove();
2190
+ const before=[it.referenceNode.id,it.pointerBeforeReferenceNode,it.nextNode().id];
2191
+ document.body.appendChild(root.firstChild);
2192
+ const moved=[it.referenceNode.id,it.nextNode()];
2193
+ let error;const recur=document.createNodeIterator(root,1,()=>{try{recur.nextNode()}catch(e){error=e.name}return 1});
2194
+ recur.nextNode();
2195
+ const fresh=document.createNodeIterator(root,NodeFilter.SHOW_TEXT);
2196
+ root.innerHTML='hello<b>world</b>';
2197
+ return JSON.stringify([removed,before,moved,error,fresh.nextNode().data,fresh.nextNode().data]);
2198
+ })()`)).toBe(JSON.stringify([['root','b'],['c',true,'c'],['root',null],'InvalidStateError','hello','world']));
2199
+ });
2200
+
2201
+ // Expectations independently checked against Chromium Headless Shell.
2202
+ test("NodeIterator repairs an active filter cursor when the candidate or its ancestor is removed", async () => {
2203
+ await load("<body></body>");
2204
+ expect(await evaluate(`JSON.stringify((()=>{const results=[];for(const direction of ['nextNode','previousNode'])for(const remove of ['self','ancestor'])for(const verdict of [1,3]){
2205
+ const root=document.createElement('div');root.id='root';root.innerHTML='<i id="a"></i><section id="parent"><b id="b"></b><b id="c"></b></section><i id="d"></i>';document.body.append(root);
2206
+ let armed=false,once=false;const seen=[];const it=document.createNodeIterator(root,1,n=>{if(armed)seen.push(n.id);if(armed&&!once&&n.id==='b'){once=true;(remove==='self'?n:n.parentNode).remove();return verdict;}return 1});
2207
+ if(direction==='previousNode')while(it.nextNode());armed=true;
2208
+ const got=[];for(let n,k=0;k<15&&(n=it[direction]());k++)got.push(n.id);
2209
+ results.push({direction,remove,verdict,seen,got,reference:it.referenceNode.id,before:it.pointerBeforeReferenceNode});root.remove();
2210
+ }return results.map(r=>[r.direction,r.remove,r.verdict,r.seen.join(','),r.got.join(','),r.reference,r.before])})())`)).toBe(JSON.stringify([
2211
+ ["nextNode", "self", 1, "root,a,parent,b,c,d", "root,a,parent,b,c,d", "d", false],
2212
+ ["nextNode", "self", 3, "root,a,parent,b,c,d", "root,a,parent,c,d", "d", false],
2213
+ ["nextNode", "ancestor", 1, "root,a,parent,b,d", "root,a,parent,b,d", "d", false],
2214
+ ["nextNode", "ancestor", 3, "root,a,parent,b,d", "root,a,parent,d", "d", false],
2215
+ ["previousNode", "self", 1, "d,c,b,parent,a,root", "d,c,b,parent,a,root", "root", true],
2216
+ ["previousNode", "self", 3, "d,c,b,parent,a,root", "d,c,parent,a,root", "root", true],
2217
+ ["previousNode", "ancestor", 1, "d,c,b,a,root", "d,c,b,a,root", "root", true],
2218
+ ["previousNode", "ancestor", 3, "d,c,b,a,root", "d,c,a,root", "root", true]
2219
+ ]));
2220
+ });
2221
+
2222
+ test("NodeIterator releases its active cursor after a filter throws", async () => {
2223
+ await load('<div id="root"><b id="child"></b></div>');
2224
+ expect(await evaluate(`(() => {
2225
+ const root=document.getElementById('root');let first=true;
2226
+ const it=document.createNodeIterator(root,1,()=>{if(first){first=false;throw Error('filter failed')}return 1});
2227
+ let message;try{it.nextNode()}catch(e){message=e.message}
2228
+ return [message,it.nextNode().id,it.nextNode().id].join(',');
2229
+ })()`)).toBe('filter failed,root,child');
2230
+ });
2231
+
2232
+ test("NodeIterator repairs removals through proxy-backed select and form parents", async () => {
2233
+ await load('<body></body>');
2234
+ expect(await evaluate(`(() => {
2235
+ const results=[];
2236
+ for(const tag of ['div','select','form']) for(const outerRoot of [false,true])
2237
+ for(const operation of ['remove','removeChild','innerHTML','reparent']) {
2238
+ const outer=document.createElement('div'),parent=document.createElement(tag);
2239
+ outer.append(parent);document.body.append(outer);
2240
+ parent.innerHTML='<option id="a"></option><option id="b"></option>';
2241
+ const it=document.createNodeIterator(outerRoot?outer:parent,1);
2242
+ while(it.nextNode()?.id!=='a') {}
2243
+ const child=parent.firstChild;
2244
+ if(operation==='remove')child.remove();
2245
+ else if(operation==='removeChild')parent.removeChild(child);
2246
+ else if(operation==='innerHTML')parent.innerHTML='';
2247
+ else outer.append(child);
2248
+ results.push([it.referenceNode===parent,it.nextNode()?.id??null]);
2249
+ outer.remove();
2250
+ }
2251
+ return JSON.stringify(results);
2252
+ })()`)).toBe(JSON.stringify(Array.from({length:6},()=>[
2253
+ [true,'b'],[true,'b'],[true,null],[true,'b']
2254
+ ]).flat()));
2255
+ });
2256
+
2257
+ test("NodeIterator validates nullable callback-interface filters at creation", async () => {
2258
+ await load('<body></body>');
2259
+ expect(await evaluate(`(() => {
2260
+ const results=[];
2261
+ for(const filter of [0,1,false,true,'','x',Symbol('filter'),1n]) {
2262
+ try { document.createNodeIterator(document.body,0,filter);results.push('accepted'); }
2263
+ catch(error) { results.push(error.name); }
2264
+ }
2265
+ for(const filter of [null,undefined,()=>1,{acceptNode(){return 1}},{}]) {
2266
+ const it=document.createNodeIterator(document.body,0,filter);
2267
+ results.push(it.filter===(filter??null) && it.nextNode()===null);
2268
+ }
2269
+ return JSON.stringify(results);
2270
+ })()`)).toBe(JSON.stringify([...Array(8).fill('TypeError'),...Array(5).fill(true)]));
2271
+ });
@@ -675,3 +675,18 @@ test("an out-of-flow box keeps its static position when the block around its pla
675
675
  (document) => { document.getElementById("edit").textContent = "a longer paragraph that wraps onto two lines"; });
676
676
  expect(rankedLayout(retained)).toEqual(rankedLayout(clean));
677
677
  });
678
+
679
+ test("anonymous flex text items do not reuse their parent's intrinsic width", () => {
680
+ const { document, window } = parseHTMLDocument(`<body style="margin:0"><div style="display:flex">
681
+ <span style="display:flex"><a>hash</a>·<time>Today</time>!</span><b>History</b>
682
+ </div></body>`);
683
+ const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
684
+ const engine = new LayoutEngine({ measureText: text => text.length * 10 });
685
+ for (const width of [500, 180, 500]) {
686
+ const layout = engine.layout(tree, { x:0, y:0, width });
687
+ expect(layout.fragments.map(f => [f.text, f.x, f.width])).toEqual([
688
+ ["hash", 0, 40], ["·", 40, 10], ["Today", 50, 50], ["!", 100, 10], ["History", 110, 70],
689
+ ]);
690
+ }
691
+ window.happyDOM.abort();
692
+ });
@@ -34,3 +34,32 @@ test('viewport resize notifies window listeners with fresh geometry and repaints
34
34
  expect(await host.call('evaluate','propertyEvents')).toBe(2);
35
35
  }finally{host.close();}
36
36
  },30000);
37
+
38
+ test('same-pixel-size DPR changes relayout a quiet page and update viewport CSS',async()=>{
39
+ const host=new RendererHost({timeout:20000});
40
+ try {
41
+ await host.call('resize',1920,1035,1);
42
+ await host.call('loadDocument',{url:'https://resize.test/',contentType:'text/html',source:`
43
+ <style>body{margin:0}#center{width:1000px;margin:0 auto;height:20px}
44
+ #units{width:50vw;height:10vh}#media{width:10px}
45
+ @media(max-width:1400px){#media{width:30px}}</style>
46
+ <div id="center">Contact — Buninu Linux</div><div id="units"></div><div id="media"></div>`});
47
+ await host.call('whenLoaded');
48
+ const geometry=()=>host.call('evaluate',`JSON.stringify(['center','units','media'].map(id=>{
49
+ const r=document.getElementById(id).getBoundingClientRect();return [r.x,r.width,r.height];
50
+ }))`);
51
+ expect(await geometry()).toBe('[[460,1000,20],[0,960,103.5],[0,10,0]]');
52
+ for(const [width,height,dpr,expected] of [
53
+ [1280,690,1.5,'[[140,1000,20],[0,640,69],[0,30,0]]'],
54
+ [1920,1035,1,'[[460,1000,20],[0,960,103.5],[0,10,0]]'],
55
+ [1280,690,1.5,'[[140,1000,20],[0,640,69],[0,30,0]]'],
56
+ // DPR-only changes repaint without changing CSS geometry.
57
+ [1280,690,2,'[[140,1000,20],[0,640,69],[0,30,0]]'],
58
+ ]) {
59
+ await host.call('resize',width,height,dpr);
60
+ expect(await geometry()).toBe(expected);
61
+ const png=Buffer.from(await host.composite('screenshot',{format:'png'}),'base64');
62
+ expect([png.readUInt32BE(16),png.readUInt32BE(20)]).toEqual([width*dpr,height*dpr]);
63
+ }
64
+ }finally{host.close()}
65
+ },30000);