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,113 @@
1
+ // Isolated because host scripting installs browser globals in this process.
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
+ const decoders = [], messages = [], errors = [], outputs = [];
9
+ let holdNext = false, finishOpen;
10
+ const renderer = await createPageRenderer({
11
+ onFrame() {}, onVideo: message => messages.push(message),
12
+ fetchResource: async () => ({ok:true, contentType:'video/mp4', body:pipe}),
13
+ reportError: error => errors.push(String(error)),
14
+ videoDecoder: {
15
+ probe: async () => ({duration:30, video:{width:20,height:12}, audio:{sampleRate:1000}}),
16
+ async decode() {
17
+ const record = {seeks:[], frames:0, at:0};
18
+ const decoder = {width:20,height:12,fps:10,
19
+ seek(ms) {record.seeks.push(ms); record.at=Math.floor(ms/1000);},
20
+ nextFrame(w,h) {record.frames++; const bytes=new Uint8Array(w*h*4).fill(20+Math.floor(record.at));record.at+=0.1;return bytes;},
21
+ close() {},
22
+ };
23
+ decoders.push(record);
24
+ if (holdNext) {holdNext=false; await new Promise(resolve => {finishOpen=resolve;});}
25
+ return decoder;
26
+ },
27
+ decodeAudio: async () => ({sampleRate:1000,channels:1,
28
+ nextSamples:()=>new Float32Array(100), close() {}}),
29
+ },
30
+ audio: {
31
+ open(options, listener) {outputs.push({listener,samples:0});return outputs.length;},
32
+ write(id, bytes) {outputs[id-1].samples+=bytes.length/2;},
33
+ close(id) {return Promise.resolve(outputs[id-1].samples*2);},
34
+ finish() {},
35
+ },
36
+ });
37
+ const currentPresentation = () => messages.filter(message=>message.type==='video-open').at(-1);
38
+ const opens = () => messages.filter(message=>message.type==='video-open').length;
39
+ const waitFor = async condition => {
40
+ for (let attempt=0;attempt<200;attempt++) {if(condition())return;await sleep(5);}
41
+ assert.fail(`timed out waiting for media transition: ${condition}; ${JSON.stringify({decoders, messages:messages.map(({type,id})=>({type,id})),errors})}`);
42
+ };
43
+ const clock = seconds => outputs.at(-1).listener({event:'timing',seconds,sentSeconds:10,sinkSeconds:10-seconds});
44
+ const beginRefill = seconds => {
45
+ holdNext=true;finishOpen=null;
46
+ pipe.append(new Uint8Array(1024));
47
+ clock(seconds);
48
+ renderer.videoReport({id:currentPresentation().id,end:seconds});
49
+ };
50
+ try {
51
+ renderer.resize(240,180,1);renderer.setScripting('host');
52
+ await renderer.loadDocument({url:'https://refill.test/',contentType:'text/html',source:
53
+ '<style>body{margin:0}video{width:100px;height:60px;display:block}#second{display:none}</style><video id=v src="clip.mp4"></video><video id=second></video>'});
54
+ await renderer.whenLoaded();
55
+ await renderer.evaluate('document.getElementById("v").play()');
56
+ outputs.at(-1).listener({event:'request',bytes:20000});
57
+ await waitFor(()=>Boolean(currentPresentation()));
58
+ assert.equal(outputs.length,1);
59
+
60
+ // A real presentation refills without probing/decoding the same prefix on
61
+ // the page thread. Audio continues to own time and keeps its output stream.
62
+ const firstOpens=opens();
63
+ beginRefill(2);
64
+ await waitFor(()=>Boolean(finishOpen));
65
+ const resumed=decoders.at(-1);
66
+ finishOpen();
67
+ await waitFor(()=>opens()>firstOpens);
68
+ assert.deepEqual(resumed.seeks,[]);
69
+ assert.equal(resumed.frames,0);
70
+ assert.equal(currentPresentation().time,2);
71
+ assert.equal(await renderer.evaluate('document.getElementById("v").currentTime'),2);
72
+ assert.equal(outputs.length,1,'refilling does not reset the measured audio stream');
73
+
74
+ // Controls enabled during the async reopen prevent presentation: the lazy
75
+ // page decoder must seek before it can draw an ordinary local frame.
76
+ const controlOpens=opens();
77
+ beginRefill(3);
78
+ await waitFor(()=>Boolean(finishOpen));
79
+ await renderer.evaluate('document.getElementById("v").setAttribute("controls", "")');
80
+ const controls=decoders.at(-1);
81
+ finishOpen();
82
+ await waitFor(()=>controls.frames>0);
83
+ assert.ok(controls.seeks.length>0);
84
+ assert.equal(controls.seeks[0],3000);
85
+ assert.equal(opens(),controlOpens);
86
+ assert.equal(await renderer.evaluate('document.getElementById("v").currentTime'),3);
87
+
88
+ await renderer.evaluate('document.getElementById("v").removeAttribute("controls");document.getElementById("v").style.width="101px"');
89
+ await waitFor(()=>opens()>controlOpens);
90
+ const multipleOpens=opens();
91
+ beginRefill(4);
92
+ await waitFor(()=>Boolean(finishOpen));
93
+ await renderer.evaluate('document.getElementById("second").style.display="block"');
94
+ const multiple=decoders.at(-1);
95
+ finishOpen();
96
+ await waitFor(()=>multiple.frames>0);
97
+ assert.ok(multiple.seeks.length>0);
98
+ assert.equal(multiple.seeks[0],4000);
99
+ assert.equal(opens(),multipleOpens,'multiple visible videos retain local decoding');
100
+
101
+ await renderer.evaluate('document.getElementById("second").style.display="none"');
102
+ await waitFor(()=>opens()>multipleOpens);
103
+ const fallback=decoders.at(-1), beforeFallback=fallback.frames;
104
+ renderer.videoReport({id:currentPresentation().id,error:'simulated decoder failure'});
105
+ await waitFor(()=>fallback.frames>beforeFallback);
106
+ assert.ok(fallback.seeks.length>0,'failed presentation positions the page decoder');
107
+ await renderer.evaluate('document.getElementById("v").pause()');
108
+ const paused=await renderer.evaluate('document.getElementById("v").currentTime');
109
+ clock(5);
110
+ assert.equal(await renderer.evaluate('document.getElementById("v").currentTime'),paused);
111
+ assert.deepEqual(errors,[]);
112
+ process.stdout.write('presentation refill avoids duplicate page decode and preserves fallbacks\n');
113
+ } finally {renderer.dispose();}
@@ -0,0 +1,148 @@
1
+ // Isolated: the host realm installs browser globals in this process.
2
+ import { strict as assert } from 'node:assert';
3
+ import { createPageRenderer } from '../../lib/renderer/page-renderer.js';
4
+
5
+ const scenario = process.argv[2], sleep = Bun.sleep;
6
+ const probes = [], pending = [], errors = [];
7
+ let hold = scenario.startsWith('initial') && scenario !== 'initial-rejected';
8
+ let failNext = scenario === 'initial-rejected';
9
+ const renderer = await createPageRenderer({
10
+ reportError: error => errors.push(String(error)),
11
+ fetchResource: async () => ({ ok: true, contentType: 'video/mp4', body: new Uint8Array(8) }),
12
+ videoDecoder: {
13
+ async probe(bytes) {
14
+ const input = { length: bytes.length, hash: Bun.hash(bytes).toString() };
15
+ probes.push(input);
16
+ if (failNext) { failNext = false; throw Error('temporary probe failure'); }
17
+ if (hold) return new Promise(resolve => pending.push(resolve));
18
+ return metadata(bytes.length);
19
+ },
20
+ decode: async () => ({ width: 20, height: 12, fps: 1, close() {}, seek() {},
21
+ nextFrame: (w, h) => new Uint8Array(w * h * 4) }),
22
+ },
23
+ });
24
+ const metadata = duration => ({ duration, video: { width: 20, height: 12 } });
25
+ const evaluate = code => renderer.evaluate(code);
26
+ const waitFor = async condition => {
27
+ for (let attempt = 0; attempt < 150; attempt++) { if (await condition()) return; await sleep(5); }
28
+ assert.fail(`probe transition timed out: ${scenario}; ${JSON.stringify({ probes, pending: pending.length, errors })}`);
29
+ };
30
+ const append = buffer => evaluate(`new Promise(resolve => {
31
+ ${buffer}.addEventListener('updateend', resolve, {once:true});
32
+ ${buffer}.appendBuffer(new Uint8Array([0,0,0,8,102,114,101,101]));
33
+ })`);
34
+ const count = () => evaluate('metadataEvents');
35
+ const duration = () => evaluate('v.duration');
36
+ const finishInitial = async () => {
37
+ await waitFor(async () => await count() === 1);
38
+ await evaluate('v.pause()');
39
+ await sleep(10);
40
+ };
41
+ try {
42
+ renderer.setScripting('host'); renderer.resize(240, 180, 1);
43
+ await renderer.loadDocument({ url: 'https://probe.test/', contentType: 'text/html',
44
+ source: '<!doctype html><video id="v"></video>' });
45
+ await renderer.whenLoaded();
46
+ await evaluate(`new Promise(resolve => {
47
+ globalThis.metadataEvents=0; globalThis.v=document.getElementById('v');
48
+ v.addEventListener('loadedmetadata',()=>metadataEvents++);
49
+ globalThis.ms=new MediaSource();
50
+ ms.addEventListener('sourceopen',()=>{
51
+ globalThis.vb=ms.addSourceBuffer('video/mp4');
52
+ globalThis.ab=ms.addSourceBuffer('audio/mp4');
53
+ globalThis.probeWaits=0;
54
+ { const pipe=ms.streamPipes().video, original=pipe.waitFor;
55
+ pipe.waitFor=mark=>{probeWaits++;return original(mark)}; }
56
+ ${scenario.startsWith('epoch') ? `const pipe=ms.streamPipes().video, original=pipe.snapshotAt;
57
+ pipe.snapshotAt=seconds=>({...original(seconds),id:seconds>=2?'late':'early'});` : ''}
58
+ ${scenario === 'moving-snapshot' ? `const pipe=ms.streamPipes().video, original=pipe.snapshotAt;
59
+ pipe.snapshotAt=seconds=>({...original(seconds),startTime:Math.floor(seconds/0.02)*0.02});` : ''}
60
+ void v.play(); resolve();
61
+ },{once:true});
62
+ v.src=URL.createObjectURL(ms);
63
+ })`);
64
+ await append('vb');
65
+ if (scenario === 'initial-rejected') {
66
+ await waitFor(() => probes.length === 1);
67
+ await sleep(10);
68
+ const waits = await evaluate('probeWaits');
69
+ for (let i = 0; i < 5; i++) await append('ab');
70
+ await sleep(15);
71
+ assert.equal(probes.length, 1, 'unchanged rejected initial bytes wait for video growth');
72
+ assert.equal(await evaluate('probeWaits'), waits, 'audio appends do not accumulate pending video waiters');
73
+ await append('vb'); await finishInitial();
74
+ assert.equal(probes.length, 2); assert.equal(await count(), 1);
75
+ } else if (scenario.startsWith('initial')) {
76
+ await waitFor(() => pending.length === 1);
77
+ await append('ab'); await append('ab'); await sleep(15);
78
+ assert.equal(probes.length, 1, 'loadMedia and first append share one initial probe');
79
+ if (scenario === 'initial-growth') {
80
+ await append('vb'); await append('vb');
81
+ pending.shift()(metadata(8));
82
+ await waitFor(() => pending.length === 1);
83
+ assert.equal(probes.length, 2, 'one latest-input recheck after coalesced appends');
84
+ assert.equal(await count(), 0, 'obsolete initial input cannot publish metadata');
85
+ hold = false; pending.shift()(metadata(24));
86
+ await finishInitial(); assert.equal(await duration(), 24);
87
+ } else if (scenario === 'initial-source') {
88
+ await evaluate('v.src="replacement.mp4";v.load()');
89
+ hold = false; pending.shift()(metadata(123));
90
+ await sleep(25); assert.equal(await count(), 1);
91
+ assert.equal(await duration(), 8, 'only the replacement source can publish metadata');
92
+ } else {
93
+ hold = false; pending.shift()(metadata(8));
94
+ await finishInitial(); assert.equal(await count(), 1);
95
+ await append('ab'); await sleep(15);
96
+ assert.equal(probes.length, 1, 'initial probe does not leave a blocked duplicate waiter');
97
+ }
98
+ } else {
99
+ await finishInitial(); assert.equal(probes.length, 1);
100
+ if (scenario === 'audio-only') {
101
+ await append('vb'); await waitFor(() => probes.length === 2);
102
+ for (let i = 0; i < 5; i++) await append('ab');
103
+ await sleep(20); assert.equal(probes.length, 2, 'audio appends reuse unchanged video metadata');
104
+ assert.equal(await count(), 1);
105
+ } else if (scenario === 'refresh-growth' || scenario === 'pipe-change') {
106
+ hold = true; await append('vb'); await waitFor(() => pending.length === 1);
107
+ if (scenario === 'pipe-change') await evaluate('ms.removeSourceBuffer(vb);globalThis.vb=ms.addSourceBuffer("video/mp4")');
108
+ await append('vb'); await append('vb');
109
+ assert.equal(probes.length, 2, 'no parallel refresh');
110
+ pending.shift()(metadata(20));
111
+ await waitFor(() => pending.length === 1);
112
+ assert.equal(probes.length, 3); assert.equal(await duration(), 8, 'stale pipe/generation cannot overwrite metadata');
113
+ hold = false; pending.shift()(metadata(40));
114
+ await waitFor(async () => await duration() === 40);
115
+ assert.equal(await count(), 1);
116
+ } else if (scenario === 'failed-retry') {
117
+ failNext = true; await append('vb'); await waitFor(() => probes.length === 2);
118
+ await append('ab'); await waitFor(() => probes.length === 3);
119
+ assert.deepEqual(probes[1], probes[2], 'same failed snapshot is retryable');
120
+ await append('ab'); await sleep(15); assert.equal(probes.length, 3);
121
+ } else if (scenario === 'mime-change') {
122
+ await evaluate('vb.changeType("video/webm")'); await append('ab');
123
+ await waitFor(() => probes.length === 2);
124
+ assert.deepEqual(probes[0], probes[1], 'MIME change invalidates identical byte input');
125
+ } else if (scenario === 'epoch-inflight') {
126
+ hold = true; await append('vb'); await waitFor(() => pending.length === 1);
127
+ await evaluate('v.currentTime=3'); pending.shift()(metadata(20));
128
+ await waitFor(() => pending.length === 1);
129
+ assert.equal(probes.length, 3);
130
+ assert.equal(await duration(), 8, 'seek to another epoch rejects old in-flight metadata');
131
+ hold = false; pending.shift()(metadata(40));
132
+ await waitFor(async () => await duration() === 40);
133
+ } else if (scenario === 'moving-snapshot') {
134
+ hold = true; await append('vb'); await waitFor(() => pending.length === 1);
135
+ await evaluate('v.currentTime=3');
136
+ hold = false; pending.shift()(metadata(20));
137
+ await waitFor(async () => await duration() === 20);
138
+ await sleep(20);
139
+ assert.equal(probes.length, 2, 'clock movement alone cannot restart an in-flight snapshot probe');
140
+ } else if (scenario === 'epoch') {
141
+ await evaluate('v.currentTime=3'); await sleep(20); await append('ab');
142
+ await waitFor(() => probes.length === 2);
143
+ assert.deepEqual(probes[0], probes[1], 'snapshot epoch invalidates identical bytes and generation');
144
+ }
145
+ }
146
+ assert.deepEqual(errors, []);
147
+ process.stdout.write(`media probe ${scenario} passed\n`);
148
+ } finally { renderer.dispose(); }
@@ -0,0 +1,79 @@
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 action = process.argv[2], sleep = Bun.sleep, pipe = createBytePipe();
7
+ pipe.append(new Uint8Array(1024));
8
+ let decodeCount = 0, rejectAudio;
9
+ const outputs = [], errors = [];
10
+ const renderer = await createPageRenderer({
11
+ onFrame() {}, onVideo() {}, reportError:error => errors.push(String(error)),
12
+ fetchResource:async () => ({ok:true, contentType:'video/mp4', body:pipe}),
13
+ videoDecoder:{
14
+ probe:async () => ({duration:30, video:{width:20,height:12}, audio:{sampleRate:1000}}),
15
+ decode:async () => ({width:20,height:12,fps:10, seek() {}, close() {},
16
+ nextFrame:(w,h) => new Uint8Array(w*h*4)}),
17
+ async decodeAudio() {
18
+ decodeCount++;
19
+ if (decodeCount === 2) await new Promise((resolve, reject) => {rejectAudio=reject;});
20
+ let position = 0;
21
+ return {sampleRate:1000,channels:1, close() {}, nextSamples() {
22
+ if (position >= 1000) return null;
23
+ position += 100;
24
+ return new Float32Array(100);
25
+ }};
26
+ },
27
+ },
28
+ audio:{
29
+ open(options, listener) {outputs.push({listener,bytes:0});return outputs.length;},
30
+ write(id, bytes) {outputs[id-1].bytes+=bytes.length;},
31
+ close(id) {return Promise.resolve(outputs[id-1].bytes);}, finish() {},
32
+ },
33
+ });
34
+ const waitFor = async condition => {
35
+ for(let n=0;n<200;n++) {if(condition())return;await sleep(5);}
36
+ assert.fail(`timed out: ${condition}`);
37
+ };
38
+ try {
39
+ renderer.resize(240,180,1);renderer.setScripting('host');
40
+ await renderer.loadDocument({url:'https://refill-cancel.test/',contentType:'text/html',source:
41
+ '<style>video{width:100px;height:60px;display:block}</style><video id=v src="clip.mp4"></video>'});
42
+ await renderer.whenLoaded();
43
+ await renderer.evaluate('globalThis.mediaEvents=[]; const v=document.getElementById("v"); v.addEventListener("error",()=>mediaEvents.push("error")); v.addEventListener("seeked",()=>mediaEvents.push("seeked")); v.play()');
44
+ // The old snapshot ends at sample 1000; the newer, now-complete input
45
+ // makes the replacement decoder's rejection a fatal error if still active.
46
+ pipe.finish();
47
+ outputs[0].listener({event:'request',bytes:3000});
48
+ await waitFor(() => Boolean(rejectAudio));
49
+
50
+ if (action === 'seek') await renderer.evaluate('document.getElementById("v").currentTime=0.2');
51
+ else if (action === 'pause') await renderer.evaluate('document.getElementById("v").pause()');
52
+ else if (action === 'navigate') {
53
+ // The replacement is a static page; it does not need another host realm.
54
+ renderer.setScripting('off');
55
+ await renderer.loadDocument({url:'https://refill-cancel.test/next',contentType:'text/html',source:'<p id=next>Next document</p>'});
56
+ await renderer.whenLoaded();
57
+ } else assert.fail(`unknown action: ${action}`);
58
+
59
+ rejectAudio(Error('obsolete failed audio refill'));
60
+ if (action === 'seek') {
61
+ for(let n=0;n<200;n++) {
62
+ if (await renderer.evaluate('mediaEvents.includes("seeked")')) break;
63
+ await sleep(5);
64
+ }
65
+ assert.equal(await renderer.evaluate('mediaEvents.includes("seeked")'),true);
66
+ assert.equal(await renderer.evaluate('document.getElementById("v").paused'),false);
67
+ assert.equal(await renderer.evaluate('document.getElementById("v").currentTime'),0.2);
68
+ assert.equal(await renderer.evaluate('mediaEvents.includes("error")'),false);
69
+ } else {
70
+ await sleep(80);
71
+ if (action === 'pause') {
72
+ assert.equal(await renderer.evaluate('document.getElementById("v").paused'),true);
73
+ assert.equal(await renderer.evaluate('mediaEvents.includes("error")'),false);
74
+ } else assert.equal(await renderer.evaluate('document.body.textContent'),'Next document');
75
+ }
76
+ assert.deepEqual(errors,[]);
77
+ assert.equal(decodeCount,2,'an obsolete rejection must not close and reopen the media');
78
+ process.stdout.write(`${action} ignores an obsolete refill rejection\n`);
79
+ } finally {renderer.dispose();}
@@ -0,0 +1,48 @@
1
+ // Isolated because host scripting installs browser globals in this process.
2
+ import {strict as assert} from 'node:assert';
3
+ import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
4
+ const sleep=Bun.sleep, report=text=>process.stdout.write(text+'\n');
5
+ const errors=[],frames=[],messages=[];
6
+ let seeked=0;
7
+ const renderer=await createPageRenderer({
8
+ renderMinInterval:80,
9
+ onFrame:frame=>frames.push(frame.tile),onVideo:message=>messages.push(message),
10
+ fetchResource:async url=>({ok:true,url,body:new Uint8Array([1]),contentType:'video/mp4'}),
11
+ reportError:error=>errors.push(String(error)),
12
+ videoDecoder:{
13
+ probe:async()=>({duration:10,video:{width:20,height:12}}),
14
+ decode:async()=>({width:20,height:12,fps:100,
15
+ nextFrame(w,h){const data=new Uint8Array(w*h*4);for(let i=0;i<data.length;i+=4)data.set([60+seeked,90,120,255],i);return data;},
16
+ seek(ms){seeked=Math.floor(ms/100);},close(){}}),
17
+ },
18
+ });
19
+ function pixel(tile,x,y){const at=(Math.round((y-tile.y)*tile.scale)*tile.pixelWidth+Math.round((x-tile.x)*tile.scale))*4;return [...tile.pixels.subarray(at,at+4)];}
20
+ try{
21
+ renderer.resize(240,180,1);renderer.setScripting('host');
22
+ await renderer.loadDocument({url:'https://media.test/',contentType:'text/html',source:'<style>body{margin:0}video{display:block;width:200px;height:120px}#marker{width:20px;height:20px;background:red}</style><video id=v src="clip.mp4"></video><div id=marker></div><video id=native src="clip.mp4" controls style="display:none"></video>'});
23
+ await renderer.whenLoaded();
24
+ await renderer.evaluate('document.getElementById("v").play()');
25
+ await sleep(160);
26
+ assert.ok(messages.some(message=>message.type==='video-open'),'first metadata/play starts independent presentation');
27
+ assert.ok(frames.at(-1).videoLayer);
28
+ frames.length=0;
29
+ await renderer.evaluate('setTimeout(()=>{document.getElementById("marker").style.background="blue"},1)');
30
+ await sleep(180);
31
+ assert.ok(frames.length>0,'scheduled UI frame is still published');
32
+ assert.ok(frames.every(tile=>pixel(tile,5,125).join(',')==='0,0,255,255'),'media ticks must not publish stale red UI before the scheduled rebuild');
33
+ await renderer.evaluate('document.getElementById("v").pause()');
34
+ assert.ok(messages.some(message=>message.type==='video-stop'),'pause stops independent presentation');
35
+ assert.equal(frames.at(-1).videoLayer,null,'paused frame returns to normal raster');
36
+ await renderer.evaluate('document.getElementById("v").currentTime=3');
37
+ await sleep(80);
38
+ assert.ok(seeked>0,'paused seek positions the decoder');
39
+ assert.ok(pixel(frames.at(-1),5,5)[0]>60,'paused seek publishes its decoded picture');
40
+ await renderer.evaluate('document.getElementById("v").style.display="none";document.getElementById("native").style.display="block"');
41
+ await renderer.evaluate('document.getElementById("native").play()');
42
+ const before=frames.length;
43
+ await sleep(140);
44
+ assert.ok(frames.length>before,'native controls/local playback continue repainting');
45
+ assert.equal(frames.at(-1).videoLayer,null,'native controls retain the normal rendering path');
46
+ await renderer.evaluate('document.getElementById("native").pause()');
47
+ assert.deepEqual(errors,[]);report('media repaint integration passed');
48
+ }finally{renderer.dispose();}
@@ -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();}