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.
- package/README.md +9 -3
- package/lib/audio/pcm.js +11 -0
- package/lib/audio/pulse-client.js +7 -2
- package/lib/cdp-server.js +31 -7
- package/lib/happy-dom/document-base.js +29 -1
- package/lib/happy-dom/mutation-observer.js +157 -0
- package/lib/happy-dom/parser.js +2 -0
- package/lib/headless-shell.js +49 -9
- package/lib/layout/repaint.js +89 -0
- package/lib/media/mp4-keyframes.js +144 -0
- package/lib/media/video-keyframe.js +29 -1
- package/lib/media/webm-snapshots.js +282 -0
- package/lib/network/cookie-jar.js +18 -2
- package/lib/paint/display-list.js +4 -3
- package/lib/paint/frame.js +12 -5
- package/lib/render-tree/index.js +3 -0
- package/lib/renderer/animation-frames.js +76 -0
- package/lib/renderer/bindings.js +2 -0
- package/lib/renderer/compositor-channel.js +95 -10
- package/lib/renderer/compositor.js +54 -18
- package/lib/renderer/document-cookies.js +87 -0
- package/lib/renderer/font-loading.js +60 -16
- package/lib/renderer/host.js +48 -19
- package/lib/renderer/idle-callbacks.js +109 -0
- package/lib/renderer/main.js +5 -1
- package/lib/renderer/media-source.js +4 -0
- package/lib/renderer/page-realm.js +159 -49
- package/lib/renderer/page-renderer.js +567 -197
- package/lib/renderer/resource-elements.js +88 -0
- package/lib/renderer/script-rewrite.js +18 -1
- package/lib/renderer/seccomp.js +6 -4
- package/lib/renderer/timers.js +71 -0
- package/lib/renderer/video-channel.js +94 -0
- package/lib/renderer/video-mask.js +115 -0
- package/lib/renderer/video-presentation.js +304 -39
- package/lib/renderer/video-worker.js +46 -0
- package/lib/renderer/workers.js +7 -17
- package/lib/style/computed-style.js +257 -61
- package/package.json +1 -1
- package/test/animation-frames.test.js +67 -0
- package/test/canvas-style-snapshot.test.js +18 -0
- package/test/cdp-stream.test.js +96 -3
- package/test/compositor-channel-ownership.test.js +194 -0
- package/test/compositor.test.js +27 -1
- package/test/computed-style-binding.test.js +49 -0
- package/test/computed-style-connectivity.test.js +13 -0
- package/test/computed-style.test.js +86 -0
- package/test/cookie-sync.test.js +82 -0
- package/test/document-base-cache.test.js +103 -0
- package/test/document-cookies.test.js +117 -0
- package/test/duplicate-style-keys.test.js +11 -0
- package/test/fixtures/animation-frames.js +56 -0
- package/test/fixtures/canvas-style-snapshot.js +81 -0
- package/test/fixtures/computed-style-connectivity.js +101 -0
- package/test/fixtures/computed-style-lazy.js +135 -0
- package/test/fixtures/duplicate-style-keys.js +97 -0
- package/test/fixtures/fake-renderer.js +12 -0
- package/test/fixtures/font-definition-invalidation.js +97 -0
- package/test/fixtures/font-metrics-invalidation.js +87 -0
- package/test/fixtures/full-style-identity.js +36 -0
- package/test/fixtures/hidden-style-engine.js +260 -0
- package/test/fixtures/hidden-style-reentry.js +232 -0
- package/test/fixtures/hidden-style-renderer.js +203 -0
- package/test/fixtures/layout-repaint.js +69 -0
- package/test/fixtures/media-presentation-continuation.js +141 -0
- package/test/fixtures/media-presentation-refill.js +113 -0
- package/test/fixtures/media-probe.js +148 -0
- package/test/fixtures/media-refill-cancel.js +79 -0
- package/test/fixtures/media-repaint.js +48 -0
- package/test/fixtures/media-resource-reselection.js +94 -0
- package/test/fixtures/media-source-attachment.js +136 -0
- package/test/fixtures/media-source-no-decoder.js +17 -0
- package/test/fixtures/media-track-refill.js +132 -0
- package/test/fixtures/media-webm-seek.js +51 -0
- package/test/fixtures/mutation-observer.js +201 -0
- package/test/fixtures/observer-invalidation.js +105 -0
- package/test/fixtures/prepaint-scheduling.js +73 -0
- package/test/fixtures/resource-discovery.js +32 -0
- package/test/fixtures/spare-renderer-scheduling.js +124 -0
- package/test/fixtures/style-propagation.js +26 -0
- package/test/fixtures/svg-node-reuse.js +83 -0
- package/test/fixtures/video-black-replay.js +140 -0
- package/test/fixtures/video-controls-invalidation.js +71 -0
- package/test/fixtures/viewport-bounds.js +148 -0
- package/test/font-loading.test.js +77 -0
- package/test/font-metrics-invalidation.test.js +60 -0
- package/test/full-style-identity.test.js +191 -0
- package/test/hidden-style-lazy.test.js +31 -0
- package/test/hidden-style-reentry.test.js +20 -0
- package/test/idle-callback.test.js +89 -0
- package/test/idle-scheduler.test.js +148 -0
- package/test/inline-style-cascade.test.js +63 -0
- package/test/layout-repaint-renderer.test.js +9 -0
- package/test/layout-repaint.test.js +222 -0
- package/test/media-presentation-continuation.test.js +14 -0
- package/test/media-presentation-refill.test.js +8 -0
- package/test/media-probe.test.js +16 -0
- package/test/media-repaint.test.js +8 -0
- package/test/media-source-attachment.test.js +34 -0
- package/test/media-track-refill.test.js +26 -0
- package/test/media-webm-seek.test.js +11 -0
- package/test/mp4-keyframes.test.js +36 -0
- package/test/mutation-observer.test.js +91 -0
- package/test/observer-invalidation.test.js +12 -0
- package/test/page-scripts.test.js +35 -0
- package/test/page-timers.test.js +67 -0
- package/test/paint-frame.test.js +18 -1
- package/test/pcm.test.js +63 -0
- package/test/prepaint-scheduling.test.js +13 -0
- package/test/pulse-client.test.js +165 -0
- package/test/raw-capture.test.js +34 -5
- package/test/renderer-host.test.js +51 -0
- package/test/resource-discovery.test.js +12 -0
- package/test/resource-elements.test.js +234 -0
- package/test/root-self-style.test.js +104 -0
- package/test/script-rewrite.test.js +35 -0
- package/test/seccomp.test.js +45 -0
- package/test/spare-renderer-scheduling.test.js +8 -0
- package/test/style-propagation.test.js +225 -0
- package/test/style-sheets.test.js +19 -0
- package/test/svg.test.js +45 -0
- package/test/timers.test.js +58 -0
- package/test/video-black-replay.test.js +34 -0
- package/test/video-capture.test.js +170 -0
- package/test/video-channel.test.js +253 -0
- package/test/video-continuation.test.js +57 -0
- package/test/video-controls-invalidation.test.js +12 -0
- package/test/video-keyframe.test.js +104 -4
- package/test/video-mask.test.js +94 -0
- package/test/video-presentation.test.js +239 -1
- package/test/video-timing-presentation.test.js +115 -0
- package/test/viewport-bounds.test.js +13 -0
- package/test/webm-snapshots.test.js +372 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Isolate module/timer mocks from the real renderer integration tests.
|
|
2
|
+
import {mock} from 'bun:test';
|
|
3
|
+
import {strict as assert} from 'node:assert';
|
|
4
|
+
|
|
5
|
+
let now=0,nextPid=100,heldPid=null;
|
|
6
|
+
const timers=new Set(),hosts=[];
|
|
7
|
+
const originalSetTimeout=globalThis.setTimeout,originalClearTimeout=globalThis.clearTimeout;
|
|
8
|
+
globalThis.setTimeout=(callback,delay,...args)=>{
|
|
9
|
+
if(delay!==1000&&delay!==30000)return originalSetTimeout(callback,delay,...args);
|
|
10
|
+
const timer={at:now+delay,callback:()=>callback(...args),unref(){}};
|
|
11
|
+
timers.add(timer);return timer;
|
|
12
|
+
};
|
|
13
|
+
globalThis.clearTimeout=timer=>{if(!timers.delete(timer))originalClearTimeout(timer);};
|
|
14
|
+
const flush=async()=>{for(let i=0;i<10;i++)await Promise.resolve();};
|
|
15
|
+
const advance=async milliseconds=>{
|
|
16
|
+
now+=milliseconds;
|
|
17
|
+
for(const timer of [...timers].sort((a,b)=>a.at-b.at))if(timer.at<=now&&timers.delete(timer))timer.callback();
|
|
18
|
+
await flush();
|
|
19
|
+
};
|
|
20
|
+
class FakeHost {
|
|
21
|
+
constructor(options){this.options=options;this.pid=nextPid++;this.generation=0;this.closed=false;hosts.push(this);}
|
|
22
|
+
start(){
|
|
23
|
+
if(!this.ready){
|
|
24
|
+
if(this.pid===heldPid){this.opening=Promise.withResolvers();this.ready=this.opening.promise;}
|
|
25
|
+
else this.ready=Promise.resolve();
|
|
26
|
+
}
|
|
27
|
+
return this.ready;
|
|
28
|
+
}
|
|
29
|
+
close(){this.closed=true;}
|
|
30
|
+
lifecycle(name,generation=this.generation){this.options.onLifecycle(name,generation);}
|
|
31
|
+
async call(method,...args){
|
|
32
|
+
if(method==='loadDocument'){
|
|
33
|
+
this.documentUrl=args[0].url;const generation=++this.generation;
|
|
34
|
+
this.lifecycle('DOMContentLoaded',generation);
|
|
35
|
+
if(!this.documentUrl.endsWith('/slow'))this.lifecycle('load',generation);
|
|
36
|
+
return generation;
|
|
37
|
+
}
|
|
38
|
+
if(method==='title')return this.documentUrl;
|
|
39
|
+
}
|
|
40
|
+
async composite(){return{x:0,y:0};}
|
|
41
|
+
}
|
|
42
|
+
mock.module('../../lib/renderer/host.js',()=>({RendererHost:FakeHost}));
|
|
43
|
+
const {createBrowser}=await import('../../lib/headless-shell.js');
|
|
44
|
+
const network={
|
|
45
|
+
documentCookie:()=>'',
|
|
46
|
+
navigate:async url=>({url,body:new TextEncoder().encode('<title>ok</title>'),contentType:'text/html',status:200,statusText:'OK'}),
|
|
47
|
+
};
|
|
48
|
+
const hostFor=browser=>hosts.find(host=>host.pid===browser.rendererPid);
|
|
49
|
+
const browsers=[];
|
|
50
|
+
try{
|
|
51
|
+
const browser=await createBrowser({network});browsers.push(browser);
|
|
52
|
+
assert.equal(hosts.length,2,'first navigation retains its parallel warmed spare');
|
|
53
|
+
const first=hostFor(browser),initialSpare=browser.sparePid;
|
|
54
|
+
await browser.context.navigate('https://one.test/slow');
|
|
55
|
+
assert.equal(browser.rendererPid,initialSpare);
|
|
56
|
+
assert.equal(browser.sparePid,null);
|
|
57
|
+
assert.equal(hosts.length,2,'committing a loading page does not immediately start another renderer');
|
|
58
|
+
assert.ok(first.closed,'cross-origin navigation closes the previous origin');
|
|
59
|
+
await advance(1000);
|
|
60
|
+
assert.equal(hosts.length,2);
|
|
61
|
+
hostFor(browser).lifecycle('load');
|
|
62
|
+
await advance(999);
|
|
63
|
+
assert.equal(browser.sparePid,null);
|
|
64
|
+
await advance(1);
|
|
65
|
+
assert.equal(hosts.length,3,'load schedules spare startup after one second');
|
|
66
|
+
const sameOrigin=browser.rendererPid,spare=browser.sparePid;
|
|
67
|
+
await browser.context.navigate('https://one.test/fast');
|
|
68
|
+
assert.equal(browser.rendererPid,sameOrigin);
|
|
69
|
+
assert.equal(browser.sparePid,spare,'same-origin navigation retains an existing spare');
|
|
70
|
+
|
|
71
|
+
await browser.context.navigate('https://two.test/slow');
|
|
72
|
+
assert.equal(browser.rendererPid,spare);
|
|
73
|
+
assert.ok(hosts.find(host=>host.pid===sameOrigin).closed);
|
|
74
|
+
await advance(29999);
|
|
75
|
+
assert.equal(browser.sparePid,null);
|
|
76
|
+
await advance(1);
|
|
77
|
+
assert.equal(hosts.length,4,'a missing load event has a bounded fallback');
|
|
78
|
+
|
|
79
|
+
await browser.context.navigate('https://three.test/slow');
|
|
80
|
+
const active=hostFor(browser),oldGeneration=active.generation;
|
|
81
|
+
await advance(500);
|
|
82
|
+
await browser.context.navigate('https://three.test/fast');
|
|
83
|
+
assert.equal(hostFor(browser),active,'same-origin navigation keeps its process');
|
|
84
|
+
const latestTimer=[...timers][0];
|
|
85
|
+
active.lifecycle('load',oldGeneration);
|
|
86
|
+
assert.equal([...timers][0],latestTimer,'a late previous-generation load does not rearm the timer');
|
|
87
|
+
await advance(1000);
|
|
88
|
+
assert.equal(hosts.length,5,'load observed before commit is also respected');
|
|
89
|
+
await browser.context.navigate('https://four.test/slow');
|
|
90
|
+
browser.close();
|
|
91
|
+
const beforeClose=hosts.length;
|
|
92
|
+
await advance(30000);
|
|
93
|
+
assert.equal(hosts.length,beforeClose,'closing cancels pending prewarming');
|
|
94
|
+
assert.equal(timers.size,0);
|
|
95
|
+
|
|
96
|
+
const disabled=await createBrowser({network,spareRenderer:false});browsers.push(disabled);
|
|
97
|
+
const beforeDisabled=hosts.length;
|
|
98
|
+
await disabled.context.navigate('https://disabled.test/fast');
|
|
99
|
+
await advance(60000);
|
|
100
|
+
assert.equal(hosts.length,beforeDisabled+1);
|
|
101
|
+
assert.equal(disabled.sparePid,null);
|
|
102
|
+
disabled.close();
|
|
103
|
+
|
|
104
|
+
// An old spare can be acquired while still starting, then superseded by a
|
|
105
|
+
// second navigation. Its eventual rejection must not clear the new spare.
|
|
106
|
+
heldPid=nextPid+1;
|
|
107
|
+
const competing=await createBrowser({network});browsers.push(competing);
|
|
108
|
+
const oldSpare=hosts.find(host=>host.pid===heldPid);
|
|
109
|
+
const failedNavigation=competing.context.navigate('https://failed.test/slow').then(()=>null,error=>error);
|
|
110
|
+
await flush();
|
|
111
|
+
await competing.context.navigate('https://replacement.test/fast');
|
|
112
|
+
await advance(1000);
|
|
113
|
+
const replacementSpare=competing.sparePid;
|
|
114
|
+
assert.ok(replacementSpare!==null);
|
|
115
|
+
oldSpare.opening.reject(Error('expected startup failure'));
|
|
116
|
+
assert.match(String(await failedNavigation),/expected startup failure/);
|
|
117
|
+
assert.equal(competing.sparePid,replacementSpare);
|
|
118
|
+
assert.equal(hosts.find(host=>host.pid===replacementSpare).closed,false);
|
|
119
|
+
competing.close();
|
|
120
|
+
process.stdout.write('spare scheduling lifecycle, isolation, cancellation and failure checks passed\n');
|
|
121
|
+
}finally{
|
|
122
|
+
for(const browser of browsers)browser.close();
|
|
123
|
+
globalThis.setTimeout=originalSetTimeout;globalThis.clearTimeout=originalClearTimeout;
|
|
124
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
const {document,window}=parseHTMLDocument('<div id="parent" style="--accent:blue">'+Array.from({length:600},(_,i)=>`<span class="c${i%30}">child ${i}</span>`).join('')+'</div>');
|
|
6
|
+
const sheets=[Array.from({length:30},(_,i)=>`.c${i}{font-size:${14+i%5}px;line-height:1.2;margin:2px;color:var(--accent)}`).join('\n')];
|
|
7
|
+
const parent=document.getElementById('parent'),children=[...parent.children],engine=new StyleEngine().compute(document,sheets);
|
|
8
|
+
lockdown({errorTaming:'safe',overrideTaming:'severe',consoleTaming:'unsafe',localeTaming:'unsafe',stackFiltering:'concise',reporting:'none'});
|
|
9
|
+
try {
|
|
10
|
+
const retained=children.map(child=>engine.get(child));
|
|
11
|
+
for(const [property,value]of [['width','200px'],['padding','4px'],['opacity','.5']]){
|
|
12
|
+
parent.style[property]=value;
|
|
13
|
+
engine.compute(document,sheets,{},new Set(),new Set([parent]));
|
|
14
|
+
assert.equal(engine.restyled,1);
|
|
15
|
+
const full=new StyleEngine().compute(document,sheets);
|
|
16
|
+
for(const [index,child]of children.entries()){
|
|
17
|
+
assert.equal(engine.get(child),retained[index]);
|
|
18
|
+
assert.deepEqual(engine.get(child),full.get(child));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
parent.style.setProperty('--accent','red');
|
|
22
|
+
engine.compute(document,sheets,{},new Set(),new Set([parent]));
|
|
23
|
+
assert.equal(engine.restyled,601);
|
|
24
|
+
const full=new StyleEngine().compute(document,sheets);
|
|
25
|
+
for(const child of children){assert.equal(engine.get(child).color,'rgba(255, 0, 0, 1)');assert.deepEqual(engine.get(child),full.get(child));}
|
|
26
|
+
}finally{window.happyDOM.abort();}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import "../../vendor/ses/dist/ses.mjs";
|
|
3
|
+
import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
|
|
4
|
+
import { StyleEngine } from "../../lib/style/computed-style.js";
|
|
5
|
+
|
|
6
|
+
const compute = StyleEngine.prototype.compute;
|
|
7
|
+
let currentDocument;
|
|
8
|
+
StyleEngine.prototype.compute = function (...args) {
|
|
9
|
+
currentDocument = args[0];
|
|
10
|
+
return compute.apply(this, args);
|
|
11
|
+
};
|
|
12
|
+
lockdown({ errorTaming: "safe", overrideTaming: "severe", consoleTaming: "unsafe", localeTaming: "unsafe", stackFiltering: "concise", reporting: "none" });
|
|
13
|
+
|
|
14
|
+
const icon = `<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><g>
|
|
15
|
+
<path d="M2 2h12v12H2z"/><circle cx="16" cy="16" r="4" fill="blue"/></g></svg>`;
|
|
16
|
+
const source = `<!doctype html><style>body{margin:0;font:14px sans-serif;color:#246}
|
|
17
|
+
main{display:flex;flex-wrap:wrap;gap:4px}article{box-sizing:border-box;width:150px;padding:3px;
|
|
18
|
+
border:1px solid #abc;position:relative}h3{font-size:14px;margin:0}svg{vertical-align:middle}
|
|
19
|
+
.badge{position:absolute;right:2px;top:1px}</style><main>` + Array.from({ length: 6 }, (_, i) =>
|
|
20
|
+
`<article id="card${i}"><h3>Card ${i}</h3><span class="icons">${icon}${icon}</span>
|
|
21
|
+
<span class="badge">${i}</span><p>Text within card ${i}.</p></article>`).join("") + "</main>";
|
|
22
|
+
|
|
23
|
+
async function open(source) {
|
|
24
|
+
const renderer = await createPageRenderer({
|
|
25
|
+
fetchResource: async url => ({ ok: false, status: 404, url, body: new Uint8Array() }),
|
|
26
|
+
});
|
|
27
|
+
renderer.resize(480, 360, 1);
|
|
28
|
+
await renderer.loadDocument({ url: "https://svg-reuse.test/", contentType: "text/html", source });
|
|
29
|
+
await renderer.whenLoaded();
|
|
30
|
+
return { renderer, document: currentDocument };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function capture(renderer) {
|
|
34
|
+
const geometry = renderer.inspect("article,svg", ["width", "height", "color", "visibility", "transform"], 100);
|
|
35
|
+
const image = await renderer.screenshot({ format: "rgba" });
|
|
36
|
+
return { geometry, pixels: Buffer.from(image.pixels) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const live = await open(source), snapshots = [];
|
|
41
|
+
try {
|
|
42
|
+
const card = live.document.getElementById("card0"), svg = card.querySelector("svg");
|
|
43
|
+
const group = svg.querySelector("g"), path = svg.querySelector("path"), circle = svg.querySelector("circle");
|
|
44
|
+
const phases = [
|
|
45
|
+
["initial", () => {}],
|
|
46
|
+
["sibling text moves retained cards", () => { live.document.querySelector("#card1 h3").firstChild.data = "Changed title wraps to another line"; }],
|
|
47
|
+
["child path", () => path.setAttribute("d", "M1 1h20v8H1z")],
|
|
48
|
+
["child fill", () => path.setAttribute("fill", "red")],
|
|
49
|
+
["inherited currentColor", () => { path.setAttribute("fill", "currentColor"); card.style.color = "purple"; }],
|
|
50
|
+
["descendant currentColor", () => { path.style.color = "green"; }],
|
|
51
|
+
["group transform", () => group.setAttribute("transform", "translate(2 3) rotate(15)")],
|
|
52
|
+
["child transform", () => circle.setAttribute("transform", "scale(.6)")],
|
|
53
|
+
["child visibility", () => { path.style.visibility = "hidden"; }],
|
|
54
|
+
["restore child visibility", () => { path.style.visibility = "visible"; }],
|
|
55
|
+
["group display", () => { group.style.display = "none"; }],
|
|
56
|
+
["restore group display", () => { group.style.display = "inline"; }],
|
|
57
|
+
["dimensions", () => { svg.setAttribute("width", "40"); svg.setAttribute("height", "32"); }],
|
|
58
|
+
["viewBox", () => svg.setAttribute("viewBox", "0 0 48 48")],
|
|
59
|
+
["CSS dimensions", () => { svg.style.width = "50px"; svg.style.height = "44px"; }],
|
|
60
|
+
["root transform", () => { svg.style.transform = "translate(8px,5px)"; }],
|
|
61
|
+
["root and group opacity", () => { svg.style.opacity = ".4"; group.style.opacity = ".5"; }],
|
|
62
|
+
["stroke paint", () => { path.style.stroke = "currentColor"; path.style.strokeWidth = "3px"; path.style.strokeOpacity = ".3"; }],
|
|
63
|
+
["fill paint", () => { path.style.fillOpacity = ".2"; path.style.fillRule = "evenodd"; }],
|
|
64
|
+
["remove shape", () => circle.remove()],
|
|
65
|
+
["reorder shape", () => group.insertBefore(circle, path)],
|
|
66
|
+
["auto dimensions", () => { svg.removeAttribute("width"); svg.removeAttribute("height"); svg.style.width = "auto"; svg.style.height = "auto"; }],
|
|
67
|
+
["root visibility", () => { svg.style.visibility = "hidden"; }],
|
|
68
|
+
["restore markup", () => { card.querySelector(".icons").innerHTML = icon + icon; card.style.color = ""; }],
|
|
69
|
+
];
|
|
70
|
+
for (const [name, mutate] of phases) {
|
|
71
|
+
mutate();
|
|
72
|
+
live.renderer.resize(480, 360, 1);
|
|
73
|
+
snapshots.push({ name, source: live.document.documentElement.outerHTML, actual: await capture(live.renderer) });
|
|
74
|
+
}
|
|
75
|
+
} finally { live.renderer.dispose(); }
|
|
76
|
+
|
|
77
|
+
for (const { name, source, actual } of snapshots) {
|
|
78
|
+
const fresh = await open(source);
|
|
79
|
+
try { assert.deepEqual(actual, await capture(fresh.renderer), `${name}: fresh renderer geometry and pixels`); }
|
|
80
|
+
finally { fresh.renderer.dispose(); }
|
|
81
|
+
}
|
|
82
|
+
process.stdout.write("SVG node reuse renderer oracle passed\n");
|
|
83
|
+
} finally { StyleEngine.prototype.compute = compute; }
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import '../../vendor/ses/dist/ses.mjs';
|
|
3
|
+
import {createPageRenderer,loadGraphics} from '../../lib/renderer/page-renderer.js';
|
|
4
|
+
import {compositeVideo} from '../../lib/renderer/video-presentation.js';
|
|
5
|
+
import {captureViewport} from '../../lib/paint/frame.js';
|
|
6
|
+
|
|
7
|
+
const {CanvasKit:kit} = await loadGraphics();
|
|
8
|
+
lockdown({errorTaming:'safe',overrideTaming:'severe',consoleTaming:'unsafe',localeTaming:'unsafe',stackFiltering:'concise',reporting:'none'});
|
|
9
|
+
let readbacks = 0, checks = 0;
|
|
10
|
+
const makeSurface = kit.MakeSurface;
|
|
11
|
+
kit.MakeSurface = (...args) => {
|
|
12
|
+
const surface = makeSurface(...args), snapshot = surface.makeImageSnapshot.bind(surface);
|
|
13
|
+
surface.makeImageSnapshot = () => {
|
|
14
|
+
const image = snapshot(), read = image.readPixels.bind(image);
|
|
15
|
+
image.readPixels = (...args) => { readbacks++; return read(...args); };
|
|
16
|
+
return image;
|
|
17
|
+
};
|
|
18
|
+
return surface;
|
|
19
|
+
};
|
|
20
|
+
const source = `<style>body{margin:0;background:#182331;color:white;height:500px}
|
|
21
|
+
#wrap{position:absolute;left:12px;top:14px;width:160px;height:90px;overflow:hidden;border-radius:13px}
|
|
22
|
+
video{display:block;width:160px;height:90px}#caption{position:absolute;left:5px;top:55px;background:#6548;font:12px sans-serif}
|
|
23
|
+
#marker{position:absolute;left:210px;top:10px;width:25px;height:15px;background:red}
|
|
24
|
+
#shadow{position:absolute;left:180px;top:15px;width:5px;height:50px;box-shadow:-5px 3px 12px #0009}
|
|
25
|
+
</style><div id=wrap><video id=v src=clip.mp4></video><span id=caption>Caption</span></div><div id=marker></div><div id=shadow></div>`;
|
|
26
|
+
function framePixels(alpha) {
|
|
27
|
+
const pixels = new Uint8Array(32*18*4);
|
|
28
|
+
for (let i=0;i<pixels.length;i+=4) pixels.set([(i*37+13)%256,(i*17+71)%256,(i*53+119)%256,i%20===0?alpha:255],i);
|
|
29
|
+
return pixels;
|
|
30
|
+
}
|
|
31
|
+
async function makeClient(alpha) {
|
|
32
|
+
const client = {messages:[],errors:[],tile:null,frame:null};
|
|
33
|
+
client.renderer = await createPageRenderer({renderMinInterval:1000,
|
|
34
|
+
allocateFramePixels:bytes=>new Uint8Array(new SharedArrayBuffer(bytes)),
|
|
35
|
+
onFrame:frame=>{client.tile=frame.tile;},onVideo:message=>client.messages.push(message),
|
|
36
|
+
reportError:error=>client.errors.push(String(error)),
|
|
37
|
+
fetchResource:async url=>({ok:true,url,body:new Uint8Array([1]),contentType:'video/mp4'}),
|
|
38
|
+
videoDecoder:{probe:async()=>({duration:100,video:{width:32,height:18}}),
|
|
39
|
+
decode:async()=>({width:32,height:18,fps:30,nextFrame:()=>framePixels(alpha),seek(){},close(){}})},
|
|
40
|
+
});
|
|
41
|
+
client.renderer.setScripting('ses');client.renderer.resize(320,220,1.25);
|
|
42
|
+
return client;
|
|
43
|
+
}
|
|
44
|
+
async function load(client,suffix='') {
|
|
45
|
+
await client.renderer.loadDocument({url:'https://video.test/'+suffix,contentType:'text/html',source});
|
|
46
|
+
await client.renderer.whenLoaded();await client.renderer.evaluate("document.getElementById('v').play()");
|
|
47
|
+
await layer(client);
|
|
48
|
+
}
|
|
49
|
+
async function layer(client) {
|
|
50
|
+
for (let i=0;i<100&&!client.tile?.videoLayer;i++) { await Bun.sleep(5); await client.renderer.screenshot({format:'rgba'}); }
|
|
51
|
+
assert.ok(client.tile?.videoLayer,'independent presentation exists');
|
|
52
|
+
}
|
|
53
|
+
function seed(client,alpha,serial=2) {
|
|
54
|
+
const message = client.messages.findLast(message=>message.type==='video-open'&&message.id===client.tile.videoLayer.id);
|
|
55
|
+
const meta = new Int32Array(message.shared,0,4), pixels = framePixels(alpha);
|
|
56
|
+
Atomics.store(meta,0,serial-1);new Uint8Array(message.shared,16,pixels.length).set(pixels);
|
|
57
|
+
Atomics.store(meta,1,32);Atomics.store(meta,2,18);Atomics.store(meta,0,serial);
|
|
58
|
+
client.frame = {pixels,width:32,height:18};
|
|
59
|
+
return {meta,shared:message.shared};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const alpha of [255,127]) {
|
|
63
|
+
// The reference never receives a shared frame, so it takes the original
|
|
64
|
+
// Skia ordinary pass and both mask passes, using identical decoder pixels.
|
|
65
|
+
const reference = await makeClient(alpha), candidate = await makeClient(alpha), clients = [reference,candidate];
|
|
66
|
+
async function mutate(expression) { for (const client of clients) await client.renderer.evaluate(expression); }
|
|
67
|
+
function compare(label,fast=alpha===255) {
|
|
68
|
+
const before = reference.tile, after = candidate.tile;
|
|
69
|
+
assert.ok(after.pixels.buffer instanceof SharedArrayBuffer,label+' allocator ownership');
|
|
70
|
+
assert.equal(Boolean(after.videoLayer),Boolean(before.videoLayer),label+' eligibility');
|
|
71
|
+
if (before.videoLayer) {
|
|
72
|
+
assert.deepEqual(after.videoLayer.rect,before.videoLayer.rect,label+' rect');
|
|
73
|
+
assert.deepEqual(after.videoLayer.base,before.videoLayer.base,label+' base');
|
|
74
|
+
assert.deepEqual(after.videoLayer.mask,before.videoLayer.mask,label+' mask');
|
|
75
|
+
}
|
|
76
|
+
const expected = fast&&before.videoLayer ? compositeVideo(before,candidate.frame).pixels : before.pixels;
|
|
77
|
+
assert.deepEqual(after.pixels,expected,label+' pixels');checks++;
|
|
78
|
+
}
|
|
79
|
+
async function repaint(label,expression,fast=alpha===255) {
|
|
80
|
+
await mutate(expression);compare(label,fast);
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
for (const client of clients) await load(client);
|
|
84
|
+
seed(candidate,alpha);
|
|
85
|
+
const before = readbacks;
|
|
86
|
+
await reference.renderer.evaluate("document.getElementById('marker').style.background='orange'");
|
|
87
|
+
const referenceReads = readbacks-before;
|
|
88
|
+
const middle = readbacks;
|
|
89
|
+
await candidate.renderer.evaluate("document.getElementById('marker').style.background='orange'");
|
|
90
|
+
assert.equal(referenceReads,3);assert.equal(readbacks-middle,alpha===255?2:3);compare('warm');
|
|
91
|
+
const oldTile = candidate.tile, oldPixels = oldTile.pixels.slice(), oldBase = oldTile.videoLayer.base.slice(), oldMask = oldTile.videoLayer.mask.slice();
|
|
92
|
+
const raw = await candidate.renderer.screenshot({format:'rgba'});
|
|
93
|
+
assert.deepEqual(raw.pixels,captureViewport(candidate.tile,0,0,320,220).pixels);
|
|
94
|
+
assert.notEqual(raw.pixels.buffer,candidate.tile.pixels.buffer);
|
|
95
|
+
const bgra = await candidate.renderer.screenshot({format:'bgra'}), swapped = raw.pixels.slice();
|
|
96
|
+
for (let i=0;i<swapped.length;i+=4) { const red=swapped[i];swapped[i]=swapped[i+2];swapped[i+2]=red; }
|
|
97
|
+
assert.deepEqual(bgra.pixels,swapped);
|
|
98
|
+
const canvasPixels = [];
|
|
99
|
+
for (const client of clients) canvasPixels.push(await client.renderer.evaluate(`var copy=document.createElement('canvas');copy.width=32;copy.height=18;var cx=copy.getContext('2d');cx.drawImage(document.getElementById('v'),0,0,32,18);Array.from(cx.getImageData(0,0,32,18).data)`));
|
|
100
|
+
assert.deepEqual(canvasPixels[0],canvasPixels[1],'native video image remains available');
|
|
101
|
+
for (const [label,expression] of [
|
|
102
|
+
['outside',"document.getElementById('marker').style.background='blue'"],
|
|
103
|
+
['caption',"document.getElementById('caption').textContent='Changed text'"],
|
|
104
|
+
['selection',"var t=document.getElementById('caption').firstChild;getSelection().setBaseAndExtent(t,0,t,5)"],
|
|
105
|
+
['selection-clear',"getSelection().removeAllRanges()"],
|
|
106
|
+
['opacity',"document.getElementById('wrap').style.opacity='.6'"],
|
|
107
|
+
['rounded',"document.getElementById('wrap').style.borderRadius='21px'"],
|
|
108
|
+
['transform',"document.getElementById('wrap').style.transform='scale(.9) translateX(7.3px)'"],
|
|
109
|
+
['shadow',"document.getElementById('shadow').style.boxShadow='-8px 3px 20px #000b'"],
|
|
110
|
+
]) await repaint(label,expression);
|
|
111
|
+
assert.deepEqual(oldTile.pixels,oldPixels);assert.deepEqual(oldTile.videoLayer.base,oldBase);assert.deepEqual(oldTile.videoLayer.mask,oldMask);
|
|
112
|
+
// A writer's odd serial must leave the previous coherent native/frame pair.
|
|
113
|
+
const message = candidate.messages.findLast(message=>message.type==='video-open');
|
|
114
|
+
Atomics.store(new Int32Array(message.shared,0,4),0,3);
|
|
115
|
+
new Uint8Array(message.shared,16,32*18*4).fill(0);
|
|
116
|
+
await repaint('incomplete-shared-frame',"document.getElementById('marker').style.background='lime'");
|
|
117
|
+
seed(candidate,alpha,4);
|
|
118
|
+
// Controls alone currently need a separate layout invalidation to refresh
|
|
119
|
+
// their display-list bit, so force geometry here to exercise the fallback.
|
|
120
|
+
await repaint('controls',"document.getElementById('v').controls=true;document.getElementById('marker').style.width='26px'",false);
|
|
121
|
+
assert.equal(candidate.tile.videoLayer,null);
|
|
122
|
+
await mutate("document.getElementById('v').controls=false;document.getElementById('marker').style.width='27px'");
|
|
123
|
+
for (const client of clients) await layer(client);seed(candidate,alpha);
|
|
124
|
+
await repaint('controls-restored',"document.getElementById('marker').style.background='cyan'");
|
|
125
|
+
await repaint('two-videos',"var extra=document.createElement('video');extra.id='extra';document.body.append(extra)",false);
|
|
126
|
+
assert.equal(candidate.tile.videoLayer,null);
|
|
127
|
+
await mutate("document.getElementById('extra').remove()");
|
|
128
|
+
for (const client of clients) await layer(client);seed(candidate,alpha);
|
|
129
|
+
await repaint('one-video-restored',"document.getElementById('marker').style.background='pink'");
|
|
130
|
+
await repaint('paused',"document.getElementById('v').pause()",false);
|
|
131
|
+
assert.equal(candidate.tile.videoLayer,null);
|
|
132
|
+
await mutate("document.getElementById('v').play()");
|
|
133
|
+
for (const client of clients) await layer(client);seed(candidate,alpha);
|
|
134
|
+
await repaint('resumed',"document.getElementById('marker').style.background='brown'");
|
|
135
|
+
for (const client of clients) await load(client,'next');seed(candidate,alpha);
|
|
136
|
+
await repaint('navigation',"document.getElementById('marker').style.background='orange'");
|
|
137
|
+
for (const client of clients) assert.deepEqual(client.errors,[]);
|
|
138
|
+
} finally { for (const client of clients) client.renderer.dispose(); }
|
|
139
|
+
}
|
|
140
|
+
console.log(`video black replay integration passed: ${checks} paired states`);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
|
|
3
|
+
import { LayoutEngine } from "../../lib/layout/text-layout.js";
|
|
4
|
+
|
|
5
|
+
let layouts = 0;
|
|
6
|
+
const originalLayout = LayoutEngine.prototype.layout;
|
|
7
|
+
LayoutEngine.prototype.layout = function (...args) {
|
|
8
|
+
layouts++;
|
|
9
|
+
return Reflect.apply(originalLayout, this, args);
|
|
10
|
+
};
|
|
11
|
+
const errors = [];
|
|
12
|
+
const renderer = await createPageRenderer({ reportError: error => errors.push(String(error)) });
|
|
13
|
+
await import("../../vendor/ses/dist/ses.mjs");
|
|
14
|
+
lockdown({ errorTaming: "safe", overrideTaming: "severe", consoleTaming: "unsafe",
|
|
15
|
+
localeTaming: "unsafe", stackFiltering: "concise", reporting: "none" });
|
|
16
|
+
|
|
17
|
+
const source = controls => `<!doctype html>
|
|
18
|
+
<style id=sheet>body{margin:0;background:white}video{display:block;width:160px;height:90px}</style>
|
|
19
|
+
<video id=v ${controls ? "controls" : ""}></video>`;
|
|
20
|
+
const load = async controls => {
|
|
21
|
+
await renderer.loadDocument({ url: "https://controls.test/", contentType: "text/html", source: source(controls) });
|
|
22
|
+
await renderer.whenLoaded();
|
|
23
|
+
};
|
|
24
|
+
const snapshot = async () => ({
|
|
25
|
+
pixels: new Bun.CryptoHasher("sha256").update(Buffer.from(await renderer.screenshot(), "base64")).digest("hex"),
|
|
26
|
+
geometry: await renderer.evaluate(`JSON.stringify((() => {
|
|
27
|
+
const rect = document.getElementById("v").getBoundingClientRect();
|
|
28
|
+
return [rect.x, rect.y, rect.width, rect.height];
|
|
29
|
+
})())`),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
renderer.setScripting("ses");
|
|
34
|
+
renderer.resize(240, 140, 1);
|
|
35
|
+
const references = [];
|
|
36
|
+
for (const controls of [false, true]) {
|
|
37
|
+
await load(controls);
|
|
38
|
+
references.push(await snapshot());
|
|
39
|
+
}
|
|
40
|
+
assert.notEqual(references[0].pixels, references[1].pixels, "native controls visibly change the video");
|
|
41
|
+
assert.equal(references[0].geometry, references[1].geometry, "native controls retain the video's box");
|
|
42
|
+
|
|
43
|
+
await load(false);
|
|
44
|
+
const transitions = [
|
|
45
|
+
["add attribute", true, `document.getElementById("v").setAttribute("controls", "")`],
|
|
46
|
+
["remove after style-only read", false, `
|
|
47
|
+
document.getElementById("v").removeAttribute("controls");
|
|
48
|
+
getComputedStyle(document.getElementById("v")).color;
|
|
49
|
+
document.getElementById("v").getBoundingClientRect()`],
|
|
50
|
+
["add during full stylesheet pass", true, `
|
|
51
|
+
document.getElementById("sheet").textContent += " p { color:red }";
|
|
52
|
+
document.getElementById("v").setAttribute("controls", "")`, true],
|
|
53
|
+
["remove after mutation delivery", false, `
|
|
54
|
+
document.getElementById("v").removeAttribute("controls");
|
|
55
|
+
new Promise(resolve => setTimeout(resolve, 0))`],
|
|
56
|
+
["enable reflected property", true, `document.getElementById("v").controls = true`],
|
|
57
|
+
["disable reflected property", false, `document.getElementById("v").controls = false`],
|
|
58
|
+
];
|
|
59
|
+
for (const [name, controls, expression, changesSheet] of transitions) {
|
|
60
|
+
const before = layouts;
|
|
61
|
+
await renderer.evaluate(expression);
|
|
62
|
+
const actual = await snapshot();
|
|
63
|
+
assert.equal(actual.pixels, references[Number(controls)].pixels, name + " pixels match a fresh document");
|
|
64
|
+
assert.equal(actual.geometry, references[Number(controls)].geometry, name + " retains geometry");
|
|
65
|
+
if (!changesSheet) assert.equal(layouts - before, 0, name + " reuses layout");
|
|
66
|
+
}
|
|
67
|
+
assert.deepEqual(errors, []);
|
|
68
|
+
console.log("video controls paint invalidation passed");
|
|
69
|
+
} finally {
|
|
70
|
+
renderer.dispose();
|
|
71
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
|
|
2
|
+
import { StyleEngine } from "../../lib/style/computed-style.js";
|
|
3
|
+
|
|
4
|
+
const originalGet = StyleEngine.prototype.get;
|
|
5
|
+
let counting = false, styleReads = 0, checks = 0;
|
|
6
|
+
StyleEngine.prototype.get = function (element) {
|
|
7
|
+
if (counting) styleReads++;
|
|
8
|
+
return originalGet.call(this, element);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
await import("../../vendor/ses/dist/ses.mjs");
|
|
12
|
+
lockdown({ errorTaming: "safe", overrideTaming: "severe", consoleTaming: "unsafe",
|
|
13
|
+
localeTaming: "unsafe", stackFiltering: "concise", reporting: "none" });
|
|
14
|
+
|
|
15
|
+
function check(condition, message) {
|
|
16
|
+
if (!condition) throw new Error(message);
|
|
17
|
+
checks++;
|
|
18
|
+
}
|
|
19
|
+
function equal(actual, expected, message) {
|
|
20
|
+
check(Bun.deepEquals(actual, expected, true), `${message}: ${JSON.stringify(actual)}`);
|
|
21
|
+
}
|
|
22
|
+
const errors = [];
|
|
23
|
+
const renderer = await createPageRenderer({ embedded: true,
|
|
24
|
+
reportError: error => errors.push(String(error)) });
|
|
25
|
+
const evaluate = source => renderer.evaluate(source);
|
|
26
|
+
async function countsStyles(source) {
|
|
27
|
+
styleReads = 0;
|
|
28
|
+
counting = true;
|
|
29
|
+
try { return await evaluate(source); }
|
|
30
|
+
finally { counting = false; }
|
|
31
|
+
}
|
|
32
|
+
const settle = () => Bun.sleep(20);
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
renderer.setScripting("ses");
|
|
36
|
+
renderer.resize(260, 180, 1);
|
|
37
|
+
await renderer.loadDocument({ url: "https://bounds.test/", contentType: "text/html", source: `
|
|
38
|
+
<!doctype html><style>
|
|
39
|
+
body { margin:0; min-height:1000px; min-width:500px; font:14px sans-serif }
|
|
40
|
+
#hidden { display:none; --tone:rgb(12,34,56); color:var(--tone) }
|
|
41
|
+
#leaf { display:inline-block; width:7ch; height:28px; font-family:monospace }
|
|
42
|
+
#ownNone { display:none }
|
|
43
|
+
#contents, #emptyContents { display:contents }
|
|
44
|
+
#fixed { position:fixed; left:140px; top:8px; width:90px; height:55px; background:rgb(0,128,0) }
|
|
45
|
+
#scroller { width:120px; height:55px; overflow:auto; border:3px solid blue; padding:2px }
|
|
46
|
+
#inner { width:300px; height:220px }
|
|
47
|
+
#button { width:80px; height:30px }
|
|
48
|
+
</style>
|
|
49
|
+
<div id="hidden"><section id="chain"><span id="leaf">hidden</span></section></div>
|
|
50
|
+
<div id="ownNone">none</div><div id="emptyContents"></div><span id="emptyInline"></span>
|
|
51
|
+
<div id="contents"><span id="inline">inline text</span></div>
|
|
52
|
+
<svg id="svg" width="28" height="24"><path id="path" d="M1 1h20v16H1z" fill="red"/></svg>
|
|
53
|
+
<div id="fixed"></div><div id="scroller"><div id="inner"></div></div>
|
|
54
|
+
<button id="button">hit</button>` });
|
|
55
|
+
await renderer.whenLoaded();
|
|
56
|
+
await evaluate(`
|
|
57
|
+
window.leaf=document.getElementById('leaf');
|
|
58
|
+
window.detached=document.createElement('div');
|
|
59
|
+
window.rect=e=>{const r=e.getBoundingClientRect();return[r.x,r.y,r.width,r.height]};
|
|
60
|
+
window.sizes=e=>[...rect(e),e.offsetWidth,e.offsetHeight,e.clientWidth,e.clientHeight,
|
|
61
|
+
e.scrollWidth,e.scrollHeight,e.scrollLeft,e.scrollTop];
|
|
62
|
+
window.clicks=0;document.getElementById('button').onclick=()=>clicks++;
|
|
63
|
+
window.scrolls=0;document.getElementById('scroller').onscroll=()=>scrolls++;
|
|
64
|
+
`);
|
|
65
|
+
|
|
66
|
+
const empty = await countsStyles(`(() => {
|
|
67
|
+
const elements=['leaf','ownNone','emptyContents','emptyInline','path'].map(id=>document.getElementById(id));
|
|
68
|
+
elements.push(detached);
|
|
69
|
+
const range=document.createRange();range.selectNodeContents(leaf);
|
|
70
|
+
return [...elements.map(sizes),rect({getBoundingClientRect:()=>range.getBoundingClientRect()})];
|
|
71
|
+
})()`);
|
|
72
|
+
check(empty.every(values => values.every(value => value === 0)), "boxless geometry is zero");
|
|
73
|
+
equal(styleReads, 0, "boxless bounds and sizes do not materialize hidden styles");
|
|
74
|
+
equal(await countsStyles(`(() => {
|
|
75
|
+
leaf.scrollTo(20,30);leaf.scrollTop=12;leaf.scrollLeft=3;
|
|
76
|
+
return [leaf.scrollLeft,leaf.scrollTop];
|
|
77
|
+
})()`), [0, 0], "hidden scroll setters remain ineffective");
|
|
78
|
+
equal(styleReads, 0, "hidden scroll setters do not read styles");
|
|
79
|
+
|
|
80
|
+
const css = await countsStyles(`(() => {
|
|
81
|
+
const s=getComputedStyle(leaf);return[s.color,s.width,s.getPropertyValue('--tone')];
|
|
82
|
+
})()`);
|
|
83
|
+
equal([css[0], css[2]], ["rgba(12, 34, 56, 1)", "rgb(12,34,56)"], "explicit hidden CSS still resolves");
|
|
84
|
+
check(css[1].endsWith("px") && Number.parseFloat(css[1]) > 0, "hidden ch width resolves to positive pixels");
|
|
85
|
+
check(styleReads > 0, "explicit CSS materializes the deferred style");
|
|
86
|
+
await evaluate(`leaf.style.width='81px';leaf.style.height='29px';leaf.style.color='rgb(9,8,7)';
|
|
87
|
+
document.getElementById('hidden').style.setProperty('--tone','purple')`);
|
|
88
|
+
equal(await evaluate(`(() => {const s=getComputedStyle(leaf);
|
|
89
|
+
return[s.width,s.height,s.color,s.getPropertyValue('--tone')];})()`),
|
|
90
|
+
["81px", "29px", "rgba(9, 8, 7, 1)", "purple"], "hidden CSS updates remain live");
|
|
91
|
+
|
|
92
|
+
const rendered = await evaluate(`['inline','contents','svg'].map(id=>rect(document.getElementById(id)))`);
|
|
93
|
+
check(rendered[0][2] > 0 && rendered[1][2] > 0, "inline and display-contents text retain bounds");
|
|
94
|
+
equal(rendered[2].slice(2), [28, 24], "SVG retains its rendered dimensions");
|
|
95
|
+
|
|
96
|
+
await evaluate(`window.resizeWidths=[];window.intersections=[];
|
|
97
|
+
window.ro=new ResizeObserver(es=>resizeWidths.push(es[0].contentRect.width));ro.observe(leaf);
|
|
98
|
+
window.io=new IntersectionObserver(es=>intersections.push(es[0].isIntersecting));io.observe(leaf)`);
|
|
99
|
+
await settle();
|
|
100
|
+
equal(await evaluate(`[resizeWidths.length,intersections.at(-1)]`), [0, false], "initial zero size does not notify ResizeObserver");
|
|
101
|
+
await evaluate(`document.getElementById('hidden').style.display='block'`);
|
|
102
|
+
await settle();
|
|
103
|
+
equal(await evaluate(`[resizeWidths.at(-1),intersections.at(-1)]`), [81, true], "reveal updates observer geometry");
|
|
104
|
+
await evaluate(`document.getElementById('hidden').style.display='none'`);
|
|
105
|
+
await settle();
|
|
106
|
+
equal(await evaluate(`[resizeWidths.at(-1),intersections.at(-1)]`), [0, false], "hiding updates observer geometry");
|
|
107
|
+
|
|
108
|
+
await evaluate(`document.getElementById('fixed').append(leaf)`);
|
|
109
|
+
const fixed = await evaluate(`rect(leaf)`);
|
|
110
|
+
renderer.scrollTo(60, 130);
|
|
111
|
+
equal(await evaluate(`rect(leaf)`), fixed, "a fixed ancestor cancels viewport scrolling");
|
|
112
|
+
renderer.scrollTo(0, 0);
|
|
113
|
+
await evaluate(`document.getElementById('inner').append(leaf)`);
|
|
114
|
+
const beforeScroll = await evaluate(`rect(leaf)`);
|
|
115
|
+
await evaluate(`document.getElementById('scroller').scrollTo(60,70)`);
|
|
116
|
+
await settle();
|
|
117
|
+
const afterScroll = await evaluate(`rect(leaf)`);
|
|
118
|
+
equal(afterScroll, [beforeScroll[0] - 60, beforeScroll[1] - 70, beforeScroll[2], beforeScroll[3]],
|
|
119
|
+
"scroll-container offsets remain part of viewport bounds");
|
|
120
|
+
check(await evaluate("scrolls > 0"), "scroll event still fires");
|
|
121
|
+
await evaluate(`leaf.style.visibility='hidden'`);
|
|
122
|
+
equal(await evaluate(`rect(leaf)`), afterScroll, "visibility hidden retains geometry");
|
|
123
|
+
await evaluate(`leaf.style.visibility='visible';leaf.style.transform='translate(8px,5px)'`);
|
|
124
|
+
const translated = await evaluate(`rect(leaf)`);
|
|
125
|
+
equal(translated, [afterScroll[0] + 8, afterScroll[1] + 5, afterScroll[2], afterScroll[3]],
|
|
126
|
+
"translated coordinates remain intact");
|
|
127
|
+
|
|
128
|
+
await evaluate(`leaf.remove()`);
|
|
129
|
+
equal(await countsStyles("sizes(leaf)"), Array(12).fill(0), "detached geometry is zero");
|
|
130
|
+
equal(styleReads, 0, "detached geometry does not read stale styles");
|
|
131
|
+
await evaluate(`document.getElementById('chain').append(leaf);window.destroyed=document.getElementById('chain');
|
|
132
|
+
document.getElementById('hidden').innerHTML=''`);
|
|
133
|
+
equal(await countsStyles("[...sizes(leaf),...sizes(destroyed)]"), Array(24).fill(0), "removed subtree has no geometry");
|
|
134
|
+
equal(styleReads, 0, "removed subtree does not materialize styles");
|
|
135
|
+
|
|
136
|
+
renderer.resize(220, 160, 2);
|
|
137
|
+
const image = await renderer.screenshot({ format: "rgba" });
|
|
138
|
+
equal([image.width, image.height], [440, 320], "DPR scales the raster");
|
|
139
|
+
const pixel = (100 * image.width + 400) * 4;
|
|
140
|
+
equal([...image.pixels.slice(pixel, pixel + 4)], [0, 128, 0, 255], "fixed box still paints at the expected viewport position");
|
|
141
|
+
const button = await evaluate("rect(document.getElementById('button'))");
|
|
142
|
+
renderer.click(button[0] + 10, button[1] + 10);
|
|
143
|
+
equal(await evaluate("clicks"), 1, "visible geometry still targets pointer activation");
|
|
144
|
+
equal(errors, [], "renderer reports no errors");
|
|
145
|
+
console.log(JSON.stringify({ passed: true, checks }));
|
|
146
|
+
} finally {
|
|
147
|
+
renderer.dispose();
|
|
148
|
+
}
|