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,94 @@
1
+ // Host scripting installs browser globals; isolate each explicit selection.
2
+ import {strict as assert} from 'node:assert';
3
+ import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
4
+
5
+ const action=process.argv[2],fetches=[],errors=[];
6
+ let finishProbe,holdProbe=true,probes=0;
7
+ const renderer=await createPageRenderer({
8
+ fetchResource:async url=>{
9
+ fetches.push(url);
10
+ if(url.startsWith('blob:'))throw Error('expired object URL');
11
+ return {ok:true,contentType:'video/mp4',body:new Uint8Array(1024)};
12
+ },
13
+ videoDecoder:{
14
+ async probe(){
15
+ probes++;
16
+ if(holdProbe)await new Promise(resolve=>{finishProbe=resolve;});
17
+ return {duration:10,video:{width:20,height:12}};
18
+ },
19
+ async decode(){return {width:20,height:12,fps:10,close(){},seek(){},nextFrame:()=>new Uint8Array(20*12*4)};},
20
+ },
21
+ reportError:error=>errors.push(String(error)),
22
+ });
23
+ const evaluate=source=>renderer.evaluate(source);
24
+ const waitFor=async condition=>{
25
+ for(let n=0;n<200;n++){if(await condition())return;await Bun.sleep(5);}
26
+ assert.fail(`timed out: ${condition}; fetches=${fetches}, errors=${errors}`);
27
+ };
28
+ try{
29
+ renderer.resize(200,120,1);renderer.setScripting('host');
30
+ await renderer.loadDocument({url:'https://selection.test/',contentType:'text/html',source:
31
+ '<base href="https://selection.test/first/"><video id=v></video>'});
32
+ await renderer.whenLoaded();
33
+ await evaluate(`globalThis.v=document.getElementById('v');v.src='clip.mp4';void v.play();`);
34
+ await waitFor(()=>Boolean(finishProbe));
35
+ await evaluate(`document.querySelector('base').href='https://selection.test/second/';`);
36
+ finishProbe();
37
+ await waitFor(()=>evaluate('v.duration===10'));
38
+ assert.deepEqual(fetches,['https://selection.test/first/clip.mp4'],
39
+ 'base alone must preserve the resource chosen before the pending probe');
40
+ await evaluate('v.pause();void v.play();');
41
+ assert.equal(fetches.length,1,'ordinary play must not reselect because base changed');
42
+
43
+ finishProbe=null;
44
+ await evaluate('v.pause()');
45
+ if(action==='src')await evaluate(`v.src='clip.mp4';`);
46
+ else if(action==='attribute')await evaluate(`v.setAttribute('src','clip.mp4');`);
47
+ else if(action==='load')await evaluate('v.load()');
48
+ else assert.fail(`unknown action ${action}`);
49
+ assert.equal(await evaluate('v.paused'),true);
50
+ if(action!=='load')await evaluate('void v.play()');
51
+ await waitFor(()=>Boolean(finishProbe));
52
+ assert.deepEqual(fetches,['https://selection.test/first/clip.mp4','https://selection.test/second/clip.mp4']);
53
+ finishProbe();
54
+ await waitFor(()=>evaluate('v.duration===10'));
55
+
56
+ if(action==='load'){
57
+ assert.equal(await evaluate('v.paused'),true,'load must not start playback');
58
+ finishProbe=null;
59
+ await evaluate('v.load()');
60
+ await waitFor(()=>Boolean(finishProbe));
61
+ assert.equal(fetches.length,3,'load explicitly reloads even an unchanged resolved URL');
62
+ finishProbe();await waitFor(()=>evaluate('v.duration===10'));
63
+ holdProbe=false;
64
+
65
+ // srcObject wins over both the property and attribute setters, including
66
+ // explicit load. A real object never needs its object URL registry entry.
67
+ await evaluate(`globalThis.ms=new MediaSource();v.srcObject=ms;
68
+ globalThis.append=source=>{const sb=source.addSourceBuffer('video/mp4; codecs="avc1.4d401e"');
69
+ const bytes=new Uint8Array(1024);new DataView(bytes.buffer).setUint32(0,1024);bytes.set([109,100,97,116],4);sb.appendBuffer(bytes);};
70
+ append(ms);`);
71
+ await waitFor(()=>evaluate('v.duration===10'));
72
+ await evaluate(`v.src='ignored.mp4';v.setAttribute('src','also-ignored.mp4');v.load();`);
73
+ await waitFor(()=>evaluate('v.readyState>=3'));
74
+ assert.equal(fetches.length,3);
75
+ assert.equal(await evaluate('v.srcObject===ms'),true);
76
+
77
+ // Revocation is harmless until an explicit load starts a new selection.
78
+ await evaluate(`v.srcObject=null;globalThis.other=new MediaSource();v.src=URL.createObjectURL(other);append(other);`);
79
+ await waitFor(()=>evaluate('v.duration===10'));
80
+ await evaluate('URL.revokeObjectURL(v.src);other.duration=20');
81
+ assert.equal(await evaluate('v.duration'),20);
82
+ await evaluate('v.load()');
83
+ await waitFor(()=>errors.length===1);
84
+ assert.equal(fetches.length,4);
85
+ assert.match(fetches[3],/^blob:/);
86
+ const before=probes;
87
+ await evaluate('other.duration=30;append(other)');
88
+ await Bun.sleep(30);
89
+ assert.equal(probes,before,'a revoked source cannot reattach through a late append');
90
+ assert.equal(await evaluate('Number.isNaN(v.duration)'),true);
91
+ assert.match(errors[0],/expired object URL/);
92
+ }else assert.deepEqual(errors,[]);
93
+ process.stdout.write(`Media resource reselection ${action} passed\n`);
94
+ }finally{renderer.dispose();}
@@ -0,0 +1,136 @@
1
+ // Host scripting installs browser globals; keep this fixture isolated.
2
+ import {strict as assert} from 'node:assert';
3
+ import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
4
+
5
+ const sleep=Bun.sleep, probes=[],errors=[],fetches=[];
6
+ let finishProbe;
7
+ const renderer=await createPageRenderer({
8
+ fetchResource:async url=>{
9
+ fetches.push(url);const body=new Uint8Array(1024);body[8]=10;
10
+ return {ok:true,contentType:'video/mp4',body};
11
+ },
12
+ videoDecoder:{
13
+ async probe(bytes) {
14
+ probes.push(bytes[8]);
15
+ if(bytes[8]===8||bytes[8]===10)await new Promise(resolve=>{finishProbe=resolve;});
16
+ return {duration:bytes[8]*100,video:{width:20,height:12}};
17
+ },
18
+ async decode() {return {width:20,height:12,fps:10,close(){},seek(){},nextFrame:()=>new Uint8Array(20*12*4)};},
19
+ },
20
+ reportError:error=>errors.push(String(error)),
21
+ });
22
+ const evaluate=source=>renderer.evaluate(source);
23
+ const duration=()=>evaluate('document.getElementById("v").duration');
24
+ const waitFor=async condition=>{
25
+ for(let n=0;n<200;n++){if(await condition())return;await sleep(5);}
26
+ assert.fail(`timed out: ${condition}; probes=${probes}, errors=${errors}`);
27
+ };
28
+ try {
29
+ renderer.resize(200,120,1);renderer.setScripting('host');
30
+ await renderer.loadDocument({url:'https://attachment.test/',contentType:'text/html',source:'<video id=v></video>'});
31
+ await renderer.whenLoaded();
32
+ await evaluate(`
33
+ globalThis.sources=[];globalThis.buffers=[];
34
+ globalThis.makeSource=()=>{const source=new MediaSource();sources.push(source);return source;};
35
+ globalThis.append=(source,marker)=>{
36
+ let buffer=buffers.find(entry=>entry.source===source)?.buffer;
37
+ if(!buffer){buffer=source.addSourceBuffer('video/mp4; codecs="avc1.4d401e"');buffers.push({source,buffer});}
38
+ const bytes=new Uint8Array(1024);new DataView(bytes.buffer).setUint32(0,bytes.length);
39
+ bytes.set([109,100,97,116],4);bytes[8]=marker;buffer.appendBuffer(bytes);
40
+ };
41
+ globalThis.a=makeSource();globalThis.b=makeSource();
42
+ document.getElementById('v').src=URL.createObjectURL(a);a.duration=59.201;
43
+ append(a,1);
44
+ `);
45
+ await waitFor(()=>probes.length===1);
46
+ assert.equal(await duration(),59.201);
47
+ await evaluate(`document.getElementById('v').src=URL.createObjectURL(b);b.duration=1000;append(b,2);`);
48
+ await waitFor(()=>probes.length===2);
49
+ assert.equal(await duration(),1000);
50
+
51
+ // Regression: the former source still exists in JS and can finish work,
52
+ // but it no longer owns this media element's current state.
53
+ await evaluate('a.duration=59.201');
54
+ assert.equal(await duration(),1000,'old source duration must not overwrite the current source');
55
+ await evaluate('append(a,3)');
56
+ await sleep(40);
57
+ assert.equal(probes.length,2,'old append must not probe or rebind the current track pipes');
58
+ await evaluate('a.endOfStream()');
59
+ assert.equal(await duration(),1000,'old source end must not restore its shorter duration');
60
+ await evaluate('b.duration=1100');
61
+ assert.equal(await duration(),1100);
62
+
63
+ // A new source with unknown duration starts a new state: the previous
64
+ // explicit-duration override cannot suppress its own metadata probe.
65
+ await evaluate(`globalThis.c=makeSource();document.getElementById('v').src=URL.createObjectURL(c);append(c,7);`);
66
+ await waitFor(()=>probes.length===3);
67
+ assert.equal(await duration(),700,'new source metadata must replace the old duration override');
68
+ await evaluate('b.duration=17');
69
+ assert.equal(await duration(),700);
70
+
71
+ // srcObject takes precedence over the src attribute; replacing an object
72
+ // must also replace state even though that attribute's URL has not changed.
73
+ await evaluate(`globalThis.d=makeSource();document.getElementById('v').srcObject=d;d.duration=800;`);
74
+ assert.equal(await duration(),800);
75
+ await evaluate(`globalThis.e=makeSource();document.getElementById('v').src=URL.createObjectURL(e);`);
76
+ assert.equal(await duration(),800,'changing src must not displace an active srcObject');
77
+ assert.equal(await evaluate('e.readyState'),'closed');
78
+ await evaluate('c.duration=11');
79
+ assert.equal(await duration(),800);
80
+ await evaluate('document.getElementById("v").srcObject=null;e.duration=900;');
81
+ assert.equal(await duration(),900,'clearing srcObject activates the src attribute');
82
+ await evaluate('d.duration=12');
83
+ assert.equal(await duration(),900);
84
+ await evaluate(`globalThis.f=makeSource();document.getElementById('v').srcObject=f;append(f,6);`);
85
+ await waitFor(()=>probes.length===4);
86
+ assert.equal(await duration(),600,'new srcObject must not inherit a previous duration override');
87
+ await evaluate('e.duration=13');
88
+ assert.equal(await duration(),600);
89
+ await evaluate(`URL.createObjectURL(f);document.getElementById('v').src='unused.mp4';`);
90
+ assert.equal(await duration(),600,'an alias and an inactive src must not reset a live srcObject');
91
+
92
+ // Revocation prevents a future URL lookup; it does not detach the source
93
+ // already selected by the media element. Both callbacks still work.
94
+ await evaluate(`document.getElementById('v').src=URL.createObjectURL(e);document.getElementById('v').srcObject=null;URL.revokeObjectURL(document.getElementById('v').src);e.duration=1200;append(e,4);`);
95
+ await waitFor(()=>probes.length===5);
96
+ assert.equal(await duration(),1200);
97
+ await evaluate('e.duration=1300');
98
+ assert.equal(await duration(),1300);
99
+
100
+ // A probe already in flight for an old attachment cannot commit metadata
101
+ // to the replacement resource or emit its loadedmetadata on the new src.
102
+ await evaluate(`globalThis.g=makeSource();document.getElementById('v').src=URL.createObjectURL(g);append(g,8);`);
103
+ await waitFor(()=>Boolean(finishProbe));
104
+ await evaluate(`globalThis.h=makeSource();document.getElementById('v').src=URL.createObjectURL(h);h.duration=1400;
105
+ globalThis.metadataEvents=0;document.getElementById('v').addEventListener('loadedmetadata',()=>metadataEvents++);`);
106
+ finishProbe();await sleep(40);
107
+ assert.equal(await duration(),1400);
108
+ assert.equal(await evaluate('metadataEvents'),0);
109
+
110
+ // A base-URL change alone must not cancel an already selected relative
111
+ // resource while its metadata probe is pending.
112
+ finishProbe=null;
113
+ await evaluate(`const base=document.createElement('base');base.href='https://attachment.test/first/';document.head.appendChild(base);
114
+ document.getElementById('v').src='clip.mp4';void document.getElementById('v').play();`);
115
+ await waitFor(()=>Boolean(finishProbe));
116
+ await evaluate(`document.querySelector('base').href='https://attachment.test/second/';`);
117
+ finishProbe();
118
+ await waitFor(async()=>await duration()===1000);
119
+ assert.deepEqual(fetches,['https://attachment.test/first/clip.mp4']);
120
+ assert.equal(await evaluate('document.getElementById("v").paused'),false);
121
+
122
+ // Old document callbacks must not create a new hidden media state in the
123
+ // replacement document. Keep only the original source/append closures.
124
+ const old={source:globalThis.h,append:globalThis.append};
125
+ // Host mode installs globals process-wide and permits just one realm here.
126
+ // The destination is static; stale callbacks must still be harmless.
127
+ renderer.setScripting('off');
128
+ await renderer.loadDocument({url:'https://attachment.test/next',contentType:'text/html',source:'<video id=v></video>'});
129
+ await renderer.whenLoaded();
130
+ const before=probes.length;
131
+ old.source.duration=3;old.append(old.source,9);
132
+ await sleep(40);
133
+ assert.equal(probes.length,before,'old document append must not start a probe');
134
+ assert.deepEqual(errors,[]);
135
+ process.stdout.write('MediaSource attachment isolation passed\n');
136
+ } finally {renderer.dispose();}
@@ -0,0 +1,17 @@
1
+ // MSE attachment and sourceopen do not require a playback decoder.
2
+ import {strict as assert} from 'node:assert';
3
+ import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
4
+
5
+ const renderer=await createPageRenderer({});
6
+ try {
7
+ renderer.resize(200,120,1);renderer.setScripting('host');
8
+ await renderer.loadDocument({url:'https://attachment.test/',contentType:'text/html',source:'<video id=v></video>'});
9
+ await renderer.whenLoaded();
10
+ await renderer.evaluate(`globalThis.source=new MediaSource();globalThis.opened=0;
11
+ source.addEventListener('sourceopen',()=>opened++);
12
+ document.getElementById('v').src=URL.createObjectURL(source);`);
13
+ assert.equal(await renderer.evaluate('source.readyState'),'open');
14
+ for(let n=0;n<100 && !await renderer.evaluate('opened');n++)await Bun.sleep(5);
15
+ assert.equal(await renderer.evaluate('opened'),1);
16
+ process.stdout.write('Decoder-less MediaSource attachment passed\n');
17
+ } finally {renderer.dispose();}
@@ -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(); }