buninu-browser 0.5.1 → 0.7.0

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.
Files changed (136) hide show
  1. package/README.md +13 -6
  2. package/index.js +10 -6
  3. package/lib/audio/pcm.js +11 -0
  4. package/lib/audio/pulse-client.js +7 -2
  5. package/lib/cdp-server.js +94 -46
  6. package/lib/happy-dom/document-base.js +29 -1
  7. package/lib/happy-dom/mutation-observer.js +157 -0
  8. package/lib/happy-dom/parser.js +2 -0
  9. package/lib/headless-shell.js +49 -9
  10. package/lib/layout/repaint.js +89 -0
  11. package/lib/media/mp4-keyframes.js +144 -0
  12. package/lib/media/video-keyframe.js +29 -1
  13. package/lib/media/webm-snapshots.js +282 -0
  14. package/lib/network/cookie-jar.js +18 -2
  15. package/lib/paint/display-list.js +4 -3
  16. package/lib/paint/frame.js +12 -5
  17. package/lib/render-tree/index.js +3 -0
  18. package/lib/renderer/animation-frames.js +76 -0
  19. package/lib/renderer/bindings.js +2 -0
  20. package/lib/renderer/compositor-channel.js +95 -10
  21. package/lib/renderer/compositor.js +54 -18
  22. package/lib/renderer/document-cookies.js +87 -0
  23. package/lib/renderer/font-loading.js +60 -16
  24. package/lib/renderer/host.js +48 -19
  25. package/lib/renderer/idle-callbacks.js +109 -0
  26. package/lib/renderer/main.js +5 -1
  27. package/lib/renderer/media-source.js +4 -0
  28. package/lib/renderer/page-realm.js +159 -49
  29. package/lib/renderer/page-renderer.js +567 -197
  30. package/lib/renderer/resource-elements.js +88 -0
  31. package/lib/renderer/script-rewrite.js +18 -1
  32. package/lib/renderer/seccomp.js +6 -4
  33. package/lib/renderer/timers.js +71 -0
  34. package/lib/renderer/video-channel.js +94 -0
  35. package/lib/renderer/video-mask.js +115 -0
  36. package/lib/renderer/video-presentation.js +304 -39
  37. package/lib/renderer/video-worker.js +46 -0
  38. package/lib/renderer/workers.js +7 -17
  39. package/lib/style/computed-style.js +257 -61
  40. package/package.json +1 -1
  41. package/test/animation-frames.test.js +67 -0
  42. package/test/canvas-style-snapshot.test.js +18 -0
  43. package/test/cdp-stream.test.js +96 -3
  44. package/test/cdp-viewers.test.js +43 -0
  45. package/test/compositor-channel-ownership.test.js +194 -0
  46. package/test/compositor.test.js +27 -1
  47. package/test/computed-style-binding.test.js +49 -0
  48. package/test/computed-style-connectivity.test.js +13 -0
  49. package/test/computed-style.test.js +86 -0
  50. package/test/cookie-sync.test.js +82 -0
  51. package/test/document-base-cache.test.js +103 -0
  52. package/test/document-cookies.test.js +117 -0
  53. package/test/duplicate-style-keys.test.js +11 -0
  54. package/test/fixtures/animation-frames.js +56 -0
  55. package/test/fixtures/canvas-style-snapshot.js +81 -0
  56. package/test/fixtures/computed-style-connectivity.js +101 -0
  57. package/test/fixtures/computed-style-lazy.js +135 -0
  58. package/test/fixtures/duplicate-style-keys.js +97 -0
  59. package/test/fixtures/fake-renderer.js +12 -0
  60. package/test/fixtures/font-definition-invalidation.js +97 -0
  61. package/test/fixtures/font-metrics-invalidation.js +87 -0
  62. package/test/fixtures/full-style-identity.js +36 -0
  63. package/test/fixtures/hidden-style-engine.js +260 -0
  64. package/test/fixtures/hidden-style-reentry.js +232 -0
  65. package/test/fixtures/hidden-style-renderer.js +203 -0
  66. package/test/fixtures/layout-repaint.js +69 -0
  67. package/test/fixtures/media-presentation-continuation.js +141 -0
  68. package/test/fixtures/media-presentation-refill.js +113 -0
  69. package/test/fixtures/media-probe.js +148 -0
  70. package/test/fixtures/media-refill-cancel.js +79 -0
  71. package/test/fixtures/media-repaint.js +48 -0
  72. package/test/fixtures/media-resource-reselection.js +94 -0
  73. package/test/fixtures/media-source-attachment.js +136 -0
  74. package/test/fixtures/media-source-no-decoder.js +17 -0
  75. package/test/fixtures/media-track-refill.js +132 -0
  76. package/test/fixtures/media-webm-seek.js +51 -0
  77. package/test/fixtures/mutation-observer.js +201 -0
  78. package/test/fixtures/observer-invalidation.js +105 -0
  79. package/test/fixtures/prepaint-scheduling.js +73 -0
  80. package/test/fixtures/resource-discovery.js +32 -0
  81. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  82. package/test/fixtures/style-propagation.js +26 -0
  83. package/test/fixtures/svg-node-reuse.js +83 -0
  84. package/test/fixtures/video-black-replay.js +140 -0
  85. package/test/fixtures/video-controls-invalidation.js +71 -0
  86. package/test/fixtures/viewport-bounds.js +148 -0
  87. package/test/font-loading.test.js +77 -0
  88. package/test/font-metrics-invalidation.test.js +60 -0
  89. package/test/full-style-identity.test.js +191 -0
  90. package/test/hidden-style-lazy.test.js +31 -0
  91. package/test/hidden-style-reentry.test.js +20 -0
  92. package/test/idle-callback.test.js +89 -0
  93. package/test/idle-scheduler.test.js +148 -0
  94. package/test/index.test.js +1 -0
  95. package/test/inline-style-cascade.test.js +63 -0
  96. package/test/layout-repaint-renderer.test.js +9 -0
  97. package/test/layout-repaint.test.js +222 -0
  98. package/test/media-presentation-continuation.test.js +14 -0
  99. package/test/media-presentation-refill.test.js +8 -0
  100. package/test/media-probe.test.js +16 -0
  101. package/test/media-repaint.test.js +8 -0
  102. package/test/media-source-attachment.test.js +34 -0
  103. package/test/media-track-refill.test.js +26 -0
  104. package/test/media-webm-seek.test.js +11 -0
  105. package/test/mp4-keyframes.test.js +36 -0
  106. package/test/mutation-observer.test.js +91 -0
  107. package/test/observer-invalidation.test.js +12 -0
  108. package/test/page-scripts.test.js +35 -0
  109. package/test/page-timers.test.js +67 -0
  110. package/test/paint-frame.test.js +18 -1
  111. package/test/pcm.test.js +63 -0
  112. package/test/prepaint-scheduling.test.js +13 -0
  113. package/test/pulse-client.test.js +165 -0
  114. package/test/raw-capture.test.js +34 -5
  115. package/test/renderer-host.test.js +51 -0
  116. package/test/resource-discovery.test.js +12 -0
  117. package/test/resource-elements.test.js +234 -0
  118. package/test/root-self-style.test.js +104 -0
  119. package/test/script-rewrite.test.js +35 -0
  120. package/test/seccomp.test.js +45 -0
  121. package/test/spare-renderer-scheduling.test.js +8 -0
  122. package/test/style-propagation.test.js +225 -0
  123. package/test/style-sheets.test.js +19 -0
  124. package/test/svg.test.js +45 -0
  125. package/test/timers.test.js +58 -0
  126. package/test/video-black-replay.test.js +34 -0
  127. package/test/video-capture.test.js +170 -0
  128. package/test/video-channel.test.js +253 -0
  129. package/test/video-continuation.test.js +57 -0
  130. package/test/video-controls-invalidation.test.js +12 -0
  131. package/test/video-keyframe.test.js +104 -4
  132. package/test/video-mask.test.js +94 -0
  133. package/test/video-presentation.test.js +239 -1
  134. package/test/video-timing-presentation.test.js +115 -0
  135. package/test/viewport-bounds.test.js +13 -0
  136. package/test/webm-snapshots.test.js +372 -0
@@ -0,0 +1,135 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { LayoutEngine } from "../../lib/layout/text-layout.js";
3
+ import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
4
+ import { StyleEngine } from "../../lib/style/computed-style.js";
5
+
6
+ // Observe expensive field serialization through the actual CSSOM path. The
7
+ // immutable style and all returned values remain unchanged by this proxy.
8
+ let observeSerialization = false, serializations = 0;
9
+ const computed = StyleEngine.prototype.get, observedStyles = new WeakMap();
10
+ StyleEngine.prototype.get = function (element) {
11
+ const style = computed.call(this, element);
12
+ if (!observeSerialization || element?.id !== "box") return style;
13
+ let observed = observedStyles.get(style);
14
+ if (!observed) observedStyles.set(style, observed = new Proxy(style, {
15
+ get(target, property, receiver) {
16
+ if (property === "fontFamily") serializations++;
17
+ return Reflect.get(target, property, receiver);
18
+ },
19
+ }));
20
+ return observed;
21
+ };
22
+
23
+ let layouts = 0;
24
+ const layout = LayoutEngine.prototype.layout;
25
+ LayoutEngine.prototype.layout = function (...args) { layouts++; return layout.apply(this, args); };
26
+ let releaseImage;
27
+ const imageReady = new Promise(resolve => { releaseImage = resolve; });
28
+ const redImage = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAAA2iEnWAAAAEElEQVR4nGP4z8AARAwoFABE0AX7pM/egAAAAABJRU5ErkJggg==", "base64");
29
+ const renderer = await createPageRenderer({ fetchResource: async url => {
30
+ await imageReady;
31
+ return { ok:true,status:200,url,contentType:"image/png",body:redImage };
32
+ } });
33
+ try {
34
+ renderer.setScripting("host");
35
+ await renderer.resize(400, 300, 1);
36
+ await renderer.loadDocument({ url: "https://page.test/", contentType: "text/html", status: 200,
37
+ source: `<!doctype html><style>body{margin:0}#box{width:50%;height:40px;color:red;--tone:red}
38
+ #child{width:50%;height:20px;padding:5%}</style><div id="box"><div id="child">Text</div></div>` });
39
+ await renderer.whenLoaded();
40
+ globalThis.__layoutCalls = () => layouts;
41
+ globalThis.__serializationCalls = () => serializations;
42
+ observeSerialization = true;
43
+ const repeated = await renderer.evaluate(`(() => {
44
+ const element=document.getElementById('box'),styles=[];
45
+ for(let i=0;i<40;i++) styles.push(getComputedStyle(element));
46
+ const values=styles.map(style=>[style.color,style.getPropertyValue('--tone')]);
47
+ const first=__serializationCalls();
48
+ element.style.setProperty('--tone','changed');
49
+ const custom=styles[0].getPropertyValue('--tone'),second=__serializationCalls();
50
+ const keys=Object.keys(styles[0]);
51
+ styles[0].getPropertyValue=()=> 'local override';
52
+ const independent=styles[1].getPropertyValue('--tone');
53
+ return {first,second,custom,independent,distinct:styles[0]!==styles[1],
54
+ keys:keys.includes('fontFamily')&&keys.includes('color'),
55
+ values:values.every(value=>value[0]==='rgba(255, 0, 0, 1)'&&value[1]==='red')};
56
+ })()`);
57
+ assert.deepEqual(repeated,{first:1,second:2,custom:'changed',independent:'changed',distinct:true,keys:true,values:true});
58
+ observeSerialization = false;
59
+ const styles = await renderer.evaluate(`(() => {
60
+ const box=document.getElementById('box'),style=getComputedStyle(box),start=__layoutCalls();
61
+ window.savedStyle=style;
62
+ const values=[];
63
+ for(let i=0;i<20;i++) {
64
+ box.style.width=(100+i)+'px';box.style.color=i%2?'blue':'red';box.style.setProperty('--tone',String(i));
65
+ values.push([style.color,style.display,style.getPropertyValue('--tone')]);
66
+ }
67
+ box.style.display='none';values.push([style.display]);box.style.display='block';
68
+ return {layouts:__layoutCalls()-start,first:values[0],last:values[19],hidden:values[20]};
69
+ })()`);
70
+ assert.deepEqual(styles, { layouts:0,first:["rgba(255, 0, 0, 1)","block","0"],
71
+ last:["rgba(0, 0, 255, 1)","block","19"],hidden:["none"] });
72
+ const padding = await renderer.evaluate(`(() => {
73
+ const box=document.getElementById('box'),child=document.getElementById('child'),start=__layoutCalls();
74
+ box.style.width='310px';box.style.padding='2px 3px 4px 5px';
75
+ const fixed=[savedStyle.padding,savedStyle.paddingTop,savedStyle.getPropertyValue('padding-right'),savedStyle.paddingBottom,savedStyle.paddingLeft];
76
+ box.style.paddingLeft='7px';fixed.push(savedStyle.paddingLeft);
77
+ const before=__layoutCalls()-start;
78
+ const percent=getComputedStyle(child).paddingLeft;
79
+ box.style.padding='0px';
80
+ return {fixed,before,percent,after:__layoutCalls()-start};
81
+ })()`);
82
+ assert.deepEqual(padding, { fixed:["2px 3px 4px 5px","2px","3px","4px","5px","7px"],
83
+ before:0,percent:"15.5px",after:1 });
84
+ const geometry = await renderer.evaluate(`(() => {
85
+ const box=document.getElementById('box'),child=document.getElementById('child'),start=__layoutCalls();
86
+ box.style.width='240px';box.style.height='60px';savedStyle.color;
87
+ const before=__layoutCalls()-start;
88
+ const result=[savedStyle.width,savedStyle.height,savedStyle.getPropertyValue('WIDTH'),getComputedStyle(child).paddingLeft];
89
+ return {before,after:__layoutCalls()-start,result};
90
+ })()`);
91
+ assert.deepEqual(geometry, { before:0,after:1,result:["240px","60px","240px","12px"] });
92
+ // A style-only read cannot hide later structure, stylesheet or viewport changes.
93
+ await renderer.evaluate(`document.getElementById('box').style.width='50%'; savedStyle.color`);
94
+ await renderer.resize(600, 300, 1.75);
95
+ assert.equal(await renderer.evaluate("savedStyle.width"), "300px");
96
+ assert.equal(await renderer.evaluate(`(() => {
97
+ const sheet=document.createElement('style');sheet.textContent='#box{width:180px!important;color:green!important}';document.head.appendChild(sheet);
98
+ return [savedStyle.color,savedStyle.width].join('/');
99
+ })()`), "rgba(0, 128, 0, 1)/180px");
100
+ assert.equal(await renderer.evaluate(`(() => {
101
+ const box=document.getElementById('box');box.style.height='auto';box.replaceChildren();
102
+ const child=document.createElement('div');child.style.height='70px';box.appendChild(child);savedStyle.display;
103
+ return savedStyle.height;
104
+ })()`), "70px");
105
+ assert.deepEqual(await renderer.evaluate(`(() => {
106
+ const node=document.createElement('div'),style=getComputedStyle(node),keys=Object.keys(style);
107
+ node.style.cssText='width:77px;height:19px;color:red';document.body.appendChild(node);
108
+ const connected=[style.width,style.height,style.color];node.remove();node.style.width='88px';
109
+ document.body.appendChild(node);const reconnected=[style.width,style.height,style.color];
110
+ return [connected,reconnected,keys.length===Object.keys(style).length];
111
+ })()`), [["77px","19px","rgba(255, 0, 0, 1)"],["88px","19px","rgba(255, 0, 0, 1)"],true]);
112
+ await renderer.evaluate("document.body.style.backgroundColor='rgb(11,22,33)';savedStyle.color");
113
+ const painted = await renderer.screenshot({format:"rgba"});
114
+ assert.deepEqual([...painted.pixels.slice(-4)], [11,22,33,255]);
115
+ assert.deepEqual(await renderer.evaluate(`(() => {
116
+ const image=document.createElement('img');image.id='delayed';image.src='/delayed.png';document.body.appendChild(image);
117
+ const start=__layoutCalls();window.imageStyle=getComputedStyle(image);
118
+ return [imageStyle.display,__layoutCalls()-start];
119
+ })()`), ["inline",0]);
120
+ releaseImage();
121
+ const deadline = Date.now() + 3000;
122
+ while (!(await renderer.evaluate("document.getElementById('delayed').complete"))) {
123
+ assert.ok(Date.now() < deadline,"delayed image should finish loading");
124
+ await Bun.sleep(5);
125
+ }
126
+ assert.deepEqual(await renderer.evaluate("[imageStyle.width,imageStyle.height]"),["2px","3px"]);
127
+ await renderer.evaluate("document.getElementById('delayed').remove();savedStyle.color");
128
+ assert.deepEqual([...(await renderer.screenshot({format:"rgba"})).pixels.slice(-4)],[11,22,33,255]);
129
+ } finally {
130
+ renderer.dispose();
131
+ delete globalThis.__layoutCalls;
132
+ delete globalThis.__serializationCalls;
133
+ StyleEngine.prototype.get = computed;
134
+ LayoutEngine.prototype.layout = layout;
135
+ }
@@ -0,0 +1,97 @@
1
+ import '../../vendor/ses/dist/ses.mjs';
2
+ import assert from 'node:assert/strict';
3
+ import { parseHTMLDocument } from '../../lib/happy-dom/parser.js';
4
+ import { matchSelectors, StyleEngine } from '../../lib/style/computed-style.js';
5
+
6
+ const source = `<body class="scope scope"><section id="outer" class="shared shared shared">
7
+ <div id="branch" class="branch branch shared shared"><span id="deep" class="target target alternate alternate">deep</span></div>
8
+ <span id="after" class="target target">after nested duplicates</span>
9
+ </section><section id="other" class="shared shared"><span id="later" class="target target">later branch</span></section>
10
+ <span id="outside" class="target target alternate">outside</span></body>`;
11
+ const sheets = [`
12
+ .target { color:black; width:1px }
13
+ .shared .target { color:red; width:11px; --tone:blue }
14
+ .branch .target { height:7px }
15
+ :is(.target, .alternate) { padding:2px }
16
+ .target.target { font-weight:700 }
17
+ .shared .target::before { content:"marker"; color:var(--tone) }
18
+ .scope .target { border:1px solid currentColor }
19
+ .missing .target { color:lime !important }
20
+ `];
21
+ const duplicate = parseHTMLDocument(source), canonical = parseHTMLDocument(source);
22
+ const nodes = document => [...document.querySelectorAll('*')];
23
+ const normalizeClasses = () => {
24
+ const left = nodes(duplicate.document), right = nodes(canonical.document);
25
+ for (let index = 0; index < left.length; index++) {
26
+ right[index].setAttribute('class', [...new Set((left[index].getAttribute('class') ?? '').split(/\s+/).filter(Boolean))].join(' '));
27
+ const style = left[index].getAttribute('style');
28
+ if (style === null) right[index].removeAttribute('style');
29
+ else right[index].setAttribute('style', style);
30
+ }
31
+ };
32
+ normalizeClasses();
33
+ const engine = new StyleEngine().compute(duplicate.document, sheets);
34
+ new StyleEngine().compute(canonical.document, sheets); // Warm DOM/style paths before lockdown.
35
+ if (process.argv[2] === 'ses') lockdown({
36
+ errorTaming: 'safe', overrideTaming: 'severe', consoleTaming: 'unsafe',
37
+ localeTaming: 'unsafe', stackFiltering: 'concise', reporting: 'none',
38
+ });
39
+
40
+ const get = id => duplicate.document.getElementById(id);
41
+ const ids = elements => [...elements].map(element => element.id);
42
+ const selectors = ['.shared .target', '.branch .target', ':is(.target, .alternate)', '.target.target', '.missing .target'];
43
+ function verify() {
44
+ normalizeClasses();
45
+ const reference = new StyleEngine().compute(canonical.document, sheets);
46
+ const left = nodes(duplicate.document), right = nodes(canonical.document);
47
+ for (let index = 0; index < left.length; index++) {
48
+ assert.deepEqual(engine.get(left[index]), reference.get(right[index]));
49
+ assert.deepEqual(engine.customPropertiesOf(left[index]), reference.customPropertiesOf(right[index]));
50
+ for (const pseudo of ['before', 'after']) assert.deepEqual(engine.getPseudo(left[index], pseudo), reference.getPseudo(right[index], pseudo));
51
+ }
52
+ const matched = matchSelectors(duplicate.document, selectors);
53
+ for (const selector of selectors) assert.deepEqual(ids(matched.get(selector)), ids(canonical.document.querySelectorAll(selector)), selector);
54
+ // Exiting the nested shared/branch keys must preserve the outer shared
55
+ // count for the next sibling, then restore it before the outside element.
56
+ for (const id of ['deep', 'after', 'later']) assert.equal(engine.get(get(id)).color, 'rgba(255, 0, 0, 1)');
57
+ assert.equal(engine.get(get('outside')).color, 'rgba(0, 0, 0, 1)');
58
+ assert.deepEqual(ids(matched.get('.shared .target')), ['deep', 'after', 'later']);
59
+ assert.deepEqual(ids(matched.get(':is(.target, .alternate)')), ['deep', 'after', 'later', 'outside']);
60
+ }
61
+
62
+ try {
63
+ // Traverse push/pop after lockdown as well as during the initial warmup.
64
+ engine.compute(duplicate.document, sheets);
65
+ verify();
66
+ assert.equal(engine.get(get('deep')).height, 7);
67
+ assert.equal(engine.get(get('after')).height, 'auto');
68
+ for (const classes of ['shared shared', 'branch branch shared shared shared']) {
69
+ get('branch').className = classes;
70
+ engine.compute(duplicate.document, sheets, {}, new Set([get('outer'), get('other')]));
71
+ assert.equal(engine.incremental, true);
72
+ verify();
73
+ assert.equal(engine.get(get('deep')).height, classes.includes('branch') ? 7 : 'auto');
74
+ }
75
+ // Long ancestor and subject lists use the guarded deduplication path. Keep
76
+ // a distinct matching token after the repeated keys so it cannot be lost.
77
+ get('outer').className = Array(1000).fill('shared').join(' ');
78
+ get('branch').className = `${Array(1000).fill('shared').join(' ')} branch`;
79
+ get('deep').className = `${Array(1000).fill('target').join(' ')} alternate`;
80
+ get('after').className = Array(17).fill('target').join(' ');
81
+ get('later').className = Array(16).fill('target').join(' ');
82
+ engine.compute(duplicate.document, sheets, {}, new Set([get('outer'), get('other')]));
83
+ verify();
84
+ assert.equal(engine.get(get('deep')).height, 7);
85
+ assert.equal(engine.get(get('after')).height, 'auto');
86
+ // Separate dirty roots build ancestor filters through repeated class keys.
87
+ get('deep').style.width = '23px';
88
+ get('later').style.width = '29px';
89
+ engine.compute(duplicate.document, sheets, {}, new Set([get('deep'), get('later')]));
90
+ assert.equal(engine.restyled, 2);
91
+ verify();
92
+ assert.equal(engine.get(get('deep')).width, 23);
93
+ assert.equal(engine.get(get('later')).width, 29);
94
+ } finally {
95
+ duplicate.window.happyDOM.abort();
96
+ canonical.window.happyDOM.abort();
97
+ }
@@ -7,6 +7,15 @@ process.on("message", async (message) => {
7
7
  if (message?.type !== "call") return;
8
8
  const [argument] = message.params;
9
9
  switch (message.method) {
10
+ case "slow":
11
+ await Bun.sleep(200);
12
+ return process.send({ type: "result", id: message.id, result: "completed" });
13
+ case "showFragment":
14
+ await Bun.sleep(200);
15
+ return process.send({ type: "result", id: message.id, result: "positioned" });
16
+ case "loadDocument":
17
+ await Bun.sleep(argument.delay);
18
+ return process.send({ type: "result", id: message.id, result: "loaded" });
10
19
  case "configuration": return process.send({ type: "result", id: message.id, result: { args: process.argv, secret: process.env.BUNINU_TEST_PRIVATE_ENV } });
11
20
  case "echo": return process.send({ type: "result", id: message.id, result: { pid: process.pid, argument } });
12
21
  case "hang": for (;;) {}
@@ -24,4 +33,7 @@ process.on("message", async (message) => {
24
33
  }
25
34
  });
26
35
  process.on("disconnect", () => process.exit(0));
36
+ const delay = Number(process.argv.find(arg => arg.startsWith('--ready-delay='))?.split('=')[1] ?? 0);
37
+ if (process.argv.includes('--exit-before-ready')) process.exit(2);
38
+ if (delay) await Bun.sleep(delay);
27
39
  process.send({ type: "ready" });
@@ -0,0 +1,97 @@
1
+ // Compare edited font definitions against fresh documents and count CSS work.
2
+ import { strict as assert } from "node:assert";
3
+ import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
4
+ import { StyleEngine } from "../../lib/style/computed-style.js";
5
+
6
+ const mono = await Bun.file(new URL("../../usr/share/fonts/DejaVuSansMono.ttf", import.meta.url)).bytes();
7
+ const sans = await Bun.file(new URL("../../usr/share/fonts/Roboto-Regular.ttf", import.meta.url)).bytes();
8
+ const primary = '@font-face{font-family:DefinitionPrimary;src:url(/mono.ttf)}';
9
+ const alias = '@font-face{font-family:DefinitionAlias;src:url(/sans.ttf)}';
10
+ const phases = [
11
+ ["loaded", primary + alias],
12
+ ["remove primary", alias],
13
+ ["rename primary", primary.replace("DefinitionPrimary", "UnusedDefinition") + alias],
14
+ ["replace source", primary.replace("/mono.ttf", "/sans.ttf") + alias],
15
+ ["restore primary", primary + alias],
16
+ ["remove all", ""],
17
+ ["restore all", primary + alias],
18
+ ];
19
+ const compute = StyleEngine.prototype.compute;
20
+ let computations = null;
21
+ let currentDocument = null;
22
+ StyleEngine.prototype.compute = function (...args) {
23
+ currentDocument = args[0];
24
+ const result = compute.apply(this, args);
25
+ computations?.push({ count: this.restyled, full: !this.incremental });
26
+ return result;
27
+ };
28
+
29
+ async function open(definitions, usesMetrics) {
30
+ let requests = 0;
31
+ const renderer = await createPageRenderer({
32
+ fetchResource: async url => ({ ok: false, status: 404, url, body: new Uint8Array() }),
33
+ pageFetch: async ({ url }) => {
34
+ requests++;
35
+ return { status: 200, body: url.endsWith("/mono.ttf") ? mono : sans };
36
+ },
37
+ });
38
+ renderer.resize(400, 250, 1);
39
+ await renderer.loadDocument({ url: "https://font-definition.test/", contentType: "text/html", source: `<!doctype html>
40
+ <style id=faces>${definitions}</style><style>
41
+ body{margin:0}#auto,#metric{display:inline-block;font:30px DefinitionPrimary,DefinitionAlias,sans-serif}
42
+ #metric{${usesMetrics ? "width:10ch" : "padding:2px"}}
43
+ #preload{font:20px DefinitionAlias,sans-serif}
44
+ </style><span id=auto>iiii</span><br><span id=metric>metric</span><div id=preload>preload alias</div>` });
45
+ await renderer.whenLoaded();
46
+ return { renderer, document: currentDocument, requests: () => requests };
47
+ }
48
+
49
+ async function capture(renderer) {
50
+ const geometry = renderer.inspect("#auto,#metric", ["width"]);
51
+ const image = await renderer.screenshot({ format: "rgba" });
52
+ return { geometry, pixels: Buffer.from(image.pixels) };
53
+ }
54
+
55
+ try {
56
+ for (const usesMetrics of [false, true]) {
57
+ const live = await open(phases[0][1], usesMetrics);
58
+ const snapshots = [];
59
+ try {
60
+ for (const [index, [name, definitions]] of phases.entries()) {
61
+ computations = [];
62
+ if (index) {
63
+ live.document.getElementById("faces").textContent = definitions;
64
+ // Repeated viewport metrics flush mutations without changing any
65
+ // layout input; font definition invalidation must reset the caches.
66
+ live.renderer.resize(400, 250, 1);
67
+ // Font bytes are already available; drain the cached-source promise
68
+ // chain that registers a new alias when a descriptor is replaced.
69
+ await Bun.sleep(0);
70
+ }
71
+ snapshots.push(await capture(live.renderer));
72
+ if (index) assert.equal(computations.some(entry => entry.full), usesMetrics,
73
+ `${name}: only font-relative computed lengths require a full cascade`);
74
+ assert.equal(live.requests(), 2, `${name}: cached sources and unused definitions must not refetch fonts`);
75
+ computations = null;
76
+ }
77
+ } finally { computations = null; live.renderer.dispose(); }
78
+
79
+ for (const [index, [name, definitions]] of phases.entries()) {
80
+ const fresh = await open(definitions, usesMetrics);
81
+ try {
82
+ const expected = await capture(fresh.renderer);
83
+ assert.deepEqual(snapshots[index].geometry, expected.geometry, `${name}: fresh layout geometry`);
84
+ assert.deepEqual(snapshots[index].pixels, expected.pixels, `${name}: fresh layout pixels`);
85
+ } finally { fresh.renderer.dispose(); }
86
+ }
87
+ assert.ok(snapshots[1].geometry[0].box.width < snapshots[0].geometry[0].box.width / 2,
88
+ "removing the primary face must select the narrower loaded fallback");
89
+ assert.deepEqual(snapshots.at(-1), snapshots[0], "restoring definitions must restore geometry and pixels");
90
+ if (usesMetrics) assert.notEqual(snapshots[1].geometry[1].style.width, snapshots[0].geometry[1].style.width,
91
+ "ch lengths must use the replacement font metrics");
92
+ }
93
+ process.stdout.write("font definition invalidation integration passed\n");
94
+ } finally {
95
+ computations = null;
96
+ StyleEngine.prototype.compute = compute;
97
+ }
@@ -0,0 +1,87 @@
1
+ // Isolate prototype counters and the forced full-cascade reference.
2
+ import { strict as assert } from "node:assert";
3
+ import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
4
+ import { StyleEngine } from "../../lib/style/computed-style.js";
5
+
6
+ const font = await Bun.file(new URL("../../usr/share/fonts/DejaVuSansMono.ttf", import.meta.url)).bytes();
7
+ const compute = StyleEngine.prototype.compute;
8
+ const dependency = Object.getOwnPropertyDescriptor(StyleEngine.prototype, "usesFontMetrics");
9
+ let computations, currentDocument;
10
+ StyleEngine.prototype.compute = function (...args) {
11
+ currentDocument = args[0];
12
+ const result = compute.apply(this, args);
13
+ computations?.push({ count: this.restyled, full: !this.incremental });
14
+ return result;
15
+ };
16
+
17
+ async function snapshot(extra, forceFull, mutate = false) {
18
+ if (forceFull) Object.defineProperty(StyleEngine.prototype, "usesFontMetrics", { configurable: true, get: () => true });
19
+ else Object.defineProperty(StyleEngine.prototype, "usesFontMetrics", dependency);
20
+ let finish, requests = 0;
21
+ const response = new Promise(resolve => { finish = resolve; });
22
+ const renderer = await createPageRenderer({
23
+ fetchResource: async url => ({ ok: false, status: 404, url, body: new Uint8Array() }),
24
+ pageFetch: () => { requests++; return response; },
25
+ });
26
+ try {
27
+ renderer.resize(400, 300, 1);
28
+ await renderer.loadDocument({ url: "https://font.test/", contentType: "text/html", source: `<!doctype html>
29
+ <style>@font-face{font-family:Delayed;src:url(/font.ttf)}
30
+ @font-face{font-family:Unused;src:url(/never.ttf)}
31
+ body{margin:0;font:16px Delayed,sans-serif;line-height:normal}
32
+ #probe,.atom{display:inline-block;padding:2px}
33
+ .flex{display:flex;width:180px}.grid{display:grid;grid-template-columns:min-content 1fr;width:180px}
34
+ ${extra}</style>
35
+ <div id=probe>iiii</div><div id=changed>normal line height</div>
36
+ <div class=flex><span class=atom>iiii Hello</span><span class=atom>more words</span></div>
37
+ <div class=grid><span>iii iii</span><span>word word word</span></div>
38
+ <div style="width:150px">${'wrapping text '.repeat(12)}</div>` });
39
+ const before = renderer.inspect("#probe")[0].box.width;
40
+ if (mutate) {
41
+ // Pending DOM/style invalidation must not be replaced by font-only work.
42
+ const changed = currentDocument.getElementById("changed");
43
+ changed.style.width = "123px";
44
+ changed.style.color = "blue";
45
+ changed.insertAdjacentHTML("beforeend", '<span style="display:block;height:19px">inserted</span>');
46
+ }
47
+ computations = [];
48
+ finish({ status: 200, body: font });
49
+ await renderer.whenLoaded();
50
+ const after = renderer.inspect("#probe")[0].box.width;
51
+ const changed = renderer.inspect("#changed", ["width", "color"])[0];
52
+ const captured = await renderer.screenshot({ format: "rgba" });
53
+ assert.equal(requests, 1, "unused font definitions must not load or reset layout");
54
+ return { before, after, changed, pixels: Buffer.from(captured.pixels), computations: [...computations] };
55
+ } finally { computations = null; renderer.dispose(); }
56
+ }
57
+
58
+ try {
59
+ for (const [name, extra, usesMetrics] of [
60
+ ["ordinary text", "", false],
61
+ ["ch", "#probe{width:10ch}", true],
62
+ ["custom variable", "body{--size:4ch}#probe{width:calc(var(--size) + 5px)}", true],
63
+ ["pseudo", "#probe::before{content:'x';display:inline-block;width:2ch}", true],
64
+ ["root font size", "html{font-family:Delayed,sans-serif;font-size:2ch}#probe{width:10rem}", true],
65
+ ]) {
66
+ const candidate = await snapshot(extra, false);
67
+ const reference = await snapshot(extra, true);
68
+ assert.deepEqual(candidate.pixels, reference.pixels, name + " pixels must match a fresh full cascade");
69
+ assert.equal(candidate.after, reference.after, name + " geometry must match");
70
+ // Root font-size ch uses the initial/parent font while resolving size,
71
+ // so it is a conservative dependency even if this face cannot affect it.
72
+ if (name !== "root font size") assert.ok(candidate.after > candidate.before, name + " must use the newly loaded face");
73
+ assert.equal(candidate.computations.some(entry => entry.full), usesMetrics, name + " cascade dependency");
74
+ if (!usesMetrics) assert.equal(candidate.computations.reduce((sum, entry) => sum + entry.count, 0), 0,
75
+ "ordinary text changes font metrics without recomputing CSS");
76
+ }
77
+ const candidate = await snapshot("", false, true);
78
+ const reference = await snapshot("", true, true);
79
+ assert.deepEqual(candidate.pixels, reference.pixels, "simultaneous DOM changes must match full cascade");
80
+ assert.equal(candidate.changed.box.width, 123);
81
+ assert.equal(candidate.changed.style.color, "rgba(0, 0, 255, 1)");
82
+ assert.ok(candidate.computations.some(entry => entry.count > 0), "pending DOM changes still restyle");
83
+ process.stdout.write("font metrics invalidation integration passed\n");
84
+ } finally {
85
+ StyleEngine.prototype.compute = compute;
86
+ Object.defineProperty(StyleEngine.prototype, "usesFontMetrics", dependency);
87
+ }
@@ -0,0 +1,36 @@
1
+ import '../../vendor/ses/dist/ses.mjs';
2
+ import assert from 'node:assert/strict';
3
+ import { parseHTMLDocument } from '../../lib/happy-dom/parser.js';
4
+ import { StyleEngine } from '../../lib/style/computed-style.js';
5
+ import { RenderTreeBuilder } from '../../lib/render-tree/index.js';
6
+ import { LayoutEngine } from '../../lib/layout/text-layout.js';
7
+ import { buildDisplayList } from '../../lib/paint/display-list.js';
8
+
9
+ const { document, window } = parseHTMLDocument('<body>' + Array.from({ length:200 }, (_, i) =>
10
+ `<section class="card"><p>Text ${i}</p><span>More ${i}</span></section>`).join('') + '</body>');
11
+ const sheets = ['body {margin:0} .card {width:500px;padding:3px} p {margin:0}'];
12
+ const engine = new StyleEngine().compute(document, sheets), builder = new RenderTreeBuilder();
13
+ let measures = 0;
14
+ const measureText = (text, style) => { measures++; return text.length * style.fontSize / 2; };
15
+ const layout = new LayoutEngine({ measureText }), options = { width:800, viewportHeight:600 };
16
+ const tree = builder.build(document, engine);
17
+ layout.layout(tree, options);
18
+ const before = [...document.querySelectorAll('*')].map(element => [element, engine.get(element)]);
19
+ lockdown({ errorTaming:'safe', overrideTaming:'severe', consoleTaming:'unsafe', localeTaming:'unsafe', stackFiltering:'concise', reporting:'none' });
20
+ try {
21
+ sheets.push('.unmatched {width:200px}');
22
+ engine.compute(document, sheets);
23
+ assert.equal(engine.incremental, false);
24
+ assert.equal(engine.restyled, before.length);
25
+ for (const [element, style] of before) assert.equal(engine.get(element), style);
26
+ const next = builder.build(document, engine);
27
+ assert.equal(next.root, tree.root);
28
+ measures = 0;
29
+ const result = layout.layout(next, options);
30
+ assert.equal(measures, 0);
31
+ const freshStyle = new StyleEngine().compute(document, sheets);
32
+ const freshTree = new RenderTreeBuilder().build(document, freshStyle);
33
+ const fresh = new LayoutEngine({ measureText }).layout(freshTree, options);
34
+ const actual = buildDisplayList(result, next), expected = buildDisplayList(fresh, freshTree);
35
+ for (const key of ['items', 'hits', 'scrollers', 'extent']) assert.deepEqual(actual[key], expected[key]);
36
+ } finally { window.happyDOM.abort(); }