buninu-browser 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +13 -6
  2. package/index.js +10 -6
  3. package/lib/audio/pcm.js +11 -0
  4. package/lib/audio/pulse-client.js +7 -2
  5. package/lib/cdp-server.js +94 -46
  6. package/lib/happy-dom/document-base.js +29 -1
  7. package/lib/happy-dom/mutation-observer.js +157 -0
  8. package/lib/happy-dom/parser.js +2 -0
  9. package/lib/headless-shell.js +49 -9
  10. package/lib/layout/repaint.js +89 -0
  11. package/lib/media/mp4-keyframes.js +144 -0
  12. package/lib/media/video-keyframe.js +29 -1
  13. package/lib/media/webm-snapshots.js +282 -0
  14. package/lib/network/cookie-jar.js +18 -2
  15. package/lib/paint/display-list.js +4 -3
  16. package/lib/paint/frame.js +12 -5
  17. package/lib/render-tree/index.js +3 -0
  18. package/lib/renderer/animation-frames.js +76 -0
  19. package/lib/renderer/bindings.js +2 -0
  20. package/lib/renderer/compositor-channel.js +95 -10
  21. package/lib/renderer/compositor.js +54 -18
  22. package/lib/renderer/document-cookies.js +87 -0
  23. package/lib/renderer/font-loading.js +60 -16
  24. package/lib/renderer/host.js +48 -19
  25. package/lib/renderer/idle-callbacks.js +109 -0
  26. package/lib/renderer/main.js +5 -1
  27. package/lib/renderer/media-source.js +4 -0
  28. package/lib/renderer/page-realm.js +159 -49
  29. package/lib/renderer/page-renderer.js +567 -197
  30. package/lib/renderer/resource-elements.js +88 -0
  31. package/lib/renderer/script-rewrite.js +18 -1
  32. package/lib/renderer/seccomp.js +6 -4
  33. package/lib/renderer/timers.js +71 -0
  34. package/lib/renderer/video-channel.js +94 -0
  35. package/lib/renderer/video-mask.js +115 -0
  36. package/lib/renderer/video-presentation.js +304 -39
  37. package/lib/renderer/video-worker.js +46 -0
  38. package/lib/renderer/workers.js +7 -17
  39. package/lib/style/computed-style.js +257 -61
  40. package/package.json +1 -1
  41. package/test/animation-frames.test.js +67 -0
  42. package/test/canvas-style-snapshot.test.js +18 -0
  43. package/test/cdp-stream.test.js +96 -3
  44. package/test/cdp-viewers.test.js +43 -0
  45. package/test/compositor-channel-ownership.test.js +194 -0
  46. package/test/compositor.test.js +27 -1
  47. package/test/computed-style-binding.test.js +49 -0
  48. package/test/computed-style-connectivity.test.js +13 -0
  49. package/test/computed-style.test.js +86 -0
  50. package/test/cookie-sync.test.js +82 -0
  51. package/test/document-base-cache.test.js +103 -0
  52. package/test/document-cookies.test.js +117 -0
  53. package/test/duplicate-style-keys.test.js +11 -0
  54. package/test/fixtures/animation-frames.js +56 -0
  55. package/test/fixtures/canvas-style-snapshot.js +81 -0
  56. package/test/fixtures/computed-style-connectivity.js +101 -0
  57. package/test/fixtures/computed-style-lazy.js +135 -0
  58. package/test/fixtures/duplicate-style-keys.js +97 -0
  59. package/test/fixtures/fake-renderer.js +12 -0
  60. package/test/fixtures/font-definition-invalidation.js +97 -0
  61. package/test/fixtures/font-metrics-invalidation.js +87 -0
  62. package/test/fixtures/full-style-identity.js +36 -0
  63. package/test/fixtures/hidden-style-engine.js +260 -0
  64. package/test/fixtures/hidden-style-reentry.js +232 -0
  65. package/test/fixtures/hidden-style-renderer.js +203 -0
  66. package/test/fixtures/layout-repaint.js +69 -0
  67. package/test/fixtures/media-presentation-continuation.js +141 -0
  68. package/test/fixtures/media-presentation-refill.js +113 -0
  69. package/test/fixtures/media-probe.js +148 -0
  70. package/test/fixtures/media-refill-cancel.js +79 -0
  71. package/test/fixtures/media-repaint.js +48 -0
  72. package/test/fixtures/media-resource-reselection.js +94 -0
  73. package/test/fixtures/media-source-attachment.js +136 -0
  74. package/test/fixtures/media-source-no-decoder.js +17 -0
  75. package/test/fixtures/media-track-refill.js +132 -0
  76. package/test/fixtures/media-webm-seek.js +51 -0
  77. package/test/fixtures/mutation-observer.js +201 -0
  78. package/test/fixtures/observer-invalidation.js +105 -0
  79. package/test/fixtures/prepaint-scheduling.js +73 -0
  80. package/test/fixtures/resource-discovery.js +32 -0
  81. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  82. package/test/fixtures/style-propagation.js +26 -0
  83. package/test/fixtures/svg-node-reuse.js +83 -0
  84. package/test/fixtures/video-black-replay.js +140 -0
  85. package/test/fixtures/video-controls-invalidation.js +71 -0
  86. package/test/fixtures/viewport-bounds.js +148 -0
  87. package/test/font-loading.test.js +77 -0
  88. package/test/font-metrics-invalidation.test.js +60 -0
  89. package/test/full-style-identity.test.js +191 -0
  90. package/test/hidden-style-lazy.test.js +31 -0
  91. package/test/hidden-style-reentry.test.js +20 -0
  92. package/test/idle-callback.test.js +89 -0
  93. package/test/idle-scheduler.test.js +148 -0
  94. package/test/index.test.js +1 -0
  95. package/test/inline-style-cascade.test.js +63 -0
  96. package/test/layout-repaint-renderer.test.js +9 -0
  97. package/test/layout-repaint.test.js +222 -0
  98. package/test/media-presentation-continuation.test.js +14 -0
  99. package/test/media-presentation-refill.test.js +8 -0
  100. package/test/media-probe.test.js +16 -0
  101. package/test/media-repaint.test.js +8 -0
  102. package/test/media-source-attachment.test.js +34 -0
  103. package/test/media-track-refill.test.js +26 -0
  104. package/test/media-webm-seek.test.js +11 -0
  105. package/test/mp4-keyframes.test.js +36 -0
  106. package/test/mutation-observer.test.js +91 -0
  107. package/test/observer-invalidation.test.js +12 -0
  108. package/test/page-scripts.test.js +35 -0
  109. package/test/page-timers.test.js +67 -0
  110. package/test/paint-frame.test.js +18 -1
  111. package/test/pcm.test.js +63 -0
  112. package/test/prepaint-scheduling.test.js +13 -0
  113. package/test/pulse-client.test.js +165 -0
  114. package/test/raw-capture.test.js +34 -5
  115. package/test/renderer-host.test.js +51 -0
  116. package/test/resource-discovery.test.js +12 -0
  117. package/test/resource-elements.test.js +234 -0
  118. package/test/root-self-style.test.js +104 -0
  119. package/test/script-rewrite.test.js +35 -0
  120. package/test/seccomp.test.js +45 -0
  121. package/test/spare-renderer-scheduling.test.js +8 -0
  122. package/test/style-propagation.test.js +225 -0
  123. package/test/style-sheets.test.js +19 -0
  124. package/test/svg.test.js +45 -0
  125. package/test/timers.test.js +58 -0
  126. package/test/video-black-replay.test.js +34 -0
  127. package/test/video-capture.test.js +170 -0
  128. package/test/video-channel.test.js +253 -0
  129. package/test/video-continuation.test.js +57 -0
  130. package/test/video-controls-invalidation.test.js +12 -0
  131. package/test/video-keyframe.test.js +104 -4
  132. package/test/video-mask.test.js +94 -0
  133. package/test/video-presentation.test.js +239 -1
  134. package/test/video-timing-presentation.test.js +115 -0
  135. package/test/viewport-bounds.test.js +13 -0
  136. package/test/webm-snapshots.test.js +372 -0
@@ -0,0 +1,105 @@
1
+ // Host scripting and the style-read counter stay outside the test runner.
2
+ import { strict as assert } from "node:assert";
3
+ import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
4
+ import { nodeOf } from "../../lib/renderer/bindings.js";
5
+ import { StyleEngine } from "../../lib/style/computed-style.js";
6
+
7
+ const font = await Bun.file(new URL("../../usr/share/fonts/DejaVuSansMono.ttf", import.meta.url)).bytes();
8
+ const sleep = Bun.sleep;
9
+ let finishFont;
10
+ const fontResponse = new Promise(resolve => { finishFont = resolve; });
11
+ const renderer = await createPageRenderer({
12
+ fetchResource: async url => ({ ok: false, status: 404, url, body: new Uint8Array() }),
13
+ pageFetch: () => fontResponse,
14
+ });
15
+ const evaluate = code => renderer.evaluate(code);
16
+ const eventually = async (code, check, message) => {
17
+ for (let attempt = 0; attempt < 100; attempt++) {
18
+ const value = await evaluate(code);
19
+ if (check(value)) return value;
20
+ await sleep(10);
21
+ }
22
+ assert.fail(message);
23
+ };
24
+ const originalGet = StyleEngine.prototype.get;
25
+ try {
26
+ renderer.resize(400, 300, 1);
27
+ renderer.setScripting("host");
28
+ await renderer.loadDocument({ url: "https://observer.test/", contentType: "text/html", source: `<!doctype html>
29
+ <style id=sheet>body{margin:0}#box{width:100px;height:50px}
30
+ body[data-wide] #box{width:175px}
31
+ #viewport{width:50%;height:10px}
32
+ #scroller{position:absolute;top:80px;width:100px;height:100px;overflow:auto}
33
+ #inside{position:absolute;top:150px;width:20px;height:20px}
34
+ #below{position:absolute;top:500px;width:20px;height:20px}</style>
35
+ <div id=box></div><div id=viewport></div>
36
+ <div id=scroller><div style="position:relative;height:300px"><div id=inside></div></div></div>
37
+ <div id=below></div><div style="height:1000px"></div>` });
38
+ await renderer.whenLoaded();
39
+ await evaluate(`window.box=document.getElementById('box');window.sizes=[];window.intersections=[];
40
+ window.ro=new ResizeObserver(entries=>sizes.push(entries[0].contentRect.width));ro.observe(box);
41
+ window.io=new IntersectionObserver(entries=>intersections.push(entries[0].isIntersecting));io.observe(box);`);
42
+ await eventually("sizes.length===1&&intersections.length===1", Boolean, "initial observers should be delivered");
43
+ // Consume initial focus bookkeeping, then count actual style measurements
44
+ // of the observed box. Reading body geometry itself cannot touch this box.
45
+ await evaluate("document.body.setAttribute('data-warm','1');document.body.clientWidth");
46
+ await sleep(40);
47
+ const target = nodeOf(globalThis.document.getElementById("box"));
48
+ let measurements = 0;
49
+ StyleEngine.prototype.get = function (element) {
50
+ if (element === target) measurements++;
51
+ return originalGet.call(this, element);
52
+ };
53
+ for (let i = 0; i < 5; i++) {
54
+ await evaluate(`document.body.setAttribute('data-counter','${i}');document.body.clientWidth`);
55
+ await sleep(30);
56
+ }
57
+ assert.equal(measurements, 0, "metadata flushes must not remeasure unchanged observer targets");
58
+ assert.deepEqual(await evaluate("[sizes,intersections]"), [[100], [true]]);
59
+ StyleEngine.prototype.get = originalGet;
60
+
61
+ // observe() itself still queues a first measurement without a new version.
62
+ await evaluate("window.newSizes=[];window.newRO=new ResizeObserver(e=>newSizes.push(e[0].contentRect.width));newRO.observe(box)");
63
+ await eventually("newSizes[0]", value => value === 100, "new observers must not wait for invalidation");
64
+ await evaluate("box.style.width='150px';document.body.clientWidth");
65
+ await eventually("sizes.at(-1)", value => value === 150, "inline size changes must notify");
66
+ await evaluate("box.style.width='';document.body.setAttribute('data-wide','');document.body.clientWidth");
67
+ await eventually("sizes.at(-1)", value => value === 175, "selector-observed metadata must still invalidate");
68
+ await evaluate("document.getElementById('sheet').textContent+='body[data-wide] #box{width:190px}';document.body.clientWidth");
69
+ await eventually("sizes.at(-1)", value => value === 190, "stylesheet changes must notify");
70
+
71
+ await evaluate(`window.insideChanges=[];window.belowChanges=[];window.viewportSizes=[];window.deviceSizes=[];
72
+ window.insideIO=new IntersectionObserver(e=>insideChanges.push(e[0].isIntersecting));insideIO.observe(document.getElementById('inside'));
73
+ window.belowIO=new IntersectionObserver(e=>belowChanges.push(e[0].isIntersecting));belowIO.observe(document.getElementById('below'));
74
+ window.viewportRO=new ResizeObserver(e=>viewportSizes.push(e[0].contentRect.width));viewportRO.observe(document.getElementById('viewport'));
75
+ window.deviceRO=new ResizeObserver(e=>deviceSizes.push(e[0].devicePixelContentBoxSize[0].inlineSize));deviceRO.observe(box,{box:'device-pixel-content-box'});`);
76
+ await eventually("insideChanges.length===1&&belowChanges.length===1&&viewportSizes.length===1&&deviceSizes.length===1", Boolean,
77
+ "scroll/viewport observers should be initialized");
78
+ assert.deepEqual(await evaluate("[insideChanges,belowChanges,viewportSizes,deviceSizes]"), [[false], [false], [200], [190]]);
79
+ await evaluate("document.getElementById('scroller').scrollTop=100");
80
+ await eventually("insideChanges.at(-1)", Boolean, "element scrolling must notify");
81
+ renderer.scrollTo(0, 450);
82
+ await eventually("belowChanges.at(-1)", Boolean, "viewport scrolling must notify");
83
+ renderer.scrollTo(0, 0);
84
+ renderer.resize(400, 300, 2);
85
+ await eventually("deviceSizes.at(-1)", value => value === 380, "DPR-only changes must notify without layout invalidation");
86
+ renderer.resize(600, 300, 2);
87
+ await eventually("viewportSizes.at(-1)", value => value === 300, "viewport resizing must notify");
88
+ await evaluate("box.remove();document.body.clientWidth");
89
+ await eventually("sizes.at(-1)===0&&intersections.at(-1)===false", Boolean, "removing a target must notify");
90
+ await evaluate("document.body.prepend(box);document.body.clientWidth");
91
+ await eventually("sizes.at(-1)===190&&intersections.at(-1)===true", Boolean, "reattaching a target must notify");
92
+
93
+ // Font completion is a resource invalidation even with no DOM mutation.
94
+ await evaluate(`document.body.insertAdjacentHTML('beforeend',
95
+ '<span id=text style="display:inline-block;font-family:ObserverMono,sans-serif;font-size:30px">iiii</span>')`);
96
+ await evaluate("window.fontSizes=[];window.fontRO=new ResizeObserver(e=>fontSizes.push(e[0].contentRect.width));fontRO.observe(document.getElementById('text'))");
97
+ const before = await eventually("fontSizes[0]", value => value > 0, "fallback font should be measured");
98
+ await evaluate(`document.getElementById('sheet').textContent+='@font-face{font-family:ObserverMono;src:url(/font.ttf)}';document.body.clientWidth`);
99
+ finishFont({ status: 200, body: font });
100
+ await eventually("fontSizes.at(-1)", value => value > before * 2, "loaded font metrics must notify");
101
+ process.stdout.write("observer invalidation integration passed\n");
102
+ } finally {
103
+ StyleEngine.prototype.get = originalGet;
104
+ renderer.dispose();
105
+ }
@@ -0,0 +1,73 @@
1
+ // Host scripting installs browser globals, so each scenario runs separately.
2
+ import {strict as assert} from 'node:assert';
3
+ import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
4
+
5
+ const scenario = process.argv[2];
6
+ const frames = [], errors = [], videos = [];
7
+ let finishScript;
8
+ const pendingScript = new Promise(resolve => { finishScript = resolve; });
9
+ const renderer = await createPageRenderer({
10
+ renderMinInterval: 180,
11
+ onFrame: ({tile}) => frames.push(tile),
12
+ onVideo: message => videos.push(message),
13
+ reportError: error => errors.push(String(error)),
14
+ fetchResource: async url => {
15
+ if (url.endsWith('slow.js')) await pendingScript;
16
+ return {ok:true, url, body:new Uint8Array([32]), contentType:url.endsWith('.js') ? 'text/javascript' : 'video/mp4'};
17
+ },
18
+ videoDecoder: {
19
+ probe: async () => ({duration:10, video:{width:20, height:12}}),
20
+ decode: async () => ({width:20, height:12, fps:30,
21
+ nextFrame(w,h) { return new Uint8Array(w*h*4).fill(255); }, seek() {}, close() {}}),
22
+ },
23
+ });
24
+
25
+ try {
26
+ renderer.resize(64,36,1);
27
+ renderer.setScripting('host');
28
+ frames.length = 0;
29
+ const loading = scenario.startsWith('loading');
30
+ const media = scenario === 'video';
31
+ await renderer.loadDocument({url:'https://prepaint.test/', contentType:'text/html',
32
+ source:'<style>body{margin:0}#marker{height:500px;background:red}video{display:block;width:20px;height:12px}</style>'
33
+ + (media ? '<video id=v src="clip.mp4"></video>' : '') + '<div id=marker></div>'
34
+ + (loading ? '<script src="slow.js"></script>' : '')});
35
+
36
+ if (loading) {
37
+ await Bun.sleep(400);
38
+ assert(frames.length > 0);
39
+ assert(frames.every(tile => tile.height === 36), 'pending scripts must not expand an already visible tile');
40
+ if (scenario === 'loading-scroll') {
41
+ renderer.applyCompositorScroll(0,140);
42
+ await Bun.sleep(90);
43
+ assert(frames.some(tile => tile.y <= 140 && tile.y+tile.height >= 176),
44
+ 'scrolling beyond the old tile must still paint while loading');
45
+ }
46
+ finishScript();
47
+ await renderer.whenLoaded();
48
+ // The script has no visual effect: refresh need not call render. The
49
+ // deferred prepaint still needs to wake and expand the unchanged tile.
50
+ await Bun.sleep(550);
51
+ assert(frames.some(tile => tile.height > 36), 'prepaint resumes after a no-op load settles');
52
+ } else {
53
+ await renderer.whenLoaded();
54
+ if (media) {
55
+ await renderer.evaluate('document.getElementById("v").play()');
56
+ await Bun.sleep(400);
57
+ assert(videos.some(message => message.type === 'video-open'));
58
+ assert(frames.some(tile => tile.height > 36 && tile.videoLayer),
59
+ 'independent video playback alone must not block idle prepaint');
60
+ } else {
61
+ await renderer.evaluate('setTimeout(()=>document.documentElement.setAttribute("data-bookkeeping","changed"),1)');
62
+ await Bun.sleep(80);
63
+ assert(frames.every(tile => tile.height === 36), 'pending frame takes priority over speculative raster');
64
+ await Bun.sleep(550);
65
+ assert(frames.some(tile => tile.height > 36), 'prepaint resumes after a queued no-op frame');
66
+ }
67
+ }
68
+ assert.deepEqual(errors, []);
69
+ process.stdout.write(`prepaint ${scenario} passed\n`);
70
+ } finally {
71
+ finishScript();
72
+ renderer.dispose();
73
+ }
@@ -0,0 +1,32 @@
1
+ // Host scripting is isolated from the test runner's globals.
2
+ import { strict as assert } from "node:assert";
3
+ import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
4
+ import { nodeOf } from "../../lib/renderer/bindings.js";
5
+ const renderer = await createPageRenderer({
6
+ fetchResource: async url => ({ ok: false, status: 404, url, body: new Uint8Array() }),
7
+ });
8
+ try {
9
+ renderer.resize(400, 300, 1);
10
+ renderer.setScripting("host");
11
+ await renderer.loadDocument({ url: "https://resource.test/", contentType: "text/html",
12
+ source: '<!doctype html><style>body{color:red}</style><body>' + '<div>Item</div>'.repeat(100) + '</body>' });
13
+ await renderer.whenLoaded();
14
+ await renderer.evaluate("document.body.setAttribute('data-warm','1');document.body.clientWidth");
15
+ const document = nodeOf(globalThis.document), query = document.querySelectorAll.bind(document);
16
+ const watched = new Set(["video, audio", "img", "style, link[rel~='stylesheet']", "iframe"]);
17
+ const queries = [];
18
+ document.querySelectorAll = selector => {
19
+ if (watched.has(selector)) queries.push(selector);
20
+ return query(selector);
21
+ };
22
+ await renderer.evaluate(`for(let i=0;i<30;i++) {
23
+ document.body.setAttribute('data-counter',String(i));
24
+ document.body.setAttribute('aria-label',String(i));
25
+ document.body.clientWidth;
26
+ }`);
27
+ assert.deepEqual(queries, [], "metadata-only geometry flushes retain resource discovery");
28
+ await renderer.evaluate("document.body.insertAdjacentHTML('beforeend','<video></video>');document.body.clientWidth");
29
+ assert.ok(queries.includes("video, audio"), "structural insertion refreshes media discovery synchronously");
30
+ assert.equal(await renderer.evaluate("document.querySelector('video').offsetWidth"), 300);
31
+ process.stdout.write("resource discovery integration passed\n");
32
+ } finally { renderer.dispose(); }
@@ -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`);