buninu-browser 0.5.1 → 0.6.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 (133) hide show
  1. package/README.md +9 -3
  2. package/lib/audio/pcm.js +11 -0
  3. package/lib/audio/pulse-client.js +7 -2
  4. package/lib/cdp-server.js +31 -7
  5. package/lib/happy-dom/document-base.js +29 -1
  6. package/lib/happy-dom/mutation-observer.js +157 -0
  7. package/lib/happy-dom/parser.js +2 -0
  8. package/lib/headless-shell.js +49 -9
  9. package/lib/layout/repaint.js +89 -0
  10. package/lib/media/mp4-keyframes.js +144 -0
  11. package/lib/media/video-keyframe.js +29 -1
  12. package/lib/media/webm-snapshots.js +282 -0
  13. package/lib/network/cookie-jar.js +18 -2
  14. package/lib/paint/display-list.js +4 -3
  15. package/lib/paint/frame.js +12 -5
  16. package/lib/render-tree/index.js +3 -0
  17. package/lib/renderer/animation-frames.js +76 -0
  18. package/lib/renderer/bindings.js +2 -0
  19. package/lib/renderer/compositor-channel.js +95 -10
  20. package/lib/renderer/compositor.js +54 -18
  21. package/lib/renderer/document-cookies.js +87 -0
  22. package/lib/renderer/font-loading.js +60 -16
  23. package/lib/renderer/host.js +48 -19
  24. package/lib/renderer/idle-callbacks.js +109 -0
  25. package/lib/renderer/main.js +5 -1
  26. package/lib/renderer/media-source.js +4 -0
  27. package/lib/renderer/page-realm.js +159 -49
  28. package/lib/renderer/page-renderer.js +567 -197
  29. package/lib/renderer/resource-elements.js +88 -0
  30. package/lib/renderer/script-rewrite.js +18 -1
  31. package/lib/renderer/seccomp.js +6 -4
  32. package/lib/renderer/timers.js +71 -0
  33. package/lib/renderer/video-channel.js +94 -0
  34. package/lib/renderer/video-mask.js +115 -0
  35. package/lib/renderer/video-presentation.js +304 -39
  36. package/lib/renderer/video-worker.js +46 -0
  37. package/lib/renderer/workers.js +7 -17
  38. package/lib/style/computed-style.js +257 -61
  39. package/package.json +1 -1
  40. package/test/animation-frames.test.js +67 -0
  41. package/test/canvas-style-snapshot.test.js +18 -0
  42. package/test/cdp-stream.test.js +96 -3
  43. package/test/compositor-channel-ownership.test.js +194 -0
  44. package/test/compositor.test.js +27 -1
  45. package/test/computed-style-binding.test.js +49 -0
  46. package/test/computed-style-connectivity.test.js +13 -0
  47. package/test/computed-style.test.js +86 -0
  48. package/test/cookie-sync.test.js +82 -0
  49. package/test/document-base-cache.test.js +103 -0
  50. package/test/document-cookies.test.js +117 -0
  51. package/test/duplicate-style-keys.test.js +11 -0
  52. package/test/fixtures/animation-frames.js +56 -0
  53. package/test/fixtures/canvas-style-snapshot.js +81 -0
  54. package/test/fixtures/computed-style-connectivity.js +101 -0
  55. package/test/fixtures/computed-style-lazy.js +135 -0
  56. package/test/fixtures/duplicate-style-keys.js +97 -0
  57. package/test/fixtures/fake-renderer.js +12 -0
  58. package/test/fixtures/font-definition-invalidation.js +97 -0
  59. package/test/fixtures/font-metrics-invalidation.js +87 -0
  60. package/test/fixtures/full-style-identity.js +36 -0
  61. package/test/fixtures/hidden-style-engine.js +260 -0
  62. package/test/fixtures/hidden-style-reentry.js +232 -0
  63. package/test/fixtures/hidden-style-renderer.js +203 -0
  64. package/test/fixtures/layout-repaint.js +69 -0
  65. package/test/fixtures/media-presentation-continuation.js +141 -0
  66. package/test/fixtures/media-presentation-refill.js +113 -0
  67. package/test/fixtures/media-probe.js +148 -0
  68. package/test/fixtures/media-refill-cancel.js +79 -0
  69. package/test/fixtures/media-repaint.js +48 -0
  70. package/test/fixtures/media-resource-reselection.js +94 -0
  71. package/test/fixtures/media-source-attachment.js +136 -0
  72. package/test/fixtures/media-source-no-decoder.js +17 -0
  73. package/test/fixtures/media-track-refill.js +132 -0
  74. package/test/fixtures/media-webm-seek.js +51 -0
  75. package/test/fixtures/mutation-observer.js +201 -0
  76. package/test/fixtures/observer-invalidation.js +105 -0
  77. package/test/fixtures/prepaint-scheduling.js +73 -0
  78. package/test/fixtures/resource-discovery.js +32 -0
  79. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  80. package/test/fixtures/style-propagation.js +26 -0
  81. package/test/fixtures/svg-node-reuse.js +83 -0
  82. package/test/fixtures/video-black-replay.js +140 -0
  83. package/test/fixtures/video-controls-invalidation.js +71 -0
  84. package/test/fixtures/viewport-bounds.js +148 -0
  85. package/test/font-loading.test.js +77 -0
  86. package/test/font-metrics-invalidation.test.js +60 -0
  87. package/test/full-style-identity.test.js +191 -0
  88. package/test/hidden-style-lazy.test.js +31 -0
  89. package/test/hidden-style-reentry.test.js +20 -0
  90. package/test/idle-callback.test.js +89 -0
  91. package/test/idle-scheduler.test.js +148 -0
  92. package/test/inline-style-cascade.test.js +63 -0
  93. package/test/layout-repaint-renderer.test.js +9 -0
  94. package/test/layout-repaint.test.js +222 -0
  95. package/test/media-presentation-continuation.test.js +14 -0
  96. package/test/media-presentation-refill.test.js +8 -0
  97. package/test/media-probe.test.js +16 -0
  98. package/test/media-repaint.test.js +8 -0
  99. package/test/media-source-attachment.test.js +34 -0
  100. package/test/media-track-refill.test.js +26 -0
  101. package/test/media-webm-seek.test.js +11 -0
  102. package/test/mp4-keyframes.test.js +36 -0
  103. package/test/mutation-observer.test.js +91 -0
  104. package/test/observer-invalidation.test.js +12 -0
  105. package/test/page-scripts.test.js +35 -0
  106. package/test/page-timers.test.js +67 -0
  107. package/test/paint-frame.test.js +18 -1
  108. package/test/pcm.test.js +63 -0
  109. package/test/prepaint-scheduling.test.js +13 -0
  110. package/test/pulse-client.test.js +165 -0
  111. package/test/raw-capture.test.js +34 -5
  112. package/test/renderer-host.test.js +51 -0
  113. package/test/resource-discovery.test.js +12 -0
  114. package/test/resource-elements.test.js +234 -0
  115. package/test/root-self-style.test.js +104 -0
  116. package/test/script-rewrite.test.js +35 -0
  117. package/test/seccomp.test.js +45 -0
  118. package/test/spare-renderer-scheduling.test.js +8 -0
  119. package/test/style-propagation.test.js +225 -0
  120. package/test/style-sheets.test.js +19 -0
  121. package/test/svg.test.js +45 -0
  122. package/test/timers.test.js +58 -0
  123. package/test/video-black-replay.test.js +34 -0
  124. package/test/video-capture.test.js +170 -0
  125. package/test/video-channel.test.js +253 -0
  126. package/test/video-continuation.test.js +57 -0
  127. package/test/video-controls-invalidation.test.js +12 -0
  128. package/test/video-keyframe.test.js +104 -4
  129. package/test/video-mask.test.js +94 -0
  130. package/test/video-presentation.test.js +239 -1
  131. package/test/video-timing-presentation.test.js +115 -0
  132. package/test/viewport-bounds.test.js +13 -0
  133. package/test/webm-snapshots.test.js +372 -0
@@ -0,0 +1,232 @@
1
+ import assert from "node:assert/strict";
2
+ const root = new URL("../../", import.meta.url).pathname.replace(/\/$/, "");
3
+ if (process.argv.includes("--ses")) {
4
+ await import(root + "/vendor/ses/dist/ses.mjs");
5
+ lockdown({
6
+ errorTaming: "safe",
7
+ overrideTaming: "severe",
8
+ consoleTaming: "unsafe",
9
+ localeTaming: "unsafe",
10
+ stackFiltering: "concise",
11
+ reporting: "none",
12
+ });
13
+ }
14
+ const { parseHTMLDocument } = await import(root + "/lib/happy-dom/parser.js");
15
+ const { StyleEngine, computeElementStyle } = await import(
16
+ root + "/lib/style/computed-style.js"
17
+ );
18
+ let assertions = 0;
19
+ function snapshot(engine, node) {
20
+ return {
21
+ width: engine.get(node).width,
22
+ fontSize: engine.get(node).fontSize,
23
+ custom: engine.customPropertiesOf(node).get("--x") ?? null,
24
+ pseudo: engine.getPseudo(node, "before")?.width ?? null,
25
+ };
26
+ }
27
+ function scenario(deferHidden, kind, warm = false) {
28
+ const f = parseHTMLDocument(
29
+ '<div id="hide" style="display:' +
30
+ (warm ? "block" : "none") +
31
+ '"><section id="parent" style="' +
32
+ (kind === "descendant" || kind === "parentpseudo"
33
+ ? "width:1ch;font-size:24px"
34
+ : "") +
35
+ '"><span id="target" style="font-size:30px;--x:value;width:1ch"></span></section></div>',
36
+ );
37
+ const target = f.document.getElementById("target"),
38
+ parent = f.document.getElementById("parent");
39
+ let calls = 0,
40
+ inner = [];
41
+ const sheets =
42
+ kind === "pseudo"
43
+ ? ['#target::before{content:"b";width:1ch}']
44
+ : kind === "parentpseudo"
45
+ ? ['#parent::before{content:"b";width:1ch}']
46
+ : [];
47
+ const engine = new StyleEngine({
48
+ deferHidden,
49
+ measureText() {
50
+ if (++calls > 5) throw Error("reentry was not bounded");
51
+ inner.push({
52
+ target: snapshot(engine, target),
53
+ parent: snapshot(engine, parent),
54
+ });
55
+ return kind === "pseudo" ? engine.get(target).fontSize : 7;
56
+ },
57
+ });
58
+ try {
59
+ engine.compute(f.document, sheets);
60
+ if (warm) {
61
+ f.document.getElementById("hide").style.display = "none";
62
+ target.style.fontSize = "40px";
63
+ calls = 0;
64
+ inner = [];
65
+ engine.compute(
66
+ f.document,
67
+ sheets,
68
+ {},
69
+ new Set([f.document.getElementById("hide")]),
70
+ );
71
+ }
72
+ const result = {
73
+ target: snapshot(engine, target),
74
+ parent: snapshot(engine, parent),
75
+ calls,
76
+ inner,
77
+ };
78
+ return result;
79
+ } finally {
80
+ f.window.happyDOM.abort();
81
+ }
82
+ }
83
+ for (const kind of ["same", "descendant", "pseudo", "parentpseudo"])
84
+ for (const warm of [false, true]) {
85
+ const eager = scenario(false, kind, warm),
86
+ lazy = scenario(true, kind, warm);
87
+ assert.deepEqual(
88
+ lazy,
89
+ eager,
90
+ kind + " reentrant values match eager snapshots",
91
+ );
92
+ assert.equal(
93
+ lazy.calls,
94
+ kind === "same" ? 1 : kind === "parentpseudo" ? 3 : 2,
95
+ );
96
+ assertions += 2;
97
+ }
98
+ // Ownership begins before ancestor/key extraction as well as measurements.
99
+ for (const ancestor of [false, true]) {
100
+ const f = parseHTMLDocument(
101
+ '<div id="hide" style="display:none"><span id="target" style="width:17px"></span></div>',
102
+ );
103
+ const target = f.document.getElementById("target"),
104
+ hooked = ancestor ? f.document.getElementById("hide") : target;
105
+ const engine = new StyleEngine({ deferHidden: true });
106
+ engine.compute(f.document);
107
+ let calls = 0,
108
+ inner = [];
109
+ const getAttribute = hooked.getAttribute;
110
+ Object.defineProperty(hooked, "getAttribute", {
111
+ configurable: true,
112
+ value(name) {
113
+ if (name === "id") {
114
+ if (++calls > 3) throw Error("key reentry was not bounded");
115
+ inner.push(engine.get(target).width);
116
+ }
117
+ return getAttribute.call(this, name);
118
+ },
119
+ });
120
+ try {
121
+ assert.equal(engine.get(target).width, 17);
122
+ assert.equal(calls, 1);
123
+ assert.deepEqual(inner, ["auto"]);
124
+ assertions += 3;
125
+ } finally {
126
+ delete hooked.getAttribute;
127
+ f.window.happyDOM.abort();
128
+ }
129
+ }
130
+ // A failure in a pseudo occurs after the principal values were stored. They
131
+ // must not make the next query mistake that failed node for a complete result.
132
+ {
133
+ const f = parseHTMLDocument(
134
+ '<html style="font-size:21px"><body><div id="hide" style="display:none"><span id="target" style="width:1ch"></span></div></body></html>',
135
+ );
136
+ const target = f.document.getElementById("target");
137
+ let calls = 0,
138
+ fail = true;
139
+ const engine = new StyleEngine({
140
+ deferHidden: true,
141
+ measureText() {
142
+ calls++;
143
+ if (fail && calls === 2)
144
+ Object.defineProperty(target, "matches", {
145
+ configurable: true,
146
+ get() {
147
+ throw Error("pseudo failure");
148
+ },
149
+ });
150
+ return 7;
151
+ },
152
+ });
153
+ try {
154
+ engine.compute(f.document, [
155
+ '#target::before{content:"b";width:1ch} #hide #target::after{content:"a"}',
156
+ ]);
157
+ let thrown = false;
158
+ try {
159
+ engine.get(target);
160
+ } catch (error) {
161
+ thrown = error.message === "pseudo failure";
162
+ }
163
+ assert.equal(thrown, true);
164
+ assert.equal(calls, 2);
165
+ delete target.matches;
166
+ fail = false;
167
+ const initial = f.document.createElement("i");
168
+ initial.style.width = "2rem";
169
+ assert.equal(computeElementStyle(initial).width, 32);
170
+ assert.equal(engine.get(target).width, 7);
171
+ assert.equal(engine.getPseudo(target, "before").width, 7);
172
+ assert.equal(engine.getPseudo(target, "after").content, "a");
173
+ assert.equal(calls, 4);
174
+ assertions += 7;
175
+ } finally {
176
+ f.window.happyDOM.abort();
177
+ }
178
+ }
179
+ // The guard belongs to an ensured generation, not the engine globally. A
180
+ // callback may start another document or another compute of the same document.
181
+ for (const sameDocument of [false, true]) {
182
+ const first = parseHTMLDocument(
183
+ '<div style="display:none;color:red"><span id="target" style="width:1ch;height:10vw"></span></div>',
184
+ );
185
+ const second = sameDocument
186
+ ? first
187
+ : parseHTMLDocument(
188
+ '<div style="display:none;color:blue"><span id="target" style="width:1ch;height:10vw"></span></div>',
189
+ );
190
+ const oldTarget = first.document.getElementById("target"),
191
+ newTarget = second.document.getElementById("target");
192
+ let calls = 0,
193
+ nested = null;
194
+ const engine = new StyleEngine({
195
+ deferHidden: true,
196
+ measureText() {
197
+ calls++;
198
+ if (calls === 1) {
199
+ engine.compute(second.document, [], { width: 400 });
200
+ nested = engine.get(newTarget);
201
+ }
202
+ return 7;
203
+ },
204
+ });
205
+ try {
206
+ engine.compute(first.document, [], { width: 800 });
207
+ engine.get(oldTarget);
208
+ assert.equal(calls, 2);
209
+ assert.equal(nested.width, 7);
210
+ assert.equal(nested.height, 40);
211
+ assert.equal(engine.get(newTarget), nested);
212
+ assert.equal(
213
+ engine.get(newTarget).color,
214
+ sameDocument ? "rgba(255, 0, 0, 1)" : "rgba(0, 0, 255, 1)",
215
+ );
216
+ assertions += 5;
217
+ if (!sameDocument) {
218
+ assert.equal(engine.get(oldTarget).width, "auto");
219
+ assertions++;
220
+ }
221
+ } finally {
222
+ first.window.happyDOM.abort();
223
+ if (!sameDocument) second.window.happyDOM.abort();
224
+ }
225
+ }
226
+ console.log(
227
+ JSON.stringify({
228
+ pass: true,
229
+ assertions,
230
+ mode: process.argv.includes("--ses") ? "ses" : "plain",
231
+ }),
232
+ );
@@ -0,0 +1,203 @@
1
+ import assert from "node:assert/strict";
2
+ const root = new URL("../../", import.meta.url).pathname.replace(/\/$/, ""),
3
+ mode = "ses";
4
+ const { createPageRenderer } = await import(
5
+ root + "/lib/renderer/page-renderer.js"
6
+ );
7
+ const errors = [],
8
+ requests = [];
9
+ const png = Buffer.from(
10
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a6XQAAAAASUVORK5CYII=",
11
+ "base64",
12
+ );
13
+ const mono = await Bun.file(
14
+ root + "/usr/share/fonts/DejaVuSansMono.ttf",
15
+ ).bytes();
16
+ const sans = await Bun.file(
17
+ root + "/usr/share/fonts/Roboto-Regular.ttf",
18
+ ).bytes();
19
+ const options = {
20
+ reportError: (e) => errors.push(String(e)),
21
+ pageFetch: async ({ url }) => {
22
+ requests.push(url);
23
+ return {
24
+ status: 200,
25
+ body: url.endsWith("mono.ttf")
26
+ ? mono
27
+ : url.endsWith("sans.ttf")
28
+ ? sans
29
+ : png,
30
+ };
31
+ },
32
+ fetchResource: async (url) => {
33
+ requests.push(url);
34
+ return { ok: true, status: 200, url, body: png };
35
+ },
36
+ };
37
+ const renderer = await createPageRenderer(options);
38
+ if (mode === "ses") {
39
+ await import(root + "/vendor/ses/dist/ses.mjs");
40
+ lockdown({
41
+ errorTaming: "safe",
42
+ overrideTaming: "severe",
43
+ consoleTaming: "unsafe",
44
+ localeTaming: "unsafe",
45
+ stackFiltering: "concise",
46
+ reporting: "none",
47
+ });
48
+ }
49
+ const phases = [];
50
+ async function state(label, mutation = "") {
51
+ if (mutation) await renderer.evaluate(mutation);
52
+ const values = await renderer.evaluate(
53
+ `(() => {const ids=['outer','nested','leaf','grand','new','canvas'];return ids.map(id=>{const e=document.getElementById(id);if(!e)return null;const s=getComputedStyle(e);const b=e.getBoundingClientRect();return {id,color:s.color,width:s.width,height:s.height,padding:s.padding,fontSize:s.fontSize,display:s.display,custom:s.getPropertyValue('--tone'),rect:[b.x,b.y,b.width,b.height]};});})()`,
54
+ );
55
+ const saved = await renderer.evaluate(
56
+ `window.saved ? [saved.color,saved.width,saved.getPropertyValue('--tone'),saved.length,keys===Object.keys(saved).join('|')] : null`,
57
+ );
58
+ const image = await renderer.screenshot({ format: "rgba" });
59
+ phases.push({
60
+ label,
61
+ values,
62
+ saved,
63
+ pixelHash: new Bun.CryptoHasher("sha256")
64
+ .update(image.pixels)
65
+ .digest("hex"),
66
+ });
67
+ const source = await renderer.evaluate("document.documentElement.outerHTML");
68
+ const fresh = await createPageRenderer({
69
+ ...options,
70
+ pageFetch: async ({ url }) => ({
71
+ status: 200,
72
+ body: url.endsWith("mono.ttf")
73
+ ? mono
74
+ : url.endsWith("sans.ttf")
75
+ ? sans
76
+ : png,
77
+ }),
78
+ fetchResource: async (url) => ({ ok: true, status: 200, url, body: png }),
79
+ });
80
+ try {
81
+ fresh.setScripting(mode);
82
+ fresh.resize(
83
+ label === "viewport" ||
84
+ [
85
+ "remove-font",
86
+ "replace-font",
87
+ "replacement-ready",
88
+ "final-reveal",
89
+ ].includes(label)
90
+ ? 220
91
+ : 320,
92
+ 180,
93
+ 1,
94
+ );
95
+ await fresh.loadDocument({
96
+ url: "https://hidden.test/",
97
+ contentType: "text/html",
98
+ source,
99
+ });
100
+ await fresh.whenLoaded();
101
+ const expected = await fresh.screenshot({ format: "rgba" });
102
+ if (!Buffer.from(image.pixels).equals(Buffer.from(expected.pixels)))
103
+ throw new Error(
104
+ label +
105
+ " pixel mismatch " +
106
+ Bun.hash(image.pixels) +
107
+ "/" +
108
+ Bun.hash(expected.pixels),
109
+ );
110
+ const expectedGeometry = fresh.inspect(
111
+ "#outer,#nested,#leaf,#grand,#new,#canvas",
112
+ ["color", "width", "height", "fontSize", "display"],
113
+ );
114
+ const actualGeometry = renderer.inspect(
115
+ "#outer,#nested,#leaf,#grand,#new,#canvas",
116
+ ["color", "width", "height", "fontSize", "display"],
117
+ );
118
+ if (!Bun.deepEquals(actualGeometry, expectedGeometry, true))
119
+ throw new Error(
120
+ label +
121
+ " geometry mismatch " +
122
+ JSON.stringify({ actualGeometry, expectedGeometry }),
123
+ );
124
+ } finally {
125
+ fresh.dispose();
126
+ }
127
+ }
128
+ try {
129
+ renderer.setScripting(mode);
130
+ renderer.resize(320, 180, 1);
131
+ await renderer.loadDocument({
132
+ url: "https://hidden.test/",
133
+ contentType: "text/html",
134
+ source: `<!doctype html><style id="faces">@font-face{font-family:Probe;src:url(/mono.ttf)}</style><style id="rules">html{font-size:18px}body{margin:0}#visible{width:210px;background:yellow;color:purple;--tone:green}#outer{display:none;color:red;--tone:blue}#nested{display:none;font-size:20px}.child{display:inline-block;background:var(--tone);width:3ch;height:2em;padding:1rem;font-family:Probe,serif}.child::before{content:'x';color:orange}#outer:has(.active) .child{color:teal}@media(max-width:250px){.child{padding:3px}}</style><span style="font-family:Probe">0</span><div id="outer"><section id="nested"><i class="child" id="grand">grand</i></section><section id="chain"><span id="leaf" class="child">leaf</span><canvas id="canvas" width="30" height="20"></canvas><img src="/pixel.png" width="10" height="10"></section></div><div id="visible">visible</div>`,
135
+ });
136
+ await renderer.whenLoaded();
137
+ await renderer.evaluate(
138
+ `window.leaf=document.getElementById('leaf');window.saved=getComputedStyle(leaf);window.keys=Object.keys(saved).join('|')`,
139
+ );
140
+ await state("initial-hidden");
141
+ await state(
142
+ "hidden-values-mutate",
143
+ `document.getElementById('outer').style.cssText='display:none;color:green;--tone:pink';leaf.style.width='5ch';document.getElementById('nested').style.fontSize='31px'`,
144
+ );
145
+ await state("clean-repeat");
146
+ await state(
147
+ "insert",
148
+ `document.getElementById('chain').insertAdjacentHTML('beforeend','<em class="child" id="new" style="width:inherit">new</em>')`,
149
+ );
150
+ await state("selector-change", `leaf.classList.add('active')`);
151
+ await state(
152
+ "new-sheet",
153
+ `document.getElementById('rules').textContent+=' .child{border:2px solid currentColor}'`,
154
+ );
155
+ await state(
156
+ "nested-reveal-hidden",
157
+ `document.getElementById('nested').style.display='block'`,
158
+ );
159
+ await state(
160
+ "outer-reveal",
161
+ `document.getElementById('outer').style.display='block'`,
162
+ );
163
+ await state(
164
+ "hidden-again",
165
+ `document.getElementById('outer').style.display='none'`,
166
+ );
167
+ await state(
168
+ "move-leaf-visible",
169
+ `document.getElementById('visible').append(leaf)`,
170
+ );
171
+ await state("detached", `leaf.remove()`);
172
+ await state(
173
+ "detached-mutate",
174
+ `leaf.style.color='rgb(12,34,56)';leaf.style.width='77px'`,
175
+ );
176
+ await state(
177
+ "reattach-hidden",
178
+ `document.getElementById('chain').append(leaf)`,
179
+ );
180
+ await state("root-rem", `document.documentElement.style.fontSize='22px'`);
181
+ renderer.resize(220, 180, 1);
182
+ await state("viewport");
183
+ await state("remove-font", `document.getElementById('faces').textContent=''`);
184
+ await state(
185
+ "replace-font",
186
+ `document.getElementById('faces').textContent='@font-face{font-family:Probe;src:url(/sans.ttf)}'`,
187
+ );
188
+ await Bun.sleep(0);
189
+ await state("replacement-ready");
190
+ await state(
191
+ "final-reveal",
192
+ `document.getElementById('outer').style.display='block'`,
193
+ );
194
+ assert.deepEqual(errors, []);
195
+ assert.deepEqual(requests.map((x) => new URL(x).pathname).sort(), [
196
+ "/mono.ttf",
197
+ "/pixel.png",
198
+ "/sans.ttf",
199
+ ]);
200
+ console.log(JSON.stringify({ pass: true, states: phases.length }));
201
+ } finally {
202
+ renderer.dispose();
203
+ }
@@ -0,0 +1,69 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createPageRenderer, loadGraphics } from '../../lib/renderer/page-renderer.js';
3
+ import { LayoutEngine } from '../../lib/layout/text-layout.js';
4
+
5
+ const original = LayoutEngine.prototype.layout;
6
+ let layouts = 0;
7
+ LayoutEngine.prototype.layout = function(...args) { layouts++; return original.apply(this, args); };
8
+ const renderer = await createPageRenderer({});
9
+ const { CanvasKit: kit } = await loadGraphics();
10
+ const geometry = `Array.from(document.querySelectorAll('[data-check]'), e => {
11
+ const r=e.getBoundingClientRect(); return [e.id,r.x,r.y,r.width,r.height];
12
+ })`;
13
+ const pixels = async () => {
14
+ const encoded = await renderer.screenshot();
15
+ const image = kit.MakeImageFromEncoded(Buffer.from(encoded, 'base64'));
16
+ try {
17
+ return image.readPixels(0, 0, { width: 320, height: 240, colorType: kit.ColorType.RGBA_8888,
18
+ alphaType: kit.AlphaType.Unpremul, colorSpace: kit.ColorSpace.SRGB });
19
+ } finally { image.delete(); }
20
+ };
21
+
22
+ try {
23
+ renderer.setScripting('host');
24
+ renderer.resize(320, 240, 1);
25
+ await renderer.loadDocument({ url: 'https://repaint.test/', contentType: 'text/html', status: 200,
26
+ source: `<!doctype html><style>
27
+ body { margin:0; background:white; color:#123 }
28
+ #box { position:relative; z-index:2; width:140px; height:76px; overflow:hidden }
29
+ #nested { opacity:.5; background:#ffd; border:1px solid currentColor }
30
+ #behind { position:absolute; z-index:-1; left:5px; top:5px; background:#58a }
31
+ #above { position:absolute; z-index:3; left:30px; top:25px; background:#f80 }
32
+ #gradient { width:160px; background-image:linear-gradient(red,blue); background-clip:text; font-size:22px }
33
+ #wrap { width:100px } #decorated { background:#eee }
34
+ td { padding:3%; border:1px solid currentColor }
35
+ </style><div id="box" data-check>text <span id="nested" data-check>nested</span>
36
+ <span id="behind" data-check>behind</span><span id="above" data-check>above</span></div>
37
+ <div id="gradient" data-check>Gradient text</div>
38
+ <div id="wrap" data-check><span id="decorated" data-check>several wrapped words</span> <span id="plain" data-check>plain</span></div>
39
+ <table style="width:180px"><tr><td id="cell" data-check>table</td><td>other</td></tr></table>` });
40
+ await renderer.whenLoaded();
41
+ await pixels();
42
+
43
+ for (const [id, property, value, expectedLayouts] of [
44
+ ['box', 'opacity', '.45', 0], ['box', 'opacity', '0', 0], ['box', 'opacity', '1', 0],
45
+ ['box', 'backgroundColor', '#ade', 0], ['decorated', 'backgroundColor', '#fea', 0],
46
+ ['cell', 'backgroundColor', '#def', 0], ['gradient', 'opacity', '.6', 0],
47
+ ['gradient', 'backgroundSize', '40px 20px', 0],
48
+ ['plain', 'backgroundColor', '#fbc', 1], ['plain', 'backgroundColor', 'transparent', 1],
49
+ ['box', 'color', '#a30', 1], ['box', 'width', '150px', 1],
50
+ ]) {
51
+ const before = layouts;
52
+ const retainedGeometry = await renderer.evaluate(`(() => {
53
+ document.getElementById(${JSON.stringify(id)}).style[${JSON.stringify(property)}]=${JSON.stringify(value)};
54
+ return ${geometry};
55
+ })()`);
56
+ const label = `${id}.${property}=${value}`;
57
+ assert.equal(layouts - before, expectedLayouts, `${label}: layout calls while reading geometry`);
58
+ const retainedPixels = await pixels();
59
+ // Viewport invalidation forces a complete style/layout pass. Restore the
60
+ // original viewport before comparing every measured rect and raster pixel.
61
+ renderer.resize(321, 240, 1);
62
+ renderer.resize(320, 240, 1);
63
+ assert.deepEqual(await renderer.evaluate(geometry), retainedGeometry, `${label}: geometry`);
64
+ assert.deepEqual(await pixels(), retainedPixels, `${label}: pixels`);
65
+ }
66
+ } finally {
67
+ renderer.dispose();
68
+ LayoutEngine.prototype.layout = original;
69
+ }
@@ -0,0 +1,141 @@
1
+ // Isolated: the host realm installs browser globals in this process.
2
+ import { strict as assert } from 'node:assert';
3
+ import { createPageRenderer } from '../../lib/renderer/page-renderer.js';
4
+ import { createBytePipe } from '../../lib/media/byte-pipe.js';
5
+
6
+ const scenario = process.argv[2], sleep = Bun.sleep, pipe = createBytePipe();
7
+ pipe.append(new Uint8Array(1024));
8
+ const decoders = [], messages = [], errors = [], outputs = [];
9
+ let holdNext = false, finishOpen, changeFormat = false;
10
+ if (scenario === 'epoch') pipe.snapshotAt = time => ({
11
+ bytes: pipe.bytes(), id: pipe.generation > 1 && time >= 2 ? 'b' : 'a',
12
+ startTime: pipe.generation > 1 && time >= 2 ? 2 : 0,
13
+ endTime: pipe.generation > 1 && time < 2 ? 2 : Infinity,
14
+ });
15
+ const renderer = await createPageRenderer({
16
+ onFrame() {}, onVideo: message => messages.push(message),
17
+ fetchResource: async () => ({ ok: true, contentType: 'video/mp4', body: pipe }),
18
+ reportError: error => errors.push(String(error)),
19
+ videoDecoder: {
20
+ probe: async () => ({ duration: 30, video: { width: 20, height: 12 }, audio: { sampleRate: 1000 } }),
21
+ async decode() {
22
+ const record = { seeks: [], frames: 0, at: 0, closed: 0 };
23
+ const decoder = {
24
+ width: changeFormat ? 21 : 20, height: 12, fps: 10,
25
+ seek(ms) { record.seeks.push(ms); record.at = Math.floor(ms / 1000); },
26
+ nextFrame(w, h) {
27
+ record.frames++;
28
+ const bytes = new Uint8Array(w * h * 4).fill(20 + Math.floor(record.at));
29
+ record.at += 0.1;
30
+ return bytes;
31
+ },
32
+ close() { record.closed++; },
33
+ };
34
+ changeFormat = false;
35
+ decoders.push(record);
36
+ if (holdNext) { holdNext = false; await new Promise(resolve => { finishOpen = resolve; }); }
37
+ return decoder;
38
+ },
39
+ decodeAudio: async () => ({ sampleRate: 1000, channels: 1,
40
+ nextSamples: () => new Float32Array(100), close() {} }),
41
+ },
42
+ audio: {
43
+ open(options, listener) { outputs.push({ listener, samples: 0 }); return outputs.length; },
44
+ write(id, bytes) { outputs[id - 1].samples += bytes.length / 2; },
45
+ close(id) { return Promise.resolve(outputs[id - 1].samples * 2); },
46
+ finish() {},
47
+ },
48
+ });
49
+ const typed = type => messages.filter(message => message.type === type);
50
+ const waitFor = async condition => {
51
+ for (let attempt = 0; attempt < 200; attempt++) { if (condition()) return; await sleep(5); }
52
+ assert.fail(`transition timed out (${scenario}): ${condition}; ${JSON.stringify({
53
+ messages: messages.map(({ type, id, revision }) => ({ type, id, revision })), decoders, errors,
54
+ })}`);
55
+ };
56
+ const evaluate = code => renderer.evaluate(code);
57
+ const waiting = () => evaluate('globalThis.waitingEvents');
58
+ try {
59
+ renderer.resize(240, 180, 1);
60
+ renderer.setScripting('host');
61
+ await renderer.loadDocument({ url: 'https://continuation.test/', contentType: 'text/html', source:
62
+ '<style>body{margin:0}video{width:100px;height:60px;display:block}</style><video id=v src="clip.mp4"></video>' });
63
+ await renderer.whenLoaded();
64
+ await evaluate('globalThis.waitingEvents=0;globalThis.v=document.getElementById("v");v.addEventListener("waiting",()=>waitingEvents++);v.play()');
65
+ outputs.at(-1).listener({ event: 'request', bytes: 20000 });
66
+ await waitFor(() => typed('video-open').length === 1);
67
+ const first = typed('video-open')[0], initial = decoders[0], beforeWaiting = await waiting();
68
+ outputs[0].listener({ event: 'timing', seconds: 1.5, sentSeconds: 10, sinkSeconds: 8.5 });
69
+ if (scenario !== 'no-growth') pipe.append(new Uint8Array(1024));
70
+ if (scenario === 'epoch' || scenario === 'no-growth') {
71
+ renderer.videoReport({ id: first.id, revision: 0, exhaustedAt: 2 });
72
+ await sleep(20);
73
+ assert.equal(typed('video-extend').length, 0);
74
+ assert.equal(decoders.length, 1, 'no speculative decoder for unavailable bytes/new epoch');
75
+ renderer.videoReport({ id: first.id, revision: 0, end: 2 });
76
+ await waitFor(() => typed('video-open').length > 1);
77
+ assert.equal(await waiting(), beforeWaiting + 1);
78
+ } else {
79
+ holdNext = true;
80
+ changeFormat = scenario === 'format';
81
+ renderer.videoReport({ id: first.id, revision: 0, exhaustedAt: 2 });
82
+ await waitFor(() => Boolean(finishOpen));
83
+ const staged = decoders.at(-1);
84
+ renderer.videoReport({ id: first.id, revision: 0, end: 2 });
85
+ renderer.videoReport({ id: first.id, revision: 0, exhaustedAt: 2 });
86
+ assert.equal(decoders.length, 2, 'one staged decoder');
87
+ assert.equal(await waiting(), beforeWaiting, 'queued EOF does not dispatch waiting');
88
+ assert.equal(typed('video-stop').length, 0);
89
+ if (scenario === 'pause' || scenario === 'controls' || scenario === 'seek' || scenario === 'source') {
90
+ const action = { pause: 'v.pause()', controls: 'v.setAttribute("controls","");v.style.width="101px"',
91
+ seek: 'v.currentTime=4', source: 'v.src="different.mp4";v.load()' }[scenario];
92
+ await evaluate(action);
93
+ await waitFor(() => typed('video-stop').length > 0);
94
+ finishOpen();
95
+ await waitFor(() => staged.closed === 1);
96
+ assert.equal(typed('video-extend').length, 0, 'canceled source/position/layout never extends');
97
+ assert.equal(staged.closed, 1);
98
+ } else {
99
+ finishOpen();
100
+ if (scenario === 'format') {
101
+ await waitFor(() => typed('video-open').length > 1);
102
+ assert.equal(staged.closed, 1);
103
+ assert.equal(typed('video-extend').length, 0);
104
+ assert.equal(await waiting(), beforeWaiting + 1);
105
+ } else {
106
+ await waitFor(() => typed('video-extend').length === 1);
107
+ const extension = typed('video-extend')[0];
108
+ assert.equal(extension.id, first.id);
109
+ assert.equal(extension.revision, 0);
110
+ assert.equal(extension.boundary, 2);
111
+ assert.equal(extension.generation, 2);
112
+ assert.equal(extension.bytes.length, 2048);
113
+ if (scenario === 'worker-failure') {
114
+ renderer.videoReport({ id: first.id, revision: 0, continuationError: 'decoder rejected snapshot' });
115
+ await waitFor(() => typed('video-open').length > 1);
116
+ assert.equal(staged.closed, 1);
117
+ assert.equal(await waiting(), beforeWaiting + 1);
118
+ } else {
119
+ renderer.videoReport({ id: first.id, revision: 1, continued: 2 });
120
+ assert.equal(initial.closed, 1, 'old lazy decoder released after ACK');
121
+ assert.equal(staged.closed, 0);
122
+ renderer.videoReport({ id: first.id, revision: 0, end: 2 });
123
+ renderer.videoReport({ id: first.id, revision: 0, exhaustedAt: 2 });
124
+ await sleep(20);
125
+ assert.equal(typed('video-open').length, 1);
126
+ assert.equal(typed('video-stop').length, 0);
127
+ assert.equal(await waiting(), beforeWaiting);
128
+ assert.equal(outputs.length, 1, 'same measured audio stream');
129
+ assert.equal(await evaluate('v.currentTime'), 1.5);
130
+ // Local fallback must use the newly opened snapshot, positioned lazily.
131
+ renderer.videoReport({ id: first.id, error: 'simulated worker close' });
132
+ await waitFor(() => staged.frames > 0);
133
+ assert.ok(staged.seeks.length > 0);
134
+ assert.equal(initial.frames, 1);
135
+ }
136
+ }
137
+ }
138
+ }
139
+ assert.deepEqual(errors, []);
140
+ process.stdout.write(`continuation ${scenario} passed\n`);
141
+ } finally { renderer.dispose(); }