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,67 @@
1
+ import { afterAll, beforeAll, expect, test } from 'bun:test';
2
+ import { RendererHost } from '../lib/renderer/host.js';
3
+
4
+ const nestingScript = `new Promise(resolve => {
5
+ const gaps = []; let previous = performance.now();
6
+ function step() {
7
+ const now = performance.now(); gaps.push(now - previous); previous = now;
8
+ if (gaps.length === 12) resolve(gaps);
9
+ else Promise.resolve().then(() => {}).then(() => queueMicrotask(() => setTimeout(step, 0)));
10
+ }
11
+ setTimeout(step, 0);
12
+ })`;
13
+ const intervalScript = `new Promise(resolve => {
14
+ let conversions = 0, calls = 0;
15
+ const id = setInterval(() => {
16
+ if (++calls === 8) { clearTimeout(id); resolve({conversions, calls}); }
17
+ }, {valueOf() { conversions++; return 0; }});
18
+ })`;
19
+ const scripts = new Map([
20
+ ['/nested.js', `${nestingScript}.then(gaps => postMessage(gaps))`],
21
+ ['/interval.js', `${intervalScript}.then(result => postMessage(result))`],
22
+ ]);
23
+ let renderer;
24
+ beforeAll(async () => {
25
+ renderer = new RendererHost({timeout: 20000, fetch: async url => {
26
+ const script = scripts.get(new URL(url).pathname);
27
+ return new Response(script ?? '', {headers: {'content-type': 'text/javascript'}});
28
+ }});
29
+ await renderer.start();
30
+ await renderer.call('loadDocument', {url: 'https://timers.test/', source: '<!doctype html><p>Timers</p>', contentType: 'text/html'});
31
+ await renderer.call('whenLoaded');
32
+ });
33
+ afterAll(() => renderer?.close());
34
+ const evaluate = code => renderer.call('evaluate', code);
35
+ const worker = path => evaluate(`new Promise((resolve, reject) => {
36
+ const worker = new Worker(${JSON.stringify(path)});
37
+ worker.onmessage = event => { worker.terminate(); resolve(event.data); };
38
+ worker.onerror = event => { worker.terminate(); reject(Error(event.message)); };
39
+ })`);
40
+
41
+ for (const scope of ['Window', 'Worker']) {
42
+ test(`${scope} preserves nested timer depth through Promise and queueMicrotask`, async () => {
43
+ const gaps = scope === 'Window' ? await evaluate(nestingScript) : await worker('/nested.js');
44
+ expect(gaps).toHaveLength(12);
45
+ // Only a lower bound: a loaded CI host can delay callbacks arbitrarily.
46
+ // The deterministic helper test checks the exact 0/4ms requested sequence.
47
+ expect(gaps.slice(6).reduce((sum, ms) => sum + ms, 0)).toBeGreaterThanOrEqual(20);
48
+ });
49
+ test(`${scope} converts interval delay once and either clear method cancels it`, async () => {
50
+ expect(scope === 'Window' ? await evaluate(intervalScript) : await worker('/interval.js'))
51
+ .toEqual({conversions: 1, calls: 8});
52
+ });
53
+ }
54
+
55
+ test('handler string and delay conversion happen at registration and errors stay synchronous', async () => {
56
+ expect(await evaluate(`new Promise(resolve => {
57
+ const calls = []; globalThis.timerText = '';
58
+ setTimeout({toString() { calls.push('handler'); return 'timerText = "executed"'; }},
59
+ {valueOf() { calls.push('delay'); return 0; }});
60
+ calls.push('registered');
61
+ for (const delay of [1n, Symbol()]) {
62
+ try { setTimeout(() => {}, delay); calls.push('accepted'); }
63
+ catch (error) { calls.push(error.name); }
64
+ }
65
+ setTimeout(() => resolve({calls, timerText}), 20);
66
+ })`)).toEqual({calls: ['handler', 'delay', 'registered', 'TypeError', 'TypeError'], timerText: 'executed'});
67
+ });
@@ -1,5 +1,5 @@
1
1
  import { expect, test } from 'bun:test';
2
- import { cropViewport, encodeImage, encodeImageBase64 } from '../lib/paint/frame.js';
2
+ import { cropViewport, encodeImage, encodeImageBase64, rawFrame } from '../lib/paint/frame.js';
3
3
  import { FrameCaptureCache } from '../lib/paint/frame-cache.js';
4
4
  import { appendParagraphText } from '../lib/paint/paragraph-text.js';
5
5
 
@@ -101,3 +101,20 @@ test('paragraphs batch ordinary text while preserving whole emoji graphemes and
101
101
  expect(runs[0].text).toBe(ordinary);
102
102
  expect(runs.at(-1)).toEqual({ text: ' end', emoji: false });
103
103
  });
104
+
105
+ test('raw capture copies borrowed pixels but can consume private offset snapshots',async()=>{
106
+ const pixels=new Uint8Array([10,20,30,40, 50,60,70,80]);
107
+ expect([...rawFrame(pixels,2,1,'bgra').pixels]).toEqual([30,20,10,40,70,60,50,80]);
108
+ expect([...pixels]).toEqual([10,20,30,40,50,60,70,80]);
109
+ const bytes=new Uint8Array(16);bytes.set(pixels,4);
110
+ const frame=rawFrame(bytes.subarray(4,12),2,1,'bgra',{takeOwnership:true});
111
+ expect(frame.pixels.buffer).toBe(bytes.buffer);
112
+ expect([...frame.pixels]).toEqual([30,20,10,40,70,60,50,80]);
113
+ expect([...bytes.subarray(0,4)]).toEqual([0,0,0,0]);
114
+ });
115
+
116
+ test("raw screenshots accept already composed BGRA without converting it twice", () => {
117
+ const pixels = Uint8Array.of(3, 2, 1, 255);
118
+ expect(rawFrame(pixels, 1, 1, "bgra", {inputFormat:"bgra"}).pixels).toEqual(pixels);
119
+ expect(rawFrame(pixels, 1, 1, "rgba", {inputFormat:"bgra"}).pixels).toEqual(Uint8Array.of(1,2,3,255));
120
+ });
@@ -0,0 +1,63 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { floatToS16 } from '../lib/audio/pcm.js';
3
+
4
+ // The previous implementation is a byte-order-explicit reference, independent
5
+ // of native Int16Array storage. Check full output bytes, not native readings.
6
+ function reference(samples) {
7
+ const out = new Uint8Array(samples.length * 2), view = new DataView(out.buffer);
8
+ for (let i = 0; i < samples.length; i++) {
9
+ const value = Math.max(-1, Math.min(1, samples[i]));
10
+ view.setInt16(i * 2, Math.round(value < 0 ? value * 32768 : value * 32767), true);
11
+ }
12
+ return out;
13
+ }
14
+
15
+ // Exercise the actual DataView fallback on little-endian test machines. Only
16
+ // replace the host-endian probe; the function under test stays the source file.
17
+ const source = await Bun.file(new URL('../lib/audio/pcm.js', import.meta.url)).text();
18
+ const probe = 'const LITTLE_ENDIAN = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1;';
19
+ if (!source.includes(probe)) throw new Error('Update the endian-fallback test probe');
20
+ const fallbackModule = await import('data:text/javascript;base64,' + Buffer.from(
21
+ source.replace(probe, 'const LITTLE_ENDIAN = false;'),
22
+ ).toString('base64'));
23
+
24
+ for (const [name, convert] of [['native', floatToS16], ['explicit little-endian fallback', fallbackModule.floatToS16]]) {
25
+ test(`s16le ${name}: zero, clamp, NaN, infinities, subnormals and signed rounding`, () => {
26
+ const values = [0, -0, NaN, Infinity, -Infinity, 1, -1, 2, -2,
27
+ Number.MAX_VALUE, -Number.MAX_VALUE, Number.MIN_VALUE, -Number.MIN_VALUE,
28
+ 0.5, -0.5, 0.5 / 32767, -0.5 / 32768, 1.5 / 32767, -1.5 / 32768,
29
+ 32766.5 / 32767, -32767.5 / 32768];
30
+ expect(convert(values)).toEqual(reference(values));
31
+ expect(convert(new Float32Array(values))).toEqual(reference(new Float32Array(values)));
32
+ expect([...convert(new Float32Array([1, -1, 0.5, -0.5]))]).toEqual([255,127,0,128,0,64,0,192]);
33
+ expect(convert(new Float32Array())).toEqual(new Uint8Array());
34
+ });
35
+
36
+ test(`s16le ${name}: every signed integer midpoint and adjacent inputs match previous rounding`, () => {
37
+ const values = [];
38
+ for (let value = -32768; value < 32767; value++) {
39
+ const midpoint = (value + 0.5) / (value < 0 ? 32768 : 32767);
40
+ values.push(midpoint - Number.EPSILON, midpoint, midpoint + Number.EPSILON);
41
+ }
42
+ expect(convert(values)).toEqual(reference(values));
43
+ const decoded = new Float32Array(values);
44
+ expect(convert(decoded)).toEqual(reference(decoded));
45
+ });
46
+
47
+ test(`s16le ${name}: randomized Float32 bit patterns preserve exact bytes and ownership`, () => {
48
+ const storage = new ArrayBuffer(4 * 100002), words = new Uint32Array(storage);
49
+ let state = 0xa538f219;
50
+ for (let i = 0; i < words.length; i++) words[i] = state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
51
+ words.set([0x80000000, 0x7f800000, 0xff800000, 0x7fc00001, 0xffc00001, 1, 0x80000001], 1);
52
+ const input = new Float32Array(storage, 4, 100000), before = new Uint8Array(storage).slice();
53
+ const first = convert(input), second = convert(input);
54
+ expect(first).toEqual(reference(input));
55
+ expect(first).toEqual(second);
56
+ expect(first.buffer).not.toBe(input.buffer);
57
+ expect(first.buffer).not.toBe(second.buffer);
58
+ expect(first.byteOffset).toBe(0);
59
+ first.buffer.transfer(0);
60
+ expect(second.byteLength).toBe(input.length * 2);
61
+ expect(new Uint8Array(storage)).toEqual(before);
62
+ });
63
+ }
@@ -0,0 +1,13 @@
1
+ import {expect, test} from 'bun:test';
2
+
3
+ for (const scenario of ['loading', 'loading-scroll', 'pending-frame', 'video']) {
4
+ test(`prepaint scheduling preserves visible work and eventual idle expansion: ${scenario}`, async () => {
5
+ const child = Bun.spawn([process.execPath,
6
+ new URL('./fixtures/prepaint-scheduling.js', import.meta.url).pathname, scenario], {stdout:'pipe', stderr:'pipe'});
7
+ const [output, error, code] = await Promise.all([
8
+ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
9
+ ]);
10
+ expect({code, error}).toEqual({code:0, error:''});
11
+ expect(output).toContain(`prepaint ${scenario} passed`);
12
+ }, 15000);
13
+ }
@@ -219,3 +219,168 @@ test("measured device progress governs the media clock and bounded audio queue t
219
219
  await new Promise(resolve => server.close(resolve));
220
220
  }
221
221
  });
222
+
223
+ // Keep the real connection/open/close path, but drive only the playback
224
+ // intervals with a virtual monotonic clock. Latency responses are controlled
225
+ // at the client boundary; the test above covers their wire representation.
226
+ async function playbackTimers(run) {
227
+ const { createServer } = await import("node:net");
228
+ const server = createServer(socket => {
229
+ let input = Buffer.alloc(0);
230
+ socket.on("data", chunk => {
231
+ input = Buffer.concat([input, chunk]);
232
+ while (input.length >= 20 && input.length >= 20 + input.readUInt32BE(0)) {
233
+ const length = input.readUInt32BE(0), channel = input.readUInt32BE(4);
234
+ const body = input.subarray(20, 20 + length);
235
+ input = input.subarray(20 + length);
236
+ if (channel !== 0xffffffff) continue;
237
+ const command = body.readUInt32BE(1), tag = body.readUInt32BE(6);
238
+ const extra = command === 3 ? [0x4c, ...u32(1), 0x4c, ...u32(1), 0x4c, ...u32(1e7)] : [];
239
+ const reply = Buffer.from([0x4c, ...u32(2), 0x4c, ...u32(tag), ...extra]);
240
+ socket.write(Buffer.concat([header(reply.length, 0xffffffff), reply]));
241
+ }
242
+ });
243
+ });
244
+ await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
245
+ let client, now = 0;
246
+ const intervals = new Map(), sent = [];
247
+ const originalSet = globalThis.setInterval, originalClear = globalThis.clearInterval;
248
+ const originalNow = performance.now;
249
+ const flush = async () => { await Promise.resolve(); await Promise.resolve(); };
250
+ try {
251
+ client = await PulseClient.connect({ port: server.address().port });
252
+ client.writeAudio = (index, bytes) => sent.push({ at: now, bytes: bytes.length });
253
+ globalThis.setInterval = (callback, delay) => {
254
+ const token = {};
255
+ intervals.set(token, { callback, delay, at: now + delay });
256
+ return token;
257
+ };
258
+ globalThis.clearInterval = token => {
259
+ if (!intervals.delete(token)) originalClear(token);
260
+ };
261
+ performance.now = () => now;
262
+ const advance = async duration => {
263
+ const end = now + duration;
264
+ for (;;) {
265
+ const next = [...intervals.values()].sort((a, b) => a.at - b.at)[0];
266
+ if (!next || next.at > end) break;
267
+ now = next.at;
268
+ next.at += next.delay;
269
+ next.callback();
270
+ await flush();
271
+ }
272
+ now = end;
273
+ await flush();
274
+ };
275
+ await run({ client, intervals, sent, advance, flush, now: () => now });
276
+ } finally {
277
+ client?.close();
278
+ globalThis.setInterval = originalSet;
279
+ globalThis.clearInterval = originalClear;
280
+ performance.now = originalNow;
281
+ await new Promise(resolve => server.close(resolve));
282
+ }
283
+ }
284
+
285
+ test("16 ms measured timing leaves PCM on its 50 ms cadence and holds a stale device clock", async () => {
286
+ await playbackTimers(async ({ client, intervals, sent, advance, now }) => {
287
+ const requests = [], times = [], polls = [];
288
+ let stalled = false, lastPlayed = 0;
289
+ const totalSent = () => sent.reduce((sum, packet) => sum + packet.bytes, 0);
290
+ client.playbackTiming = () => {
291
+ polls.push(now());
292
+ if (!stalled) lastPlayed = Math.min(now(), totalSent());
293
+ return { writeIndex: totalSent(), readIndex: totalSent(),
294
+ sinkSeconds: (totalSent() - lastPlayed) / 1000, playing: true };
295
+ };
296
+ const stream = await client.openPlayback({ rate: 500, channels: 1,
297
+ onRequest: bytes => requests.push(bytes), onTiming: timing => times.push(timing.seconds) });
298
+ expect([...intervals.values()].map(timer => timer.delay).sort((a, b) => a - b)).toEqual([16, 50]);
299
+ stream.write(new Uint8Array(2000));
300
+ expect(totalSent()).toBe(500);
301
+ await advance(49);
302
+ expect(polls).toEqual([16, 32, 48]);
303
+ expect(times.at(-1)).toBeCloseTo(0.048, 6);
304
+ expect(totalSent()).toBe(500); // timing callbacks do not send PCM
305
+ await advance(51);
306
+ expect(polls).toEqual([16, 32, 48, 64, 80, 96]);
307
+ expect(sent.every(packet => packet.at === 0 || packet.at === 50 || packet.at === 100)).toBe(true);
308
+ expect(totalSent()).toBe(596); // only the measured 96 ms has replenished the budget
309
+ expect(totalSent() - lastPlayed).toBe(500);
310
+ stalled = true;
311
+ await advance(1000);
312
+ expect(times.at(-1)).toBeCloseTo(0.096, 6);
313
+ expect(totalSent()).toBe(596); // neither wall time nor more queries invent progress
314
+ expect(requests.reduce((sum, bytes) => sum + bytes, 0)).toBeLessThanOrEqual(2600);
315
+ stream.kill();
316
+ expect(intervals.size).toBe(0);
317
+ const count = polls.length;
318
+ await advance(1000);
319
+ expect(polls).toHaveLength(count);
320
+ expect(stream.queued).toBe(0);
321
+ });
322
+ });
323
+
324
+ test("slow timing responses stay single-flight and a reply after kill cannot publish or restart polling", async () => {
325
+ await playbackTimers(async ({ client, intervals, advance, flush }) => {
326
+ let calls = 0, resolve;
327
+ const times = [], errors = [];
328
+ client.playbackTiming = () => { calls++; return new Promise(done => { resolve = done; }); };
329
+ const stream = await client.openPlayback({ rate: 500, channels: 1,
330
+ onTiming: value => times.push(value), onTimingError: error => errors.push(error) });
331
+ stream.write(new Uint8Array(2000));
332
+ await advance(160);
333
+ expect(calls).toBe(1);
334
+ expect(times).toEqual([]);
335
+ resolve({ writeIndex: 500, readIndex: 500, sinkSeconds: 0.4, playing: true });
336
+ await flush();
337
+ expect(times.map(value => value.seconds)).toEqual([0.1]);
338
+ await advance(160);
339
+ expect(calls).toBe(2);
340
+ stream.kill();
341
+ expect(intervals.size).toBe(0);
342
+ resolve({ writeIndex: 600, readIndex: 600, sinkSeconds: 0, playing: true });
343
+ await flush();
344
+ await advance(160);
345
+ expect(calls).toBe(2);
346
+ expect(times.map(value => value.seconds)).toEqual([0.1]);
347
+ expect(errors).toEqual([]);
348
+ });
349
+ });
350
+
351
+ test("streams without a timing consumer keep only the PCM pacing timer", async () => {
352
+ await playbackTimers(async ({ client, intervals, advance }) => {
353
+ let polls = 0;
354
+ client.playbackTiming = () => { polls++; throw Error("unexpected timing query"); };
355
+ const stream = await client.openPlayback({ rate: 500, channels: 1 });
356
+ expect([...intervals.values()].map(timer => timer.delay)).toEqual([50]);
357
+ await advance(1000);
358
+ expect(polls).toBe(0);
359
+ await stream.close();
360
+ expect(intervals.size).toBe(0);
361
+ });
362
+ });
363
+
364
+ test("timing rejection permits a retry and a late rejection after kill stays silent", async () => {
365
+ await playbackTimers(async ({ client, advance, flush }) => {
366
+ const pending = [], errors = [], times = [];
367
+ client.playbackTiming = () => new Promise((resolve, reject) => pending.push({ resolve, reject }));
368
+ const stream = await client.openPlayback({ rate: 500, channels: 1,
369
+ onTiming: value => times.push(value), onTimingError: error => errors.push(error.message) });
370
+ stream.write(new Uint8Array(2000));
371
+ await advance(160);
372
+ expect(pending).toHaveLength(1);
373
+ pending[0].reject(Error("pulse: timing request timed out"));
374
+ await flush();
375
+ expect(errors).toEqual(["pulse: timing request timed out"]);
376
+ await advance(16);
377
+ expect(pending).toHaveLength(2);
378
+ stream.kill();
379
+ pending[1].reject(Error("late timeout"));
380
+ await flush();
381
+ await advance(160);
382
+ expect(pending).toHaveLength(2);
383
+ expect(errors).toHaveLength(1);
384
+ expect(times).toEqual([]);
385
+ });
386
+ });
@@ -1,6 +1,7 @@
1
- import {test,expect} from 'bun:test';
1
+ import {test,expect,spyOn} from 'bun:test';
2
2
  import {readFileSync,existsSync,unlinkSync} from 'node:fs';
3
3
  import {dirname} from 'node:path';
4
+ import {Server} from 'node:net';
4
5
  import {RendererHost} from '../lib/renderer/host.js';
5
6
  import {PacketReader,encodePacket,listenCompositor,connectCompositor} from '../lib/renderer/compositor-channel.js';
6
7
  import {RawFrames} from '../lib/paint/raw-frames.js';
@@ -85,17 +86,45 @@ test('normal CDP routes raw requests to the controller without changing standard
85
86
  expect([crop.readUInt32BE(16),crop.readUInt32BE(20)]).toEqual([7,4]);
86
87
  await cdp.send('Page.screencastFrameAck',{sessionId:event.params.sessionId});
87
88
  await cdp.send('Page.stopScreencast');
89
+ cdp.events.length=0;
90
+ const zero={x:0,y:0,width:0,height:0,scale:1};
91
+ await cdp.send('Page.startScreencast',{format:'png',clip:zero});
92
+ let notice;for(let i=0;i<100;i++){notice=cdp.events.find(e=>e.method==='Page.screencastFrame');if(notice)break;await Bun.sleep(20);}
93
+ expect(notice).toBeDefined();
94
+ const placeholder=Buffer.from(notice.params.data,'base64');
95
+ expect([placeholder.readUInt32BE(16),placeholder.readUInt32BE(20)]).toEqual([1,1]);
96
+ await cdp.send('Page.stopScreencast');
97
+ await expect(cdp.send('Page.captureScreenshot',{format:'png',clip:zero})).rejects.toThrow('Invalid screenshot clip');
88
98
  const png=await cdp.send('Page.captureScreenshot',{format:'png'});
89
99
  expect([...Buffer.from(png.data,'base64').subarray(0,4)]).toEqual([137,80,78,71]);
90
100
  }finally{cdp.close();server.stop(true);browser.close();}
91
101
  expect(existsSync(directory)).toBe(false);
92
102
  },20000);
93
103
 
94
- test('closing during listener startup rejects promptly and a new start remains usable',async()=>{
95
- const host=new RendererHost();
96
- const pending=host.start();host.close();
104
+ test('closing during listener startup releases the listener and only a new host remains usable',async()=>{
105
+ const host=new RendererHost(),listeners=[],closed=[];
106
+ const original=Server.prototype.listen;
107
+ const listening=spyOn(Server.prototype,'listen').mockImplementation(function(...args){
108
+ listeners.push(this);closed.push(new Promise(resolve=>this.once('close',resolve)));
109
+ return original.apply(this,args);
110
+ });
111
+ let pending;
112
+ try{pending=host.start();host.close();}finally{listening.mockRestore();}
97
113
  await expect(pending).rejects.toThrow('closed');
98
- try{await host.start();expect(host.pid).toBeGreaterThan(0);}finally{host.close();}
114
+ expect(listeners).toHaveLength(1);
115
+ // Cancellation rejects before listen() resolves. Its later continuation
116
+ // must close that newly-created listener instead of leaving a server alive.
117
+ await Promise.all(closed);
118
+ expect(listeners[0].listening).toBe(false);
119
+ await expect(host.start()).rejects.toThrow('Renderer is closed');
120
+ expect(host.pid).toBeNull();
121
+ const fresh=new RendererHost();
122
+ try{
123
+ await fresh.start();expect(fresh.pid).toBeGreaterThan(0);
124
+ await fresh.call('loadDocument',{url:'about:blank',source:'<body>fresh host</body>',contentType:'text/html'});
125
+ const png=await fresh.composite('screenshot',{format:'png'});
126
+ expect([...Buffer.from(png,'base64').subarray(0,4)]).toEqual([137,80,78,71]);
127
+ }finally{fresh.close();}
99
128
  },15000);
100
129
 
101
130
  for(const compositor of [true,false])test(`fractional CDP clips preserve exact physical dimensions and raw pixels (compositor=${compositor})`,async()=>{
@@ -24,6 +24,57 @@ test("runs calls in a separate process with an empty environment", async () => {
24
24
  expect(result.argument.hello).toEqual(new Uint8Array([1, 2]));
25
25
  });
26
26
 
27
+ test("slow startup outlives the method deadline without replacing the child", async () => {
28
+ const renderer = host({ timeout: 20, args: ['--ready-delay=200'] });
29
+ const ready = renderer.start();
30
+ const pid = renderer.pid;
31
+ await ready;
32
+ expect(renderer.pid).toBe(pid);
33
+ expect(renderer.pid).toBeGreaterThan(0);
34
+ });
35
+
36
+ test("disabled watchdog lets slow methods finish and still permits explicit close", async () => {
37
+ const renderer = host({ timeout: 0 });
38
+ expect(await renderer.call('slow')).toBe('completed');
39
+ const hanging = renderer.call('hang').catch(error => error.message);
40
+ await Bun.sleep(20);
41
+ renderer.close();
42
+ expect(await hanging).toBe('Renderer closed');
43
+ });
44
+
45
+ test("closing during slow startup rejects the wait", async () => {
46
+ const renderer = host({ args: ['--ready-delay=60000'] });
47
+ const ready = renderer.start();
48
+ const result = ready.then(() => 'unexpected ready', error => error.message);
49
+ renderer.close();
50
+ expect(await result).toBe('Renderer closed during startup');
51
+ expect(renderer.pid).toBeNull();
52
+ });
53
+
54
+ test("slow document loading is not killed by the interactive method deadline", async () => {
55
+ const renderer = host({ timeout: 20 });
56
+ await renderer.start();
57
+ const pid = renderer.pid;
58
+ expect(await renderer.call('loadDocument', { url: 'https://example.test/', delay: 200 })).toBe('loaded');
59
+ expect(await renderer.call('showFragment', 'https://example.test/#section')).toBe('positioned');
60
+ expect(renderer.pid).toBe(pid);
61
+ });
62
+
63
+ test("closing cancels a pending document load", async () => {
64
+ const renderer = host();
65
+ await renderer.start();
66
+ const loading = renderer.call('loadDocument', { url: 'https://example.test/', delay: 60000 });
67
+ const result = loading.then(() => 'unexpected completion', error => error.message);
68
+ await Bun.sleep(20);
69
+ renderer.close();
70
+ expect(await result).toBe('Renderer closed');
71
+ });
72
+
73
+ test("exit before ready rejects startup without waiting for a deadline", async () => {
74
+ const renderer = host({ args: ['--exit-before-ready'] });
75
+ await expect(renderer.start()).rejects.toThrow('renderer exited during startup');
76
+ });
77
+
27
78
  test("kills a renderer that misses its deadline and restarts it on the next call", async () => {
28
79
  const restarts = [];
29
80
  const renderer = host({
@@ -0,0 +1,12 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ test("geometry reads reuse discovery while newly inserted media stays fresh", async () => {
4
+ const child = Bun.spawn([process.execPath, new URL("./fixtures/resource-discovery.js", import.meta.url).pathname], {
5
+ stdout: "pipe", stderr: "pipe",
6
+ });
7
+ const [out, error, code] = await Promise.all([
8
+ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
9
+ ]);
10
+ expect({ code, error }).toEqual({ code: 0, error: "" });
11
+ expect(out).toContain("resource discovery integration passed");
12
+ }, 30_000);