buninu-browser 0.2.3 → 0.3.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.
- package/README.md +86 -1
- package/lib/cdp-server.js +47 -14
- package/lib/clipboard.js +22 -0
- package/lib/happy-dom/document-base.js +33 -0
- package/lib/happy-dom/parser.js +15 -6
- package/lib/happy-dom/selector-matcher.js +2 -2
- package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
- package/lib/happy-dom/svg-attribute-adapter.js +50 -0
- package/lib/headless-shell.js +40 -11
- package/lib/input/hit-test.js +67 -19
- package/lib/layout/text-layout.js +213 -91
- package/lib/media/byte-pipe.js +2 -2
- package/lib/media/position-audio.js +10 -1
- package/lib/media/source-snapshots.js +126 -0
- package/lib/media/video-keyframe.js +17 -4
- package/lib/media/webm-stream.js +106 -0
- package/lib/network/network-service.js +268 -120
- package/lib/paint/display-list.js +8 -28
- package/lib/paint/frame-cache.js +25 -0
- package/lib/paint/frame.js +77 -16
- package/lib/paint/paragraph-text.js +24 -0
- package/lib/paint/raster-readback.js +41 -0
- package/lib/paint/raw-frames.js +47 -0
- package/lib/paint/rounded-rect.js +82 -0
- package/lib/paint/shared-frame-pool.js +105 -0
- package/lib/render-tree/index.js +38 -6
- package/lib/render-tree/svg.js +15 -7
- package/lib/renderer/bindings.js +233 -80
- package/lib/renderer/broadcast-channel.js +76 -0
- package/lib/renderer/canvas-2d.js +257 -14
- package/lib/renderer/canvas-text.js +120 -0
- package/lib/renderer/compositor-channel.js +103 -0
- package/lib/renderer/compositor.js +34 -40
- package/lib/renderer/custom-element-reactions.js +25 -1
- package/lib/renderer/flags.js +1 -1
- package/lib/renderer/font-loading.js +227 -0
- package/lib/renderer/host.js +133 -98
- package/lib/renderer/image-resources.js +65 -0
- package/lib/renderer/interfaces.js +28 -0
- package/lib/renderer/layout-observers.js +236 -0
- package/lib/renderer/main.js +52 -5
- package/lib/renderer/media-source.js +126 -55
- package/lib/renderer/page-realm.js +287 -99
- package/lib/renderer/page-renderer.js +663 -239
- package/lib/renderer/prepared-script-cache.js +50 -0
- package/lib/renderer/rendered-text.js +108 -0
- package/lib/renderer/rewrite-worker.js +4 -3
- package/lib/renderer/script-rewrite.js +13 -2
- package/lib/renderer/scroll-into-view.js +28 -0
- package/lib/renderer/slot-assignment.js +60 -0
- package/lib/renderer/video-presentation.js +23 -4
- package/lib/renderer/workers.js +2 -2
- package/lib/style/computed-style.js +611 -104
- package/lib/style/selector-traits.js +49 -5
- package/package.json +1 -1
- package/test/broadcast-channel.test.js +26 -0
- package/test/canvas-2d.test.js +83 -2
- package/test/canvas-paint.test.js +9 -0
- package/test/canvas-text.test.js +41 -0
- package/test/cdp-stream.test.js +56 -0
- package/test/clipboard.test.js +35 -0
- package/test/computed-style-binding.test.js +151 -0
- package/test/computed-style.test.js +258 -1
- package/test/display-list.test.js +12 -0
- package/test/document-base.test.js +87 -0
- package/test/dom-platform.test.js +461 -0
- package/test/fetch-lifecycle.test.js +161 -0
- package/test/fixtures/browser-raw-demo.html +36 -0
- package/test/fixtures/canvas-paint.js +117 -0
- package/test/fixtures/canvas-text.js +42 -0
- package/test/fixtures/image-lifetime.js +90 -0
- package/test/fixtures/media-seek-snapshot.js +77 -0
- package/test/fixtures/mse-audio-chirp.webm +0 -0
- package/test/fixtures/mse-switch-large.mp4 +0 -0
- package/test/fixtures/mse-switch-small.mp4 +0 -0
- package/test/fixtures/raw-capture-benchmark.json +76 -0
- package/test/fixtures/renderer-memory.js +110 -0
- package/test/fixtures/summary-style.js +28 -0
- package/test/font-loading.test.js +97 -0
- package/test/grid.test.js +58 -0
- package/test/hit-test.test.js +21 -1
- package/test/image-lifetime.test.js +12 -0
- package/test/image-resources.test.js +94 -0
- package/test/local-media.test.js +84 -0
- package/test/media-seek-snapshot.test.js +11 -0
- package/test/media-source-config.test.js +98 -0
- package/test/media-source-webm-accounting.test.js +98 -0
- package/test/media-source-webm.test.js +108 -0
- package/test/navigation.test.js +1 -0
- package/test/network-service.test.js +388 -0
- package/test/page-scripts.test.js +1 -1
- package/test/paint-frame.test.js +103 -0
- package/test/paint-paragraph-cache.test.js +87 -0
- package/test/position-audio.test.js +24 -0
- package/test/positioning.test.js +22 -0
- package/test/prepared-script-cache.test.js +45 -0
- package/test/pulse-client.test.js +10 -3
- package/test/raster-readback.test.js +40 -0
- package/test/raw-capture.test.js +152 -0
- package/test/render-tree.test.js +28 -0
- package/test/renderer-memory.test.js +11 -0
- package/test/selector-traits.test.js +24 -0
- package/test/shared-frame-pool.test.js +63 -0
- package/test/style-sheets.test.js +51 -0
- package/test/svg.test.js +29 -0
- package/test/text-layout.test.js +106 -0
- package/test/video-keyframe.test.js +61 -0
- package/test/video-presentation.test.js +69 -0
- package/test/window-resize.test.js +36 -0
- package/tools/bench-raw-capture.js +32 -0
- package/tools/browser-lab.js +78 -0
- package/tools/browser-soak.js +75 -0
- package/tools/network-reference.js +49 -0
- package/tools/process-memory.js +38 -0
- package/tools/script-benchmark.js +25 -0
- package/ai-debug.log +0 -5537
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Browser raw frame demo</title>
|
|
6
|
+
<style>body{margin:0;padding:32px;background:#10252f;color:#e4f4fa;font:22px sans-serif}h1{color:#63dedb}a{color:#73d8ff}.box{padding:20px;margin:16px 0;background:#183c4c;border:2px solid #3b7a8d}.track{height:75px;background:#244451;position:relative;overflow:hidden}#bar{position:absolute;left:0;top:0;width:150px;height:75px;background:#49d6c8}.stripe{height:70px;padding:15px;margin-top:10px}</style>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<h1>Buninu Browser · Raw BGRA</h1>
|
|
10
|
+
<div class="box">這是 buninu-browser 自己繪製的頁面,不是 Firefox 或 Chromium。<br>Raw 像素經 Casty 傳給 bunterm。請試著捲動、點網址列,或瀏覽其他網站。</div>
|
|
11
|
+
<div class="track">
|
|
12
|
+
<div id="bar">
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
<p id="tick">Animation starting…</p>
|
|
16
|
+
<p>
|
|
17
|
+
<a href="https://example.com">example.com</a> · <a href="https://www.wikipedia.org">Wikipedia</a>
|
|
18
|
+
</p>
|
|
19
|
+
<div class="stripe" style="background:rgb(30,55,75)">Scroll test 1 — 中文 ABC 0123456789</div>
|
|
20
|
+
<div class="stripe" style="background:rgb(35,62,80)">Scroll test 2 — 中文 ABC 0123456789</div>
|
|
21
|
+
<div class="stripe" style="background:rgb(40,69,85)">Scroll test 3 — 中文 ABC 0123456789</div>
|
|
22
|
+
<div class="stripe" style="background:rgb(45,76,90)">Scroll test 4 — 中文 ABC 0123456789</div>
|
|
23
|
+
<div class="stripe" style="background:rgb(50,83,95)">Scroll test 5 — 中文 ABC 0123456789</div>
|
|
24
|
+
<div class="stripe" style="background:rgb(55,90,100)">Scroll test 6 — 中文 ABC 0123456789</div>
|
|
25
|
+
<div class="stripe" style="background:rgb(60,97,105)">Scroll test 7 — 中文 ABC 0123456789</div>
|
|
26
|
+
<div class="stripe" style="background:rgb(65,104,110)">Scroll test 8 — 中文 ABC 0123456789</div>
|
|
27
|
+
<div class="stripe" style="background:rgb(70,111,115)">Scroll test 9 — 中文 ABC 0123456789</div>
|
|
28
|
+
<div class="stripe" style="background:rgb(75,118,120)">Scroll test 10 — 中文 ABC 0123456789</div>
|
|
29
|
+
<div class="stripe" style="background:rgb(80,125,125)">Scroll test 11 — 中文 ABC 0123456789</div>
|
|
30
|
+
<div class="stripe" style="background:rgb(85,132,130)">Scroll test 12 — 中文 ABC 0123456789</div>
|
|
31
|
+
<div class="stripe" style="background:rgb(90,139,135)">Scroll test 13 — 中文 ABC 0123456789</div>
|
|
32
|
+
<div class="stripe" style="background:rgb(95,146,140)">Scroll test 14 — 中文 ABC 0123456789</div>
|
|
33
|
+
<div class="stripe" style="background:rgb(100,153,145)">Scroll test 15 — 中文 ABC 0123456789</div>
|
|
34
|
+
<script>let n=0;setInterval(()=>{n++;document.getElementById('bar').style.left=((n*7)%900)+'px';document.getElementById('tick').textContent='Animation tick: '+n;},50);</script>
|
|
35
|
+
</body>
|
|
36
|
+
</html>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { createPageRenderer, loadGraphics } from "../../lib/renderer/page-renderer.js";
|
|
3
|
+
const sleep = Bun.sleep, now = Date.now;
|
|
4
|
+
const { CanvasKit: kit } = await loadGraphics();
|
|
5
|
+
const redPng = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAAA2iEnWAAAAEElEQVR4nGP4z8AARAwoFABE0AX7pM/egAAAAABJRU5ErkJggg==", "base64");
|
|
6
|
+
let frames = 0;
|
|
7
|
+
const corsRequests = [];
|
|
8
|
+
const renderer = await createPageRenderer({ onFrame: () => frames++,
|
|
9
|
+
fetchResource: async url => ({ ok: true, status: 200, url, body: redPng }),
|
|
10
|
+
pageFetch: async request => {
|
|
11
|
+
corsRequests.push(request);
|
|
12
|
+
if (request.url.includes("denied")) throw new TypeError("CORS disallowed");
|
|
13
|
+
return { status: 200, url: request.url, body: new Blob([redPng]).stream() };
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
async function pixel(x = 1, y = 1) {
|
|
17
|
+
const image = kit.MakeImageFromEncoded(Buffer.from(await renderer.screenshot(), "base64"));
|
|
18
|
+
try { return [...image.readPixels(x, y, { width: 1, height: 1, colorType: kit.ColorType.RGBA_8888,
|
|
19
|
+
alphaType: kit.AlphaType.Unpremul, colorSpace: kit.ColorSpace.SRGB })]; }
|
|
20
|
+
finally { image.delete(); }
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
renderer.resize(400, 300, 1);
|
|
24
|
+
renderer.setScripting("host");
|
|
25
|
+
await renderer.loadDocument({ url: "https://canvas.test/", contentType: "text/html",
|
|
26
|
+
source: `<body style="margin:0"><canvas id=c style="display:block">fallback must not appear</canvas><span id=after>after</span><script>
|
|
27
|
+
window.canvas = document.getElementById('c'); window.ctx = canvas.getContext('2d');
|
|
28
|
+
ctx.fillStyle='red';ctx.fillRect(0,0,300,150);
|
|
29
|
+
</script>` });
|
|
30
|
+
await renderer.whenLoaded();
|
|
31
|
+
assert.deepEqual(renderer.inspect("canvas")[0].box, { x: 0, y: 0, width: 300, height: 150 });
|
|
32
|
+
assert.ok(renderer.inspect("#after")[0].box.y >= 150);
|
|
33
|
+
assert.deepEqual(await pixel(), [255, 0, 0, 255]);
|
|
34
|
+
assert.equal(await renderer.evaluate("ctx.canvas === canvas"), true);
|
|
35
|
+
assert.equal(await renderer.evaluate("ctx.Hd === undefined"), true, "native internals are not page APIs");
|
|
36
|
+
assert.equal((await renderer.evaluate("document.body.innerText")).includes("fallback"), false);
|
|
37
|
+
const initial = frames;
|
|
38
|
+
await renderer.evaluate("ctx.fillStyle='blue';ctx.fillRect(0,0,300,150)");
|
|
39
|
+
assert.deepEqual(await pixel(), [0, 0, 255, 255], "drawing alone invalidates paint");
|
|
40
|
+
assert.ok(frames > initial);
|
|
41
|
+
await renderer.evaluate("ctx.clearRect(0,0,300,150)");
|
|
42
|
+
assert.deepEqual(await pixel(), [255, 255, 255, 255], "transparent canvas shows page background");
|
|
43
|
+
await renderer.evaluate("ctx.fillStyle='green';ctx.fillRect(0,0,300,150);canvas.width=100");
|
|
44
|
+
assert.equal(renderer.inspect("canvas")[0].box.width, 100);
|
|
45
|
+
assert.deepEqual(await pixel(), [255, 255, 255, 255], "width reset clears displayed bitmap");
|
|
46
|
+
await renderer.evaluate("canvas.style.width='200px';canvas.style.height='100px';ctx.fillStyle='red';ctx.fillRect(0,0,50,150)");
|
|
47
|
+
assert.deepEqual(await pixel(99, 50), [255, 0, 0, 255]);
|
|
48
|
+
assert.deepEqual(await pixel(101, 50), [255, 255, 255, 255]);
|
|
49
|
+
await renderer.evaluate("canvas.remove()");
|
|
50
|
+
await renderer.evaluate("ctx.fillStyle='blue';ctx.fillRect(0,0,100,150);document.body.prepend(canvas)");
|
|
51
|
+
assert.deepEqual(await pixel(1, 1), [0, 0, 255, 255], "detached canvases keep their bitmap when reattached");
|
|
52
|
+
const exported = await renderer.evaluate("canvas.toDataURL('image/png')");
|
|
53
|
+
assert.deepEqual(await new Bun.Image(Buffer.from(exported.split(",")[1], "base64")).metadata(), { width: 100, height: 150, format: "png" });
|
|
54
|
+
assert.ok((await renderer.evaluate("canvas.toDataURL('unsupported/type')")).startsWith("data:image/png;base64,"));
|
|
55
|
+
assert.deepEqual(await renderer.evaluate("new Promise(resolve => canvas.toBlob(blob => resolve([blob.type.startsWith('image/'), blob.size > 0]), 'image/jpeg'))"), [true, true]);
|
|
56
|
+
await renderer.evaluate("window.copy=document.createElement('canvas');copy.width=copy.height=10;window.copyCtx=copy.getContext('2d');copyCtx.drawImage(canvas,0,0,10,10)");
|
|
57
|
+
assert.deepEqual(await renderer.evaluate("[...copyCtx.getImageData(1,1,1,1).data]"), [0, 0, 255, 255]);
|
|
58
|
+
await renderer.evaluate("window.img=document.createElement('img');img.style.display='none';img.src='https://foreign.test/red.png';document.body.appendChild(img)");
|
|
59
|
+
const deadline = now() + 5000;
|
|
60
|
+
while (!await renderer.evaluate("img.complete && img.naturalWidth === 2")) { assert.ok(now() < deadline); await sleep(10); }
|
|
61
|
+
await renderer.evaluate("ctx.drawImage(img,0,0,100,150)");
|
|
62
|
+
assert.deepEqual(await pixel(), [255, 0, 0, 255], "cross-origin images can be drawn");
|
|
63
|
+
assert.deepEqual(await renderer.evaluate("[()=>ctx.getImageData(0,0,1,1),()=>canvas.toDataURL(),()=>canvas.toBlob(()=>{})].map(fn=>{try{fn();return 'allowed'}catch(error){return error.name}})"), ["SecurityError", "SecurityError", "SecurityError"]);
|
|
64
|
+
await renderer.evaluate("copyCtx.drawImage(canvas,0,0,10,10)");
|
|
65
|
+
assert.equal(await renderer.evaluate("(()=>{try{copy.toDataURL();return 'allowed'}catch(error){return error.name}})()"), "SecurityError", "taint follows canvas copies");
|
|
66
|
+
await renderer.evaluate("canvas.width=100");
|
|
67
|
+
assert.ok((await renderer.evaluate("canvas.toDataURL()")).startsWith("data:image/png;base64,"), "bitmap reset restores origin-clean state");
|
|
68
|
+
await renderer.evaluate("img.crossOrigin='anonymous'");
|
|
69
|
+
while (!await renderer.evaluate("img.complete && img.naturalWidth === 2")) { assert.ok(now() < deadline); await sleep(10); }
|
|
70
|
+
assert.equal(corsRequests.length, 1, "changing crossorigin refetches with the CORS policy");
|
|
71
|
+
assert.equal(corsRequests[0].mode, "cors");
|
|
72
|
+
assert.equal(corsRequests[0].credentials, "same-origin");
|
|
73
|
+
await renderer.evaluate("ctx.drawImage(img,0,0,100,150)");
|
|
74
|
+
assert.deepEqual(await renderer.evaluate("[...ctx.getImageData(1,1,1,1).data]"), [255, 0, 0, 255], "CORS-approved foreign images keep the canvas clean");
|
|
75
|
+
await renderer.evaluate("img.crossOrigin='use-credentials'");
|
|
76
|
+
while (!await renderer.evaluate("img.complete && img.naturalWidth === 2")) { assert.ok(now() < deadline); await sleep(10); }
|
|
77
|
+
assert.equal(corsRequests.at(-1).credentials, "include");
|
|
78
|
+
await renderer.evaluate("img.src='https://foreign.test/denied.png'");
|
|
79
|
+
while (!await renderer.evaluate("img.complete")) { assert.ok(now() < deadline); await sleep(10); }
|
|
80
|
+
assert.equal(await renderer.evaluate("img.naturalWidth"), 0, "CORS-denied images cannot fall back to an unfiltered response");
|
|
81
|
+
await renderer.evaluate("canvas.width=0");
|
|
82
|
+
assert.equal(await renderer.evaluate("canvas.toDataURL()"), "data:,");
|
|
83
|
+
await renderer.evaluate("canvas.style.width='';canvas.style.height=''");
|
|
84
|
+
assert.deepEqual(renderer.inspect("canvas")[0].box, { x: 0, y: 0, width: 0, height: 150 });
|
|
85
|
+
assert.deepEqual(await renderer.evaluate(`(() => {
|
|
86
|
+
const c = document.createElement('canvas'), ctx = c.getContext('2d');
|
|
87
|
+
const fill = () => { ctx.fillStyle = 'red'; ctx.fillRect(0,0,1,1); };
|
|
88
|
+
const red = () => ctx.getImageData(0,0,1,1).data[0];
|
|
89
|
+
const results = [];
|
|
90
|
+
c.setAttribute('width', '20'); fill(); c.setAttribute('WIDTH', '20'); results.push(red());
|
|
91
|
+
fill(); c.setAttributeNS('urn:test', 'test:width', '7'); results.push(red(), c.width);
|
|
92
|
+
c.setAttributeNS(null, 'width', '30'); results.push(red(), c.width);
|
|
93
|
+
fill(); c.removeAttributeNS(null, 'width'); results.push(red(), c.width);
|
|
94
|
+
c.setAttribute('height', '10'); fill(); c.removeAttribute('height'); results.push(red(), c.height);
|
|
95
|
+
fill(); c.toggleAttribute('width', false); results.push(red());
|
|
96
|
+
c.toggleAttribute('width', true); results.push(red());
|
|
97
|
+
c.setAttribute('width','10'); fill(); c.getAttributeNode('width').value='10'; results.push(red());
|
|
98
|
+
const attr = c.getAttributeNode('width');
|
|
99
|
+
fill(); c.removeAttribute('width'); results.push(red(), c.width);
|
|
100
|
+
fill(); attr.value='15'; results.push(red(), c.width);
|
|
101
|
+
return results;
|
|
102
|
+
})()`), [0, 255, 20, 0, 30, 0, 300, 0, 150, 255, 0, 0, 0, 300, 255, 300], "all size-attribute APIs reset the bitmap; foreign/detached attributes and no-op removals do not");
|
|
103
|
+
await renderer.loadDocument({ url: "https://canvas.test/next", contentType: "text/html", source: "<body style='margin:0'>next</body>" });
|
|
104
|
+
await renderer.whenLoaded();
|
|
105
|
+
// Prepaint and animation are independent timers. A canvas draw retires its
|
|
106
|
+
// old native snapshot while a larger scrolled tile may still be queued.
|
|
107
|
+
await renderer.loadDocument({ url: "https://canvas.test/animation", contentType: "text/html",
|
|
108
|
+
source: `<body style="margin:0;height:5000px"><canvas id=a width=100 height=100 style="position:fixed;top:0;left:0"></canvas><script>
|
|
109
|
+
const ctx=document.getElementById('a').getContext('2d');let tick=0;
|
|
110
|
+
window.animation=setInterval(()=>{ctx.fillStyle=++tick%2?'red':'blue';ctx.fillRect(0,0,100,100)},17);
|
|
111
|
+
</script>` });
|
|
112
|
+
await renderer.whenLoaded();
|
|
113
|
+
for (let i = 0; i < 20; i++) { renderer.scroll(0, i % 2 ? -110 : 110); await sleep(31); }
|
|
114
|
+
await renderer.evaluate("clearInterval(animation)");
|
|
115
|
+
const animated = await pixel(10, 10);
|
|
116
|
+
assert.ok(animated[0] === 255 || animated[2] === 255);
|
|
117
|
+
} finally { renderer.dispose(); }
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
|
|
3
|
+
const renderer = await createPageRenderer({});
|
|
4
|
+
try {
|
|
5
|
+
renderer.resize(500, 200, 1);
|
|
6
|
+
renderer.setScripting("host");
|
|
7
|
+
await renderer.loadDocument({ url: "https://text.test/", contentType: "text/html",
|
|
8
|
+
source: `<body style="margin:0"><canvas width=500 height=200 id=c></canvas><script>
|
|
9
|
+
window.c=document.getElementById('c');window.ctx=c.getContext('2d');
|
|
10
|
+
window.ink=()=>{const data=ctx.getImageData(0,0,500,200).data;let left=500,right=-1,top=200,bottom=-1;
|
|
11
|
+
for(let y=0;y<200;y++)for(let x=0;x<500;x++)if(data[(y*500+x)*4+3]){left=Math.min(left,x);right=Math.max(right,x);top=Math.min(top,y);bottom=Math.max(bottom,y)}
|
|
12
|
+
return {left,right,top,bottom};};
|
|
13
|
+
</script>` });
|
|
14
|
+
await renderer.whenLoaded();
|
|
15
|
+
assert.equal(await renderer.evaluate("ctx.font"), "10px sans-serif");
|
|
16
|
+
await renderer.evaluate("ctx.font='32px sans-serif';window.width=ctx.measureText('Canvas text').width;ctx.fillText('Canvas text',10,50)");
|
|
17
|
+
assert.ok(await renderer.evaluate("width > 150 && width < 190"));
|
|
18
|
+
const left = await renderer.evaluate("ink()");
|
|
19
|
+
assert.ok(left.left >= 10 && left.right > 160 && left.bottom <= 51);
|
|
20
|
+
await renderer.evaluate("ctx.clearRect(0,0,500,200);ctx.textAlign='right';ctx.fillText('Canvas text',300,50)");
|
|
21
|
+
const right = await renderer.evaluate("ink()");
|
|
22
|
+
assert.ok(right.right >= 295 && right.right <= 300);
|
|
23
|
+
assert.ok(right.left > 125 && right.left < 150);
|
|
24
|
+
await renderer.evaluate("ctx.clearRect(0,0,500,200);ctx.textAlign='left';ctx.fillText('Canvas text',10,50,80)");
|
|
25
|
+
const scaled = await renderer.evaluate("ink()");
|
|
26
|
+
assert.ok(scaled.right <= 90 && scaled.right >= 85);
|
|
27
|
+
await renderer.evaluate("ctx.save();ctx.font='48px serif';ctx.textAlign='center';ctx.textBaseline='top';ctx.direction='rtl';ctx.restore()");
|
|
28
|
+
assert.deepEqual(await renderer.evaluate("[ctx.font,ctx.textAlign,ctx.textBaseline,ctx.direction]"), ["32px sans-serif", "left", "alphabetic", "inherit"]);
|
|
29
|
+
await renderer.evaluate("ctx.font='not a font';ctx.textAlign='invalid'");
|
|
30
|
+
assert.deepEqual(await renderer.evaluate("[ctx.font,ctx.textAlign]"), ["32px sans-serif", "left"]);
|
|
31
|
+
await renderer.evaluate("ctx.translate(12,18);ctx.scale(2,3)");
|
|
32
|
+
assert.deepEqual(await renderer.evaluate("const m=ctx.getTransform();[m.a,m.b,m.c,m.d,m.e,m.f]"), [2,0,0,3,12,18]);
|
|
33
|
+
await renderer.evaluate("c.width=500");
|
|
34
|
+
assert.deepEqual(await renderer.evaluate("[ctx.font,ctx.textAlign,ctx.textBaseline,ctx.direction]"), ["10px sans-serif", "start", "alphabetic", "inherit"]);
|
|
35
|
+
await renderer.evaluate("ctx.font='32px sans-serif';ctx.fillText('測試 🙂',10,50)");
|
|
36
|
+
assert.ok((await renderer.evaluate("ink()")).right > 60, "Unicode text uses browser fallback fonts");
|
|
37
|
+
await renderer.evaluate(`ctx.clearRect(0,0,500,200);window.gradient=ctx.createLinearGradient(0,0,150,0);
|
|
38
|
+
gradient.addColorStop(0,'red');gradient.addColorStop(1,'blue');ctx.strokeStyle=gradient;ctx.lineWidth=2;
|
|
39
|
+
ctx.strokeText('Gradient',10,50)`);
|
|
40
|
+
assert.ok((await renderer.evaluate("ink()")).right > 100, "shaped strokeText accepts a branded gradient without reading the native color-only getter");
|
|
41
|
+
assert.equal(await renderer.evaluate("ctx.strokeStyle===gradient && gradient.Td===undefined && gradient.pe===undefined"), true);
|
|
42
|
+
} finally { renderer.dispose(); }
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Host scripting is isolated from the test runner's globals.
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import { createPageRenderer, loadGraphics } from "../../lib/renderer/page-renderer.js";
|
|
4
|
+
import { encodeImage } from "../../lib/paint/frame.js";
|
|
5
|
+
const sleep = Bun.sleep;
|
|
6
|
+
const now = Date.now;
|
|
7
|
+
const { CanvasKit } = await loadGraphics();
|
|
8
|
+
const images = new Map();
|
|
9
|
+
for (const [name, color] of [["red", [255, 0, 0, 255]], ["blue", [0, 0, 255, 255]]]) {
|
|
10
|
+
images.set(name, await encodeImage(new Uint8Array(Array.from({ length: 16 }, () => color).flat()), 4, 4));
|
|
11
|
+
}
|
|
12
|
+
const makeImage = CanvasKit.MakeImageFromEncoded;
|
|
13
|
+
let created = 0, alive = 0, frames = 0, fetched = 0;
|
|
14
|
+
const instrument = (image) => {
|
|
15
|
+
if (!image) return image;
|
|
16
|
+
created++; alive++;
|
|
17
|
+
const remove = image.delete.bind(image), clone = image.clone.bind(image);
|
|
18
|
+
image.delete = () => { alive--; remove(); };
|
|
19
|
+
image.clone = () => instrument(clone());
|
|
20
|
+
return image;
|
|
21
|
+
};
|
|
22
|
+
CanvasKit.MakeImageFromEncoded = bytes => instrument(makeImage(bytes));
|
|
23
|
+
let releaseLate;
|
|
24
|
+
const renderer = await createPageRenderer({
|
|
25
|
+
onFrame: () => { frames++; },
|
|
26
|
+
fetchResource: async url => {
|
|
27
|
+
fetched++;
|
|
28
|
+
if (url.includes("late")) await new Promise(resolve => { releaseLate = resolve; });
|
|
29
|
+
return { ok: true, status: 200, url, body: images.get(url.includes("blue") ? "blue" : "red") };
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
async function until(check) {
|
|
33
|
+
const end = now() + 5000;
|
|
34
|
+
while (!(await check())) { assert.ok(now() < end, "condition timed out"); await sleep(10); }
|
|
35
|
+
}
|
|
36
|
+
async function pixel() {
|
|
37
|
+
const image = makeImage(Buffer.from(await renderer.screenshot(), "base64"));
|
|
38
|
+
try { return [...image.readPixels(1, 1, { width: 1, height: 1, colorType: CanvasKit.ColorType.RGBA_8888,
|
|
39
|
+
alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB })]; }
|
|
40
|
+
finally { image.delete(); }
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
renderer.resize(400, 300, 1);
|
|
44
|
+
renderer.setScripting("host");
|
|
45
|
+
await renderer.loadDocument({ url: "https://images.test/", contentType: "text/html", source: '<body style="margin:0"></body>' });
|
|
46
|
+
await renderer.whenLoaded();
|
|
47
|
+
frames = 0;
|
|
48
|
+
await renderer.evaluate(`window.saved = []; for (let i=0;i<100;i++) {
|
|
49
|
+
const image=document.createElement('img'); image.width=image.height=20; image.src='/red.png';
|
|
50
|
+
document.body.appendChild(image); saved.push(image);
|
|
51
|
+
}`);
|
|
52
|
+
await until(async () => await renderer.evaluate("saved.every(image => image.complete && image.naturalWidth === 4)"));
|
|
53
|
+
await sleep(150);
|
|
54
|
+
assert.equal(fetched, 1, "one network/IPC request for duplicate in-flight images");
|
|
55
|
+
assert.equal(alive, 100, "independently-owned handles share one underlying image");
|
|
56
|
+
assert.ok(frames < 10, `one arrival burst must not paint 100 frames: ${frames}`);
|
|
57
|
+
assert.deepEqual(await pixel(), [255, 0, 0, 255]);
|
|
58
|
+
await renderer.evaluate("saved[0].src='/blue.png'");
|
|
59
|
+
await until(async () => await renderer.evaluate("saved[0].complete"));
|
|
60
|
+
assert.deepEqual(await pixel(), [0, 0, 255, 255], "same-size replacement invalidates the raster cache");
|
|
61
|
+
await renderer.evaluate("document.body.replaceChildren()");
|
|
62
|
+
assert.equal(alive, 0, "removed images release native handles even when script retains DOM nodes");
|
|
63
|
+
await renderer.evaluate("document.body.appendChild(saved[0])");
|
|
64
|
+
await until(async () => await renderer.evaluate("saved[0].complete"));
|
|
65
|
+
assert.equal(alive, 1, "reattaching a removed image reloads its resource");
|
|
66
|
+
assert.deepEqual(await pixel(), [0, 0, 255, 255]);
|
|
67
|
+
await renderer.evaluate("document.body.replaceChildren(); document.body.style.height='100px'");
|
|
68
|
+
for (let i=0;i<8;i++) {
|
|
69
|
+
await renderer.evaluate(`document.body.style.backgroundImage='url(/red-bg-${i}.png)'`);
|
|
70
|
+
await until(() => alive === 1);
|
|
71
|
+
await renderer.screenshot();
|
|
72
|
+
assert.equal(alive, 1, "unused CSS background images are released");
|
|
73
|
+
}
|
|
74
|
+
await renderer.evaluate("document.body.style.backgroundImage='none'; saved[0].src='/late.png'; document.body.appendChild(saved[0])");
|
|
75
|
+
await until(() => Boolean(releaseLate));
|
|
76
|
+
renderer.dispose();
|
|
77
|
+
const stopped = frames;
|
|
78
|
+
const beforeLate = created;
|
|
79
|
+
releaseLate();
|
|
80
|
+
await until(() => created > beforeLate);
|
|
81
|
+
await sleep(50);
|
|
82
|
+
assert.equal(alive, 0, "late resources cannot revive a disposed renderer");
|
|
83
|
+
assert.equal(frames, stopped);
|
|
84
|
+
renderer.dispose();
|
|
85
|
+
process.stdout.write("image lifetime integration passed\n");
|
|
86
|
+
} finally {
|
|
87
|
+
releaseLate?.();
|
|
88
|
+
renderer.dispose();
|
|
89
|
+
CanvasKit.MakeImageFromEncoded = makeImage;
|
|
90
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { strict as assert } from 'node:assert';
|
|
2
|
+
import { createPageRenderer } from '../../lib/renderer/page-renderer.js';
|
|
3
|
+
import { createBytePipe } from '../../lib/media/byte-pipe.js';
|
|
4
|
+
import wasmpeg from '../../vendor/wasmpeg/src/js/index.js';
|
|
5
|
+
|
|
6
|
+
// Isolated because host scripting installs page globals in this process.
|
|
7
|
+
const sleep = Bun.sleep;
|
|
8
|
+
const bytes = await Bun.file(new URL('./mse-switch-small.mp4', import.meta.url)).bytes();
|
|
9
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
10
|
+
let prefixEnd = 0;
|
|
11
|
+
for (let offset = 0; offset + 8 <= bytes.length;) {
|
|
12
|
+
const size = view.getUint32(offset);
|
|
13
|
+
assert.ok(size >= 8 && offset + size <= bytes.length);
|
|
14
|
+
const type = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8));
|
|
15
|
+
if (type === 'mdat') { prefixEnd = offset + size; break; }
|
|
16
|
+
offset += size;
|
|
17
|
+
}
|
|
18
|
+
assert.ok(prefixEnd > 0 && prefixEnd < bytes.length);
|
|
19
|
+
await wasmpeg.load();
|
|
20
|
+
const expected = await wasmpeg.decode(bytes, { format: 'mp4' });
|
|
21
|
+
let expectedHash;
|
|
22
|
+
try {
|
|
23
|
+
expected.seek(4000);
|
|
24
|
+
expectedHash = Bun.hash(expected.nextFrame(64, 48));
|
|
25
|
+
} finally { expected.close(); }
|
|
26
|
+
|
|
27
|
+
const pipe = createBytePipe();
|
|
28
|
+
pipe.append(bytes.subarray(0, prefixEnd));
|
|
29
|
+
const openedSizes = [], drawnHashes = [], errors = [];
|
|
30
|
+
const renderer = await createPageRenderer({
|
|
31
|
+
fetchResource: async () => ({ ok: true, contentType: 'video/mp4', body: pipe }),
|
|
32
|
+
reportError: error => errors.push(String(error)),
|
|
33
|
+
videoDecoder: {
|
|
34
|
+
probe: (...args) => wasmpeg.probe(...args),
|
|
35
|
+
async decode(snapshot, options) {
|
|
36
|
+
openedSizes.push(snapshot.byteLength);
|
|
37
|
+
const decoder = await wasmpeg.decode(snapshot, options);
|
|
38
|
+
const nextFrame = decoder.nextFrame.bind(decoder);
|
|
39
|
+
decoder.nextFrame = (width, height) => {
|
|
40
|
+
const pixels = nextFrame(width, height);
|
|
41
|
+
// Seek probes are 32x18; these are frames actually drawn by the page.
|
|
42
|
+
if (pixels && width === 64 && height === 48) drawnHashes.push(Bun.hash(pixels));
|
|
43
|
+
return pixels;
|
|
44
|
+
};
|
|
45
|
+
return decoder;
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
try {
|
|
50
|
+
renderer.setScripting('host');
|
|
51
|
+
await renderer.loadDocument({ url: 'https://seek.test/', contentType: 'text/html',
|
|
52
|
+
source: '<video id="v" width="64" height="48" src="clip.mp4"></video>' });
|
|
53
|
+
const seek = seconds => renderer.evaluate(`new Promise(resolve => {
|
|
54
|
+
const video = document.getElementById('v');
|
|
55
|
+
video.addEventListener('seeked', () => resolve(true), { once: true });
|
|
56
|
+
video.currentTime = ${seconds};
|
|
57
|
+
})`);
|
|
58
|
+
await seek(0);
|
|
59
|
+
assert.equal(openedSizes.at(-1), prefixEnd);
|
|
60
|
+
assert.notEqual(drawnHashes.at(-1), expectedHash);
|
|
61
|
+
const firstHash = drawnHashes.at(-1);
|
|
62
|
+
const oldOpens = openedSizes.length;
|
|
63
|
+
pipe.append(bytes.subarray(prefixEnd));
|
|
64
|
+
pipe.finish();
|
|
65
|
+
for (let attempt = 0; attempt < 100 && await renderer.evaluate("document.getElementById('v').duration") < 6; attempt++) await sleep(10);
|
|
66
|
+
assert.equal(await renderer.evaluate("document.getElementById('v').duration"), 6);
|
|
67
|
+
await seek(5);
|
|
68
|
+
assert.ok(openedSizes.length > oldOpens, 'seeking beyond an old prefix opens the latest snapshot');
|
|
69
|
+
assert.equal(openedSizes.at(-1), bytes.length);
|
|
70
|
+
assert.equal(drawnHashes.at(-1), expectedHash, 'the seek draws the target keyframe, not the stale prefix');
|
|
71
|
+
const currentOpens = openedSizes.length;
|
|
72
|
+
await seek(1);
|
|
73
|
+
assert.equal(openedSizes.length, currentOpens, 'a current snapshot remains reusable for backward seeking');
|
|
74
|
+
assert.equal(drawnHashes.at(-1), firstHash);
|
|
75
|
+
assert.deepEqual(errors, []);
|
|
76
|
+
process.stdout.write('latest video snapshot seek passed\n');
|
|
77
|
+
} finally { renderer.dispose(); }
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"sandbox": {
|
|
3
|
+
"seccomp": {
|
|
4
|
+
"installed": true,
|
|
5
|
+
"reason": null
|
|
6
|
+
},
|
|
7
|
+
"ses": {
|
|
8
|
+
"installed": true,
|
|
9
|
+
"reason": null
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"capture": [
|
|
13
|
+
1920,
|
|
14
|
+
1035
|
|
15
|
+
],
|
|
16
|
+
"reports": [
|
|
17
|
+
{
|
|
18
|
+
"format": "png",
|
|
19
|
+
"count": 20,
|
|
20
|
+
"meanMs": 168.16266580000007,
|
|
21
|
+
"medianMs": 167.81952999999976,
|
|
22
|
+
"bytes": 123889
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"format": "jpeg",
|
|
26
|
+
"count": 20,
|
|
27
|
+
"meanMs": 42.5512477499999,
|
|
28
|
+
"medianMs": 41.08310699999947,
|
|
29
|
+
"bytes": 150292
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"format": "rgba",
|
|
33
|
+
"count": 20,
|
|
34
|
+
"meanMs": 22.61524315000006,
|
|
35
|
+
"medianMs": 23.11667800000032,
|
|
36
|
+
"bytes": 7948800
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"format": "bgra",
|
|
40
|
+
"count": 20,
|
|
41
|
+
"meanMs": 25.103456849999976,
|
|
42
|
+
"medianMs": 25.162263999999595,
|
|
43
|
+
"bytes": 7948800
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"format": "bgra",
|
|
47
|
+
"count": 20,
|
|
48
|
+
"meanMs": 24.67374660000005,
|
|
49
|
+
"medianMs": 25.82330600000023,
|
|
50
|
+
"bytes": 7948800
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"format": "rgba",
|
|
54
|
+
"count": 20,
|
|
55
|
+
"meanMs": 21.321299350000118,
|
|
56
|
+
"medianMs": 21.798502999999982,
|
|
57
|
+
"bytes": 7948800
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"format": "jpeg",
|
|
61
|
+
"count": 20,
|
|
62
|
+
"meanMs": 43.68695314999977,
|
|
63
|
+
"medianMs": 46.23293099999864,
|
|
64
|
+
"bytes": 150292
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"format": "png",
|
|
68
|
+
"count": 20,
|
|
69
|
+
"meanMs": 169.70291704999983,
|
|
70
|
+
"medianMs": 169.88830599999892,
|
|
71
|
+
"bytes": 123889
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
"method": "Native Intel i5-5200U, alternating viewport crops, three warmups then 20 captures per pass; round trip includes controller file write for raw. Not terminal FPS.",
|
|
75
|
+
"date": "2026-10-22"
|
|
76
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { strict as assert } from 'node:assert';
|
|
2
|
+
import '../../vendor/ses/dist/ses.mjs';
|
|
3
|
+
import { createPageRenderer, loadGraphics } from '../../lib/renderer/page-renderer.js';
|
|
4
|
+
|
|
5
|
+
// Use the real WASM allocator: MakeSurface.delete() does not free its pixels.
|
|
6
|
+
// Each document needs an isolated realm: host scripting overwrites process
|
|
7
|
+
// globals and cannot be repeatedly installed for this lifetime stress test.
|
|
8
|
+
const { CanvasKit: kit } = await loadGraphics();
|
|
9
|
+
lockdown({ errorTaming: 'safe', overrideTaming: 'severe', consoleTaming: 'unsafe',
|
|
10
|
+
localeTaming: 'unsafe', stackFiltering: 'concise', reporting: 'none' });
|
|
11
|
+
const malloc = kit._malloc, free = kit._free, makeSurface = kit.MakeSurface;
|
|
12
|
+
const allocations = new Map(), surfaces = new Set(), images = new Set();
|
|
13
|
+
const errors = [];
|
|
14
|
+
let reads = 0, failAt = Infinity;
|
|
15
|
+
kit._malloc = size => { const pointer = malloc(size); allocations.set(pointer, size); return pointer; };
|
|
16
|
+
kit._free = pointer => { allocations.delete(pointer); return free(pointer); };
|
|
17
|
+
kit.MakeSurface = (...args) => {
|
|
18
|
+
const surface = makeSurface(...args);
|
|
19
|
+
if (!surface) return surface;
|
|
20
|
+
surfaces.add(surface);
|
|
21
|
+
const dispose = surface.dispose.bind(surface), snapshot = surface.makeImageSnapshot.bind(surface);
|
|
22
|
+
surface.dispose = () => { surfaces.delete(surface); return dispose(); };
|
|
23
|
+
surface.makeImageSnapshot = (...args) => {
|
|
24
|
+
const image = snapshot(...args), release = image.delete.bind(image), read = image.readPixels.bind(image);
|
|
25
|
+
images.add(image);
|
|
26
|
+
image.delete = () => { images.delete(image); return release(); };
|
|
27
|
+
image.readPixels = (...args) => {
|
|
28
|
+
if (++reads === failAt) throw new Error('intentional readback failure');
|
|
29
|
+
return read(...args);
|
|
30
|
+
};
|
|
31
|
+
return image;
|
|
32
|
+
};
|
|
33
|
+
return surface;
|
|
34
|
+
};
|
|
35
|
+
const liveBytes = () => [...allocations.values()].reduce((sum, size) => sum + size, 0);
|
|
36
|
+
let warmBytes;
|
|
37
|
+
try {
|
|
38
|
+
for (let cycle = 0; cycle < 8; cycle++) {
|
|
39
|
+
const renderer = await createPageRenderer({
|
|
40
|
+
fetchResource: async () => ({ ok: false, status: 404, body: new Uint8Array() }),
|
|
41
|
+
reportError: error => errors.push(String(error)),
|
|
42
|
+
});
|
|
43
|
+
try {
|
|
44
|
+
renderer.setScripting('ses');
|
|
45
|
+
renderer.resize(400, 300, 1);
|
|
46
|
+
await renderer.loadDocument({ url: 'https://memory.test/', contentType: 'text/html', source:
|
|
47
|
+
`<canvas id="c" width="200" height="150"></canvas><p>Renderer lifecycle</p><script>
|
|
48
|
+
window.c=document.getElementById('c');window.ctx=c.getContext('2d');
|
|
49
|
+
ctx.fillStyle='blue';ctx.fillRect(0,0,200,150);
|
|
50
|
+
</script>` });
|
|
51
|
+
await renderer.whenLoaded();
|
|
52
|
+
assert.equal(await renderer.evaluate("[...ctx.getImageData(1,1,1,1).data].join(',')"), '0,0,255,255');
|
|
53
|
+
for (let step = 0; step < 6; step++) {
|
|
54
|
+
renderer.resize(step % 2 ? 400 : 640, step % 2 ? 300 : 480, 1);
|
|
55
|
+
await renderer.evaluate(`c.width=${200 + step};ctx.fillStyle='red';ctx.fillRect(0,0,200,150)`);
|
|
56
|
+
assert.equal(await renderer.evaluate("[...ctx.getImageData(1,1,1,1).data].join(',')"), '255,0,0,255');
|
|
57
|
+
await renderer.screenshot({ format: step % 2 ? 'jpeg' : 'png' });
|
|
58
|
+
}
|
|
59
|
+
await renderer.loadDocument({ url: 'https://memory.test/next', contentType: 'text/html', source: '<p>Next document</p>' });
|
|
60
|
+
await renderer.whenLoaded();
|
|
61
|
+
await renderer.screenshot();
|
|
62
|
+
} finally { renderer.dispose(); }
|
|
63
|
+
assert.deepEqual(errors, [], 'every document actually executes its canvas script');
|
|
64
|
+
assert.equal(surfaces.size, 0, 'all software surface pixels are disposed');
|
|
65
|
+
assert.equal(images.size, 0, 'all snapshots are deleted');
|
|
66
|
+
warmBytes ??= liveBytes();
|
|
67
|
+
assert.equal(liveBytes(), warmBytes, 'native allocations do not accumulate across renderer lifetimes');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const presentations = [];
|
|
71
|
+
const renderer = await createPageRenderer({
|
|
72
|
+
fetchResource: async () => ({ ok: true, status: 200, body: new Uint8Array([1]) }),
|
|
73
|
+
reportError: error => errors.push(String(error)),
|
|
74
|
+
videoDecoder: {
|
|
75
|
+
probe: async () => ({ duration: 20, video: { width: 2, height: 2 } }),
|
|
76
|
+
decode: async () => ({ width: 2, height: 2, fps: 10, seek() {}, close() {}, nextFrame: () => new Uint8Array(16).fill(255) }),
|
|
77
|
+
},
|
|
78
|
+
onVideo(message) { presentations.push(message.type); },
|
|
79
|
+
});
|
|
80
|
+
try {
|
|
81
|
+
renderer.setScripting('ses');
|
|
82
|
+
await renderer.loadDocument({ url: 'https://memory.test/video', contentType: 'text/html',
|
|
83
|
+
source: '<video id="v" width="200" height="150" src="clip.mp4"></video>' });
|
|
84
|
+
await renderer.whenLoaded();
|
|
85
|
+
await renderer.evaluate("document.getElementById('v').play()");
|
|
86
|
+
for (let attempt = 0; attempt < 50 && !presentations.includes('video-open'); attempt++) {
|
|
87
|
+
await Bun.sleep(10);
|
|
88
|
+
await renderer.screenshot();
|
|
89
|
+
}
|
|
90
|
+
assert.ok(presentations.includes('video-open'), 'video uses the separate presentation layer');
|
|
91
|
+
assert.deepEqual(errors, []);
|
|
92
|
+
await renderer.screenshot();
|
|
93
|
+
// First read is the normal frame; second is the video overlay's base.
|
|
94
|
+
failAt = reads + 2;
|
|
95
|
+
assert.throws(() => renderer.resize(641, 481), /intentional readback failure/);
|
|
96
|
+
assert.equal(reads, failAt, 'the video overlay readback was exercised');
|
|
97
|
+
if (images.size !== 0) throw new Error(`failed overlay readback left ${images.size} snapshot(s) alive`);
|
|
98
|
+
} finally { failAt = Infinity; renderer.dispose(); }
|
|
99
|
+
assert.equal(surfaces.size, 0);
|
|
100
|
+
assert.equal(images.size, 0);
|
|
101
|
+
// Video MakeImage transfers its input allocation to C++; its eventual
|
|
102
|
+
// native free does not call the JS _free wrapper. Check the owned surfaces
|
|
103
|
+
// and snapshots here, rather than comparing that different allocation path
|
|
104
|
+
// with the canvas-only baseline above.
|
|
105
|
+
process.stdout.write('renderer memory lifecycle passed\n');
|
|
106
|
+
} finally {
|
|
107
|
+
kit._malloc = malloc;
|
|
108
|
+
kit._free = free;
|
|
109
|
+
kit.MakeSurface = makeSurface;
|
|
110
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
|
|
3
|
+
|
|
4
|
+
const renderer = await createPageRenderer({});
|
|
5
|
+
try {
|
|
6
|
+
renderer.setScripting("host");
|
|
7
|
+
await renderer.loadDocument({url:"https://page.test/",contentType:"text/html",status:200,
|
|
8
|
+
source:'<!doctype html><details><summary id="first">First</summary><summary id="second">Second</summary></details>'});
|
|
9
|
+
await renderer.whenLoaded();
|
|
10
|
+
const read = () => ["#first","#second"].map(selector => {
|
|
11
|
+
const style = renderer.inspect(selector,["display","listStyleType"])[0]?.style;
|
|
12
|
+
return style && [style.display,style.listStyleType];
|
|
13
|
+
});
|
|
14
|
+
const mutate = async source => {
|
|
15
|
+
await renderer.evaluate(`${source}; document.body.offsetHeight`);
|
|
16
|
+
};
|
|
17
|
+
const closed = [["list-item","disclosure-closed"],["block","disc"]];
|
|
18
|
+
assert.deepEqual(read(),closed);
|
|
19
|
+
await mutate("document.querySelector('details').open=true");
|
|
20
|
+
assert.deepEqual(read(),[["list-item","disclosure-open"],["block","disc"]]);
|
|
21
|
+
await mutate("document.querySelector('details').open=false");
|
|
22
|
+
assert.equal(await renderer.evaluate("document.querySelector('details').open"),false);
|
|
23
|
+
assert.deepEqual(read(),closed);
|
|
24
|
+
await mutate("document.querySelector('details').insertBefore(document.getElementById('second'),document.getElementById('first'))");
|
|
25
|
+
assert.deepEqual(read(),[["block","disc"],["list-item","disclosure-closed"]]);
|
|
26
|
+
await mutate("document.getElementById('second').remove()");
|
|
27
|
+
assert.deepEqual(read()[0],["list-item","disclosure-closed"]);
|
|
28
|
+
} finally { renderer.dispose(); }
|