buninu-browser 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +9 -3
  2. package/lib/audio/pcm.js +11 -0
  3. package/lib/audio/pulse-client.js +7 -2
  4. package/lib/cdp-server.js +31 -7
  5. package/lib/happy-dom/document-base.js +29 -1
  6. package/lib/happy-dom/mutation-observer.js +157 -0
  7. package/lib/happy-dom/parser.js +2 -0
  8. package/lib/headless-shell.js +49 -9
  9. package/lib/layout/repaint.js +89 -0
  10. package/lib/media/mp4-keyframes.js +144 -0
  11. package/lib/media/video-keyframe.js +29 -1
  12. package/lib/media/webm-snapshots.js +282 -0
  13. package/lib/network/cookie-jar.js +18 -2
  14. package/lib/paint/display-list.js +4 -3
  15. package/lib/paint/frame.js +12 -5
  16. package/lib/render-tree/index.js +3 -0
  17. package/lib/renderer/animation-frames.js +76 -0
  18. package/lib/renderer/bindings.js +2 -0
  19. package/lib/renderer/compositor-channel.js +95 -10
  20. package/lib/renderer/compositor.js +54 -18
  21. package/lib/renderer/document-cookies.js +87 -0
  22. package/lib/renderer/font-loading.js +60 -16
  23. package/lib/renderer/host.js +48 -19
  24. package/lib/renderer/idle-callbacks.js +109 -0
  25. package/lib/renderer/main.js +5 -1
  26. package/lib/renderer/media-source.js +4 -0
  27. package/lib/renderer/page-realm.js +159 -49
  28. package/lib/renderer/page-renderer.js +567 -197
  29. package/lib/renderer/resource-elements.js +88 -0
  30. package/lib/renderer/script-rewrite.js +18 -1
  31. package/lib/renderer/seccomp.js +6 -4
  32. package/lib/renderer/timers.js +71 -0
  33. package/lib/renderer/video-channel.js +94 -0
  34. package/lib/renderer/video-mask.js +115 -0
  35. package/lib/renderer/video-presentation.js +304 -39
  36. package/lib/renderer/video-worker.js +46 -0
  37. package/lib/renderer/workers.js +7 -17
  38. package/lib/style/computed-style.js +257 -61
  39. package/package.json +1 -1
  40. package/test/animation-frames.test.js +67 -0
  41. package/test/canvas-style-snapshot.test.js +18 -0
  42. package/test/cdp-stream.test.js +96 -3
  43. package/test/compositor-channel-ownership.test.js +194 -0
  44. package/test/compositor.test.js +27 -1
  45. package/test/computed-style-binding.test.js +49 -0
  46. package/test/computed-style-connectivity.test.js +13 -0
  47. package/test/computed-style.test.js +86 -0
  48. package/test/cookie-sync.test.js +82 -0
  49. package/test/document-base-cache.test.js +103 -0
  50. package/test/document-cookies.test.js +117 -0
  51. package/test/duplicate-style-keys.test.js +11 -0
  52. package/test/fixtures/animation-frames.js +56 -0
  53. package/test/fixtures/canvas-style-snapshot.js +81 -0
  54. package/test/fixtures/computed-style-connectivity.js +101 -0
  55. package/test/fixtures/computed-style-lazy.js +135 -0
  56. package/test/fixtures/duplicate-style-keys.js +97 -0
  57. package/test/fixtures/fake-renderer.js +12 -0
  58. package/test/fixtures/font-definition-invalidation.js +97 -0
  59. package/test/fixtures/font-metrics-invalidation.js +87 -0
  60. package/test/fixtures/full-style-identity.js +36 -0
  61. package/test/fixtures/hidden-style-engine.js +260 -0
  62. package/test/fixtures/hidden-style-reentry.js +232 -0
  63. package/test/fixtures/hidden-style-renderer.js +203 -0
  64. package/test/fixtures/layout-repaint.js +69 -0
  65. package/test/fixtures/media-presentation-continuation.js +141 -0
  66. package/test/fixtures/media-presentation-refill.js +113 -0
  67. package/test/fixtures/media-probe.js +148 -0
  68. package/test/fixtures/media-refill-cancel.js +79 -0
  69. package/test/fixtures/media-repaint.js +48 -0
  70. package/test/fixtures/media-resource-reselection.js +94 -0
  71. package/test/fixtures/media-source-attachment.js +136 -0
  72. package/test/fixtures/media-source-no-decoder.js +17 -0
  73. package/test/fixtures/media-track-refill.js +132 -0
  74. package/test/fixtures/media-webm-seek.js +51 -0
  75. package/test/fixtures/mutation-observer.js +201 -0
  76. package/test/fixtures/observer-invalidation.js +105 -0
  77. package/test/fixtures/prepaint-scheduling.js +73 -0
  78. package/test/fixtures/resource-discovery.js +32 -0
  79. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  80. package/test/fixtures/style-propagation.js +26 -0
  81. package/test/fixtures/svg-node-reuse.js +83 -0
  82. package/test/fixtures/video-black-replay.js +140 -0
  83. package/test/fixtures/video-controls-invalidation.js +71 -0
  84. package/test/fixtures/viewport-bounds.js +148 -0
  85. package/test/font-loading.test.js +77 -0
  86. package/test/font-metrics-invalidation.test.js +60 -0
  87. package/test/full-style-identity.test.js +191 -0
  88. package/test/hidden-style-lazy.test.js +31 -0
  89. package/test/hidden-style-reentry.test.js +20 -0
  90. package/test/idle-callback.test.js +89 -0
  91. package/test/idle-scheduler.test.js +148 -0
  92. package/test/inline-style-cascade.test.js +63 -0
  93. package/test/layout-repaint-renderer.test.js +9 -0
  94. package/test/layout-repaint.test.js +222 -0
  95. package/test/media-presentation-continuation.test.js +14 -0
  96. package/test/media-presentation-refill.test.js +8 -0
  97. package/test/media-probe.test.js +16 -0
  98. package/test/media-repaint.test.js +8 -0
  99. package/test/media-source-attachment.test.js +34 -0
  100. package/test/media-track-refill.test.js +26 -0
  101. package/test/media-webm-seek.test.js +11 -0
  102. package/test/mp4-keyframes.test.js +36 -0
  103. package/test/mutation-observer.test.js +91 -0
  104. package/test/observer-invalidation.test.js +12 -0
  105. package/test/page-scripts.test.js +35 -0
  106. package/test/page-timers.test.js +67 -0
  107. package/test/paint-frame.test.js +18 -1
  108. package/test/pcm.test.js +63 -0
  109. package/test/prepaint-scheduling.test.js +13 -0
  110. package/test/pulse-client.test.js +165 -0
  111. package/test/raw-capture.test.js +34 -5
  112. package/test/renderer-host.test.js +51 -0
  113. package/test/resource-discovery.test.js +12 -0
  114. package/test/resource-elements.test.js +234 -0
  115. package/test/root-self-style.test.js +104 -0
  116. package/test/script-rewrite.test.js +35 -0
  117. package/test/seccomp.test.js +45 -0
  118. package/test/spare-renderer-scheduling.test.js +8 -0
  119. package/test/style-propagation.test.js +225 -0
  120. package/test/style-sheets.test.js +19 -0
  121. package/test/svg.test.js +45 -0
  122. package/test/timers.test.js +58 -0
  123. package/test/video-black-replay.test.js +34 -0
  124. package/test/video-capture.test.js +170 -0
  125. package/test/video-channel.test.js +253 -0
  126. package/test/video-continuation.test.js +57 -0
  127. package/test/video-controls-invalidation.test.js +12 -0
  128. package/test/video-keyframe.test.js +104 -4
  129. package/test/video-mask.test.js +94 -0
  130. package/test/video-presentation.test.js +239 -1
  131. package/test/video-timing-presentation.test.js +115 -0
  132. package/test/viewport-bounds.test.js +13 -0
  133. package/test/webm-snapshots.test.js +372 -0
@@ -0,0 +1,132 @@
1
+ // Host scripting globals are isolated from the test runner.
2
+ import {strict as assert} from 'node:assert';
3
+ import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
4
+ import {createBytePipe} from '../../lib/media/byte-pipe.js';
5
+
6
+ const sleep = Bun.sleep, pipe = createBytePipe();
7
+ pipe.append(new Uint8Array(1024));
8
+ // A shared pipe with MSE-like epochs. Epoch 0's decoder may have more
9
+ // frames than its boundary; the renderer must stop it exactly at 3 seconds.
10
+ let thirdEpoch = false;
11
+ pipe.snapshotAt = time => {
12
+ const id = time < 3 ? 1 : time < 6 || !thirdEpoch ? 2 : 3;
13
+ return {id, startTime:(id-1)*3, endTime:id < 3 ? id*3 : Infinity,
14
+ bytes:new Uint8Array([id, pipe.size / 1024])};
15
+ };
16
+ const outputs = [], messages = [], audioDecoders = [], errors = [];
17
+ let finishVideoOpen, holdVideo = false, failAudio = false;
18
+ const renderer = await createPageRenderer({
19
+ onFrame() {}, onVideo:message => messages.push(message),
20
+ fetchResource:async () => ({ok:true, contentType:'video/mp4', body:pipe}),
21
+ reportError:error => errors.push(String(error)),
22
+ videoDecoder:{
23
+ probe:async () => ({duration:30, video:{width:20,height:12}, audio:{sampleRate:1000}}),
24
+ async decode() {
25
+ if (holdVideo) {holdVideo=false; await new Promise(resolve => {finishVideoOpen=resolve;});}
26
+ return {width:20,height:12,fps:10, seek() {}, close() {},
27
+ nextFrame:(w,h) => new Uint8Array(w*h*4)};
28
+ },
29
+ async decodeAudio(bytes) {
30
+ if (failAudio) throw Error('decoder failure after the completed input');
31
+ const start = (bytes[0]-1)*3000;
32
+ const record = {position:start, end:start+bytes[1]*2000, closed:false};
33
+ audioDecoders.push(record);
34
+ return {sampleRate:1000,channels:1, close() {record.closed=true;},
35
+ nextSamples() {
36
+ if (record.position >= record.end) return null;
37
+ return Float32Array.from({length:Math.min(100,record.end-record.position)},
38
+ () => (record.position++ % 1000) / 1024);
39
+ }};
40
+ },
41
+ },
42
+ audio:{
43
+ open(options, listener) {outputs.push({listener,pcm:[]});return outputs.length;},
44
+ write(id, bytes) {outputs[id-1].pcm.push(...new Int16Array(bytes.buffer,bytes.byteOffset,bytes.byteLength/2));},
45
+ close(id) {return Promise.resolve(outputs[id-1].pcm.length*2);}, finish() {},
46
+ },
47
+ });
48
+ const current = () => messages.filter(message => message.type === 'video-open').at(-1);
49
+ const waitFor = async condition => {
50
+ for(let n=0;n<200;n++) {if(condition())return;await sleep(5);}
51
+ assert.fail(`timed out: ${condition}; ${JSON.stringify({audioDecoders,errors})}`);
52
+ };
53
+ const clock = seconds => outputs[0].listener({event:'timing',seconds,sentSeconds:10});
54
+ const request = samples => outputs[0].listener({event:'request',bytes:samples*2});
55
+ const startRefill = time => {
56
+ pipe.append(new Uint8Array(1024));
57
+ holdVideo=true;finishVideoOpen=null;
58
+ clock(time);
59
+ renderer.videoReport({id:current().id,end:time});
60
+ };
61
+ try {
62
+ renderer.resize(240,180,1);renderer.setScripting('host');
63
+ await renderer.loadDocument({url:'https://track-refill.test/',contentType:'text/html',source:
64
+ '<style>video{width:100px;height:60px;display:block}</style><video id=v src="clip.mp4"></video>'});
65
+ await renderer.whenLoaded();
66
+ await renderer.evaluate('document.getElementById("v").play()');
67
+ request(500);
68
+ await waitFor(() => Boolean(current()));
69
+ assert.equal(audioDecoders.length,1);
70
+ const first = audioDecoders[0], firstId = current().id;
71
+
72
+ // Appending shared bytes does not make the sound decoder unusable. A small
73
+ // request queued behind video's async open must consume that SAME decoder.
74
+ startRefill(0.4);
75
+ await waitFor(() => Boolean(finishVideoOpen));
76
+ request(500);
77
+ finishVideoOpen();
78
+ await waitFor(() => current().id !== firstId && outputs[0].pcm.length === 1000);
79
+ assert.equal(audioDecoders.length,1,'video EOF does not reopen usable audio');
80
+ assert.equal(first.closed,false);
81
+ assert.equal(await renderer.evaluate('document.getElementById("v").currentTime'),0.4);
82
+
83
+ // Audio reaches its own old snapshot's EOF while draining a request held
84
+ // during another video refill. It must reopen once and continue at sample
85
+ // 2000, preserving both the in-flight request and the output stream.
86
+ const secondId = current().id;
87
+ startRefill(0.5);
88
+ await waitFor(() => Boolean(finishVideoOpen));
89
+ request(1500);
90
+ finishVideoOpen();
91
+ await waitFor(() => current().id !== secondId && outputs[0].pcm.length === 2500);
92
+ assert.equal(audioDecoders.length,2,'audio itself still refills at EOF');
93
+ assert.equal(first.closed,true);
94
+ assert.equal(outputs.length,1,'decoder refill keeps its sample clock/output stream');
95
+ assert.equal(await renderer.evaluate('document.getElementById("v").currentTime'),0.5);
96
+
97
+ // An epoch boundary must trigger a new configuration even when the old
98
+ // decoder still has PCM and no new append occurred since its last reopen.
99
+ request(1000);
100
+ await waitFor(() => outputs[0].pcm.length === 3500);
101
+ assert.equal(audioDecoders.length,3);
102
+ assert.equal(audioDecoders[1].closed,true);
103
+ assert.equal(audioDecoders[2].position,3500);
104
+ assert.equal(outputs.length,1);
105
+ for(let i=0;i<3500;i++) assert.equal(outputs[0].pcm[i],Math.round((i%1000)/1024*32767),`sample ${i}`);
106
+ assert.deepEqual(errors,[]);
107
+
108
+ // An exhausted range with no different snapshot must wait for an append,
109
+ // without reopening that same epoch in a loop. Then resume at its exact
110
+ // next sample when a genuinely different epoch arrives.
111
+ request(3000);
112
+ await sleep(80);
113
+ assert.equal(outputs[0].pcm.length,6000);
114
+ assert.equal(audioDecoders.length,3);
115
+ thirdEpoch=true;
116
+ pipe.append(new Uint8Array(1024));
117
+ await waitFor(() => outputs[0].pcm.length === 6500);
118
+ assert.equal(audioDecoders.length,4);
119
+ assert.equal(outputs.length,1);
120
+ for(let i=3500;i<6500;i++) assert.equal(outputs[0].pcm[i],Math.round((i%1000)/1024*32767),`sample ${i}`);
121
+
122
+ // Exhausting a finished input must surface a subsequent decoder failure,
123
+ // rather than treating it as another append or swallowing the error.
124
+ pipe.finish(); failAudio=true;
125
+ request(9000);
126
+ await waitFor(() => errors.length > 0);
127
+ assert.match(errors[0],/decoder failure after the completed input/);
128
+ await sleep(80);
129
+ assert.equal(errors.length,1,'fatal refill is reported once instead of restarting an EOF loop');
130
+ assert.equal(await renderer.evaluate('document.getElementById("v").paused'),true);
131
+ process.stdout.write('track-independent refill integration passed\n');
132
+ } finally {renderer.dispose();}
@@ -0,0 +1,51 @@
1
+ import {strict as assert} from 'node:assert';
2
+ import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
3
+ import {createMediaSourceScope} from '../../lib/renderer/media-source.js';
4
+ import {createWebMStream} from '../../lib/media/webm-stream.js';
5
+ import {floatToS16} from '../../lib/audio/pcm.js';
6
+ import wasmpeg from '../../vendor/wasmpeg/src/js/index.js';
7
+
8
+ const sleep=Bun.sleep, tasks=[];
9
+ await wasmpeg.load();
10
+ const fixture=await Bun.file(new URL('./mse-audio-chirp.webm',import.meta.url)).bytes();
11
+ const elements=createWebMStream().append(fixture),clusters=elements.filter(e=>e.id===0x1f43b675);
12
+ const scope=createMediaSourceScope({origin:'https://gap.test',DOMException,schedule:fn=>tasks.push(fn)});
13
+ const flush=()=>{while(tasks.length)tasks.shift()()};
14
+ const source=new scope.MediaSource();source.attach('blob:https://gap.test/audio');flush();
15
+ const buffer=source.addSourceBuffer('audio/webm; codecs="opus"');
16
+ for(const e of [...elements.slice(0,elements.indexOf(clusters[0])),clusters[0],clusters[3]]){buffer.appendBuffer(e.bytes);flush();}
17
+ const pipe=buffer._pipe, selected=pipe.snapshotAt(7);
18
+ const reference=await wasmpeg.decodeAudio(selected.bytes,{format:'webm'});
19
+ let skip=Math.round((7-selected.startTime)*reference.sampleRate),expected;
20
+ try {for(let samples;samples=reference.nextSamples();){if(skip>=samples.length){skip-=samples.length;continue;}expected=floatToS16(samples.subarray(skip,skip+100));break;}}
21
+ finally{reference.close();}
22
+ const outputs=[],errors=[];
23
+ const renderer=await createPageRenderer({
24
+ fetchResource:async()=>({ok:true,body:pipe,contentType:'audio/webm'}),videoDecoder:wasmpeg,
25
+ reportError:error=>errors.push(String(error)),
26
+ audio:{open(options,listener){outputs.push({listener,pcm:[]});return outputs.length;},
27
+ write(id,bytes){outputs[id-1].pcm.push(bytes.slice());},finish(){},close(){return Promise.resolve(0);}},
28
+ });
29
+ const wait=async condition=>{for(let i=0;i<200;i++){if(await condition())return;await sleep(5);}assert.fail(`timed out; ${errors}`);};
30
+ try{
31
+ renderer.setScripting('host');
32
+ await renderer.loadDocument({url:'https://gap.test/',contentType:'text/html',source:'<audio id=a src="sparse.webm"></audio>'});
33
+ await renderer.evaluate('document.getElementById("a").play()');
34
+ assert.equal(outputs.length,1);outputs[0].listener({event:'request',bytes:4800});
35
+ outputs[0].listener({event:'timing',seconds:.025,sentSeconds:.05});
36
+ await renderer.evaluate('globalThis.seekDone=0;const a=document.getElementById("a");a.addEventListener("seeked",()=>seekDone++);a.currentTime=7');
37
+ await wait(()=>outputs.length===2);
38
+ assert.equal(await renderer.evaluate('seekDone'),1,'the sparse seek completed');
39
+ outputs[1].listener({event:'request',bytes:200});
40
+ assert.deepEqual(outputs[1].pcm[0],expected,'resumed stream begins at the original timeline sample');
41
+ outputs[1].listener({event:'timing',seconds:.001,sentSeconds:.002});
42
+ assert.equal(await renderer.evaluate('document.getElementById("a").currentTime'),7.001);
43
+ await renderer.evaluate('document.getElementById("a").pause();document.getElementById("a").currentTime=.5');
44
+ await wait(async()=>await renderer.evaluate('seekDone')===2);
45
+ assert.equal(await renderer.evaluate('document.getElementById("a").currentTime'),.5);
46
+ assert.equal(outputs.length,2,'paused backwards seek does not open output');
47
+ await renderer.evaluate('document.getElementById("a").play()');
48
+ assert.equal(outputs.length,3,'play after backwards seek reopens audio normally');
49
+ assert.deepEqual(errors,[]);
50
+ process.stdout.write('sparse WebM seek and playback passed\n');
51
+ }finally{pipe.finish();renderer.dispose();}
@@ -0,0 +1,201 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createPageRenderer } from '../../lib/renderer/page-renderer.js';
3
+
4
+ async function mutationObserverCases() {
5
+ const results = {};
6
+ const tick = () => new Promise(resolve => setTimeout(resolve, 0));
7
+ const describe = records => records.map(r => [r.type, r.target.id, r.attributeName, r.oldValue,
8
+ Array.from(r.addedNodes, n => n.id || n.nodeName), Array.from(r.removedNodes, n => n.id || n.nodeName)]);
9
+ async function run(name, fn) {
10
+ const root = document.createElement('div');root.id='root';
11
+ root.innerHTML='<div id="a"><span id="child"></span></div><div id="b"></div>';
12
+ document.body.appendChild(root);
13
+ const observers=[];
14
+ const make = callback => {const o=new MutationObserver(callback);observers.push(o);return o;};
15
+ try { results[name]=await fn(root,make); }
16
+ finally {for(const observer of observers)observer.disconnect();root.remove();await tick();}
17
+ }
18
+ await run('multipleTargets',async(root,make)=>{
19
+ const [a,b]=root.children,batches=[];const o=make(r=>batches.push(describe(r)));
20
+ o.observe(a,{attributes:true});o.observe(b,{attributes:true});
21
+ b.setAttribute('x','1');a.setAttribute('y','2');b.setAttribute('z','3');await tick();return batches;
22
+ });
23
+ await run('takeRecords',async(root,make)=>{
24
+ const [a,b]=root.children,batches=[];const o=make(r=>batches.push(describe(r)));
25
+ o.observe(a,{attributes:true});o.observe(b,{attributes:true});
26
+ b.setAttribute('x','1');a.setAttribute('y','2');b.setAttribute('z','3');
27
+ const records=describe(o.takeRecords());await tick();return {records,batches,empty:o.takeRecords().length};
28
+ });
29
+ await run('overlappingTargets',async(root,make)=>{
30
+ const child=root.querySelector('#child'),batches=[];child.setAttribute('value','old');
31
+ const o=make(r=>batches.push(describe(r)));
32
+ o.observe(root,{subtree:true,attributes:true,attributeOldValue:true});o.observe(child,{attributes:true});
33
+ child.setAttribute('value','new');await tick();return batches;
34
+ });
35
+ await run('creationAndPromiseOrder',async(root,make)=>{
36
+ const [a,b]=root.children,events=[];
37
+ const older=make(()=>events.push('older')),newer=make(()=>events.push('newer'));
38
+ newer.observe(b,{attributes:true});older.observe(a,{attributes:true});
39
+ b.setAttribute('x','1');Promise.resolve().then(()=>events.push('promise'));a.setAttribute('x','1');
40
+ await tick();return events;
41
+ });
42
+ await run('callbackMutation',async(root,make)=>{
43
+ const [a,b]=root.children,events=[];let calls=0;
44
+ const o=make(r=>{events.push(describe(r));if(++calls===1){a.setAttribute('second','a');b.setAttribute('second','b');}});
45
+ o.observe(a,{attributes:true});o.observe(b,{attributes:true});
46
+ a.setAttribute('first','1');Promise.resolve().then(()=>events.push('promise'));
47
+ await tick();return events;
48
+ });
49
+ await run('callbackMutatesLaterObserver',async(root,make)=>{
50
+ const [a,b]=root.children,events=[];
51
+ const first=make(r=>{events.push(['first',describe(r)]);b.setAttribute('later','2');});
52
+ const second=make(r=>events.push(['second',describe(r)]));
53
+ first.observe(a,{attributes:true});second.observe(b,{attributes:true});
54
+ b.setAttribute('initial','1');a.setAttribute('initial','1');await tick();return events;
55
+ });
56
+ await run('disconnectReobserve',async(root,make)=>{
57
+ const [a,b]=root.children,batches=[];const o=make(r=>batches.push(describe(r)));
58
+ o.observe(a,{attributes:true});a.setAttribute('discard','1');o.disconnect();
59
+ o.observe(b,{attributes:true});b.setAttribute('keep','2');await tick();return batches;
60
+ });
61
+ await run('drainOtherObserver',async(root,make)=>{
62
+ const [a,b]=root.children,events=[];let second;
63
+ const first=make(()=>events.push(['drained',describe(second.takeRecords())]));
64
+ second=make(r=>events.push(['second',describe(r)]));
65
+ first.observe(a,{attributes:true});second.observe(b,{attributes:true});
66
+ b.setAttribute('x','1');a.setAttribute('x','1');await tick();return events;
67
+ });
68
+ await run('reobserveOptions',async(root,make)=>{
69
+ const a=root.firstElementChild,batches=[];const o=make(r=>batches.push(describe(r)));
70
+ o.observe(a,{attributes:true,attributeFilter:['x']});a.setAttribute('x','first');
71
+ o.observe(a,{attributes:true,attributeFilter:['y']});a.setAttribute('x','ignored');a.setAttribute('y','kept');
72
+ await tick();return batches;
73
+ });
74
+ for(const childList of [false,true])await run('detachedSubtree'+childList,async(root,make)=>{
75
+ const a=root.firstElementChild,child=a.firstElementChild,batches=[];
76
+ const o=make(r=>batches.push(describe(r)));o.observe(root,{subtree:true,childList,attributes:true});
77
+ a.remove();child.setAttribute('before','1');await tick();child.setAttribute('after','2');await tick();return batches;
78
+ });
79
+ await run('moveAndEmpty',async(root,make)=>{
80
+ const [a,b]=root.children,child=a.firstElementChild,batches=[];
81
+ const o=make(r=>batches.push(describe(r)));o.observe(root,{subtree:true,childList:true,attributes:true});
82
+ a.remove();a.removeChild(child);b.appendChild(child);child.setAttribute('before','1');
83
+ await tick();child.setAttribute('after','2');await tick();return batches;
84
+ });
85
+ await run('detachedTarget',async(root,make)=>{
86
+ const a=root.firstElementChild,batches=[];a.remove();
87
+ const o=make(r=>batches.push(describe(r)));o.observe(a,{attributes:true});a.setAttribute('x','1');await tick();return batches;
88
+ });
89
+ await run('exceptionIsolation',async(root,make)=>{
90
+ const [a,b]=root.children,events=[];
91
+ const handler=e=>e.preventDefault();window.addEventListener('error',handler);
92
+ const first=make(()=>{events.push('first');throw new Error('observer-fixture-expected');});
93
+ const second=make(()=>events.push('second'));first.observe(a,{attributes:true});second.observe(b,{attributes:true});
94
+ a.setAttribute('x','1');b.setAttribute('x','1');await tick();window.removeEventListener('error',handler);return events;
95
+ });
96
+ await run('omittedAttributeOptions',async(root,make)=>{
97
+ const a=root.firstElementChild,events=[];
98
+ const o=make(r=>events.push(describe(r)));o.observe(a,{attributeOldValue:false});
99
+ a.setAttribute('x','first');a.setAttribute('x','second');await tick();return events;
100
+ });
101
+ await run('omittedCharacterOptions',async(root,make)=>{
102
+ const a=root.firstElementChild,text=document.createTextNode('old'),events=[];a.appendChild(text);
103
+ const o=make(r=>events.push(r.map(x=>[x.type,x.oldValue])));o.observe(text,{characterDataOldValue:false});
104
+ text.data='new';await tick();return events;
105
+ });
106
+ await run('invalidCallback',async()=>[null,undefined,{},1,'x'].map(value=>{
107
+ try {new MutationObserver(value);return false;} catch(error){return error.name;}
108
+ }));
109
+ await run('filterCase',async(root,make)=>{
110
+ const a=root.firstElementChild,events=[];const o=make(r=>events.push(describe(r)));
111
+ o.observe(a,{attributes:true,attributeFilter:['UPPER']});a.setAttribute('UPPER','1');
112
+ await tick();return events;
113
+ });
114
+ await run('reobserveSubtree',async(root,make)=>{
115
+ const a=root.firstElementChild,child=a.firstElementChild,events=[];const o=make(r=>events.push(describe(r)));
116
+ o.observe(root,{attributes:true,subtree:true});a.remove();
117
+ o.observe(root,{attributes:true,subtree:false});child.setAttribute('ignored','1');root.setAttribute('yes','1');
118
+ root.appendChild(a);child.setAttribute('also-ignored','1');await tick();
119
+ o.observe(root,{attributes:true,subtree:true});child.setAttribute('kept','1');await tick();return events;
120
+ });
121
+ await run('transientDuringCallback',async(root,make)=>{
122
+ const a=root.firstElementChild,child=a.firstElementChild,events=[];
123
+ const o=make(r=>{events.push(describe(r));child.setAttribute('callback','ignored');});
124
+ o.observe(root,{attributes:true,subtree:true,childList:true});a.remove();await tick();return events;
125
+ });
126
+ await run('takeRecordsKeepsTransient',async(root,make)=>{
127
+ const a=root.firstElementChild,child=a.firstElementChild,events=[];const o=make(r=>events.push(describe(r)));
128
+ o.observe(root,{attributes:true,subtree:true,childList:true});a.remove();const drained=describe(o.takeRecords());
129
+ child.setAttribute('before','yes');await tick();child.setAttribute('after','ignored');await tick();return {drained,events};
130
+ });
131
+ await run('optionValidation',async(root,make)=>{
132
+ const a=root.firstElementChild,callable=function(){};
133
+ callable[Symbol.iterator]=function*(){yield 'x'};
134
+ const options=[{}, {attributes:false,attributeOldValue:false},
135
+ {childList:true,attributes:false,attributeFilter:[]}, {characterData:false,characterDataOldValue:true},
136
+ {attributeFilter:[]}, {attributeFilter:new Set(['x'])}, {attributeFilter:callable},
137
+ {attributeFilter:'x'}, {attributeFilter:{length:1,0:'x'}}, {attributeFilter:null},
138
+ {attributeOldValue:undefined}, {characterDataOldValue:undefined},
139
+ {childList:true,attributes:false,attributeOldValue:false}, {attributeFilter:[Symbol('x')]}];
140
+ return options.map((option,i)=>{const observer=make(()=>{});try{
141
+ observer.observe(a,option);a.setAttribute('x',String(i));const records=observer.takeRecords();
142
+ observer.disconnect();return ['ok',records.map(record=>record.attributeName)];
143
+ }catch(error){return error.name;}});
144
+ });
145
+ await run('optionConversionOrder',async(root,make)=>{
146
+ const events=[],options={},filter={get [Symbol.iterator](){events.push('iterator');return function*(){
147
+ events.push('first');yield {toString(){events.push('string');return 'x'}};events.push('end');
148
+ }}};
149
+ for(const key of ['attributeFilter','attributeOldValue','attributes','characterData','characterDataOldValue','childList','subtree']){
150
+ Object.defineProperty(options,key,{get(){events.push(key);return key==='attributeFilter'?filter:undefined}});
151
+ }
152
+ make(()=>{}).observe(root,options);return events;
153
+ });
154
+ return results;
155
+ }
156
+
157
+ const expected = {
158
+ "multipleTargets": [[["attributes","b","x",null,[],[]],["attributes","a","y",null,[],[]],["attributes","b","z",null,[],[]]]],
159
+ "takeRecords": {"records":[["attributes","b","x",null,[],[]],["attributes","a","y",null,[],[]],["attributes","b","z",null,[],[]]],"batches":[],"empty":0},
160
+ "overlappingTargets": [[["attributes","child","value","old",[],[]]]],
161
+ "creationAndPromiseOrder": ["older","newer","promise"],
162
+ "callbackMutation": [[["attributes","a","first",null,[],[]]],"promise",[["attributes","a","second",null,[],[]],["attributes","b","second",null,[],[]]]],
163
+ "callbackMutatesLaterObserver": [["first",[["attributes","a","initial",null,[],[]]]],["second",[["attributes","b","initial",null,[],[]],["attributes","b","later",null,[],[]]]]],
164
+ "disconnectReobserve": [[["attributes","b","keep",null,[],[]]]],
165
+ "drainOtherObserver": [["drained",[["attributes","b","x",null,[],[]]]]],
166
+ "reobserveOptions": [[["attributes","a","x",null,[],[]],["attributes","a","y",null,[],[]]]],
167
+ "detachedSubtreefalse": [[["attributes","child","before",null,[],[]]]],
168
+ "detachedSubtreetrue": [[["childList","root",null,null,[],["a"]],["attributes","child","before",null,[],[]]]],
169
+ "moveAndEmpty": [[["childList","root",null,null,[],["a"]],["childList","a",null,null,[],["child"]],["childList","b",null,null,["child"],[]],["attributes","child","before",null,[],[]]],[["attributes","child","after",null,[],[]]]],
170
+ "detachedTarget": [[["attributes","a","x",null,[],[]]]],
171
+ "exceptionIsolation": ["first","second"],
172
+ "omittedAttributeOptions": [[["attributes","a","x",null,[],[]],["attributes","a","x",null,[],[]]]],
173
+ "omittedCharacterOptions": [[["characterData",null]]],
174
+ "invalidCallback": ["TypeError","TypeError","TypeError","TypeError","TypeError"],
175
+ "filterCase": [],
176
+ "reobserveSubtree": [[["attributes","root","yes",null,[],[]]],[["attributes","child","kept",null,[],[]]]],
177
+ "transientDuringCallback": [[["childList","root",null,null,[],["a"]]]],
178
+ "takeRecordsKeepsTransient": {"drained":[["childList","root",null,null,[],["a"]]],"events":[[["attributes","child","before",null,[],[]]]]},
179
+ "optionValidation": ["TypeError","TypeError","TypeError","TypeError",["ok",[]],["ok",["x"]],["ok",["x"]],"TypeError","TypeError","TypeError","TypeError","TypeError",["ok",[]],"TypeError"],
180
+ "optionConversionOrder": ["attributeFilter","iterator","first","string","end","attributeOldValue","attributes","characterData","characterDataOldValue","childList","subtree"]
181
+ };
182
+
183
+ const errors=[];
184
+ const renderer=await createPageRenderer({reportError:error=>errors.push(String(error))});
185
+ await import('../../vendor/ses/dist/ses.mjs');
186
+ lockdown({errorTaming:'safe',overrideTaming:'severe',consoleTaming:'unsafe',localeTaming:'unsafe',stackFiltering:'concise',reporting:'none'});
187
+ try {
188
+ renderer.setScripting('ses'); renderer.resize(400,300,1);
189
+ await renderer.loadDocument({url:'https://mutation.test/',contentType:'text/html',source:'<!doctype html><body></body>'});
190
+ await renderer.whenLoaded();
191
+ const result=JSON.parse(await renderer.evaluate('('+mutationObserverCases.toString()+')().then(JSON.stringify)'));
192
+ for (const [name,value] of Object.entries(expected)) assert.deepEqual(result[name],value,name);
193
+ assert.equal(errors.length,1); assert.match(errors[0],/observer-fixture-expected/);
194
+ // Navigation gives the next realm a fresh observer constructor and delivery.
195
+ await renderer.loadDocument({url:'https://mutation.test/next',contentType:'text/html',source:'<!doctype html><body></body>'});
196
+ await renderer.whenLoaded();
197
+ assert.equal(await renderer.evaluate(`new Promise(resolve=>{
198
+ const observer=new MutationObserver(records=>{observer.disconnect();resolve(records.length)});
199
+ observer.observe(document.body,{attributes:true});document.body.setAttribute('next','1');
200
+ })`),1);
201
+ } finally { renderer.dispose(); }
@@ -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(); }