buninu-browser 0.2.2 → 0.2.4

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 (118) hide show
  1. package/lib/audio/playback-buffer.js +27 -0
  2. package/lib/audio/pulse-client.js +18 -8
  3. package/lib/cdp-server.js +53 -14
  4. package/lib/happy-dom/document-base.js +33 -0
  5. package/lib/happy-dom/parser.js +20 -6
  6. package/lib/happy-dom/selector-matcher.js +2 -2
  7. package/lib/happy-dom/selector-pseudo-adapter.js +33 -0
  8. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  9. package/lib/headless-shell.js +12 -6
  10. package/lib/input/hit-test.js +67 -19
  11. package/lib/layout/text-layout.js +289 -131
  12. package/lib/media/byte-pipe.js +2 -2
  13. package/lib/media/position-audio.js +10 -1
  14. package/lib/media/source-snapshots.js +126 -0
  15. package/lib/media/video-keyframe.js +42 -0
  16. package/lib/media/webm-stream.js +106 -0
  17. package/lib/network/network-service.js +268 -120
  18. package/lib/paint/display-list.js +22 -27
  19. package/lib/paint/frame-cache.js +25 -0
  20. package/lib/paint/frame.js +42 -13
  21. package/lib/paint/paragraph-text.js +24 -0
  22. package/lib/paint/raster-readback.js +41 -0
  23. package/lib/paint/rounded-rect.js +82 -0
  24. package/lib/paint/shared-frame-pool.js +105 -0
  25. package/lib/render-tree/index.js +40 -6
  26. package/lib/render-tree/svg.js +15 -7
  27. package/lib/renderer/bindings.js +244 -82
  28. package/lib/renderer/broadcast-channel.js +76 -0
  29. package/lib/renderer/canvas-2d.js +257 -14
  30. package/lib/renderer/canvas-text.js +120 -0
  31. package/lib/renderer/compositor.js +27 -10
  32. package/lib/renderer/custom-element-reactions.js +42 -0
  33. package/lib/renderer/font-loading.js +227 -0
  34. package/lib/renderer/history.js +42 -0
  35. package/lib/renderer/host.js +36 -5
  36. package/lib/renderer/image-loading.js +27 -0
  37. package/lib/renderer/image-resources.js +65 -0
  38. package/lib/renderer/interfaces.js +28 -0
  39. package/lib/renderer/keyboard-input.js +12 -0
  40. package/lib/renderer/layout-observers.js +236 -0
  41. package/lib/renderer/main.js +33 -5
  42. package/lib/renderer/media-playback-events.js +13 -0
  43. package/lib/renderer/media-source.js +126 -55
  44. package/lib/renderer/page-realm.js +278 -147
  45. package/lib/renderer/page-renderer.js +840 -301
  46. package/lib/renderer/piano-input.js +22 -0
  47. package/lib/renderer/pointer-move.js +29 -0
  48. package/lib/renderer/prepared-script-cache.js +50 -0
  49. package/lib/renderer/rendered-text.js +108 -0
  50. package/lib/renderer/rewrite-worker.js +4 -3
  51. package/lib/renderer/script-rewrite.js +13 -2
  52. package/lib/renderer/scroll-into-view.js +28 -0
  53. package/lib/renderer/slot-assignment.js +60 -0
  54. package/lib/renderer/video-presentation.js +145 -0
  55. package/lib/renderer/workers.js +2 -2
  56. package/lib/style/computed-style.js +632 -108
  57. package/lib/style/selector-traits.js +49 -5
  58. package/package.json +1 -1
  59. package/test/broadcast-channel.test.js +26 -0
  60. package/test/canvas-2d.test.js +83 -2
  61. package/test/canvas-paint.test.js +9 -0
  62. package/test/canvas-text.test.js +41 -0
  63. package/test/cdp-stream.test.js +56 -0
  64. package/test/compositor.test.js +38 -0
  65. package/test/computed-style-binding.test.js +151 -0
  66. package/test/computed-style.test.js +279 -1
  67. package/test/display-list.test.js +26 -0
  68. package/test/document-base.test.js +87 -0
  69. package/test/dom-platform.test.js +461 -0
  70. package/test/download.test.js +8 -0
  71. package/test/fetch-lifecycle.test.js +161 -0
  72. package/test/fixtures/canvas-paint.js +117 -0
  73. package/test/fixtures/canvas-text.js +42 -0
  74. package/test/fixtures/image-lifetime.js +90 -0
  75. package/test/fixtures/media-seek-snapshot.js +77 -0
  76. package/test/fixtures/mse-audio-chirp.webm +0 -0
  77. package/test/fixtures/mse-switch-large.mp4 +0 -0
  78. package/test/fixtures/mse-switch-small.mp4 +0 -0
  79. package/test/fixtures/renderer-memory.js +110 -0
  80. package/test/fixtures/summary-style.js +28 -0
  81. package/test/font-loading.test.js +97 -0
  82. package/test/grid.test.js +58 -0
  83. package/test/headless-shell.test.js +22 -0
  84. package/test/hit-test.test.js +21 -1
  85. package/test/image-lifetime.test.js +12 -0
  86. package/test/image-resources.test.js +94 -0
  87. package/test/local-media.test.js +84 -0
  88. package/test/media-playback-events.test.js +22 -0
  89. package/test/media-seek-snapshot.test.js +11 -0
  90. package/test/media-source-config.test.js +98 -0
  91. package/test/media-source-webm-accounting.test.js +98 -0
  92. package/test/media-source-webm.test.js +108 -0
  93. package/test/navigation.test.js +1 -0
  94. package/test/network-service.test.js +388 -0
  95. package/test/page-scripts.test.js +160 -2
  96. package/test/paint-frame.test.js +103 -0
  97. package/test/paint-paragraph-cache.test.js +87 -0
  98. package/test/piano-input.test.js +44 -0
  99. package/test/playback-buffer.test.js +31 -0
  100. package/test/position-audio.test.js +24 -0
  101. package/test/positioning.test.js +22 -0
  102. package/test/prepared-script-cache.test.js +45 -0
  103. package/test/pulse-client.test.js +27 -5
  104. package/test/raster-readback.test.js +40 -0
  105. package/test/render-tree.test.js +28 -0
  106. package/test/renderer-memory.test.js +11 -0
  107. package/test/selector-traits.test.js +24 -0
  108. package/test/shared-frame-pool.test.js +63 -0
  109. package/test/style-sheets.test.js +51 -0
  110. package/test/svg.test.js +29 -0
  111. package/test/text-layout.test.js +168 -0
  112. package/test/video-keyframe.test.js +61 -0
  113. package/test/video-presentation.test.js +136 -0
  114. package/tools/browser-lab.js +78 -0
  115. package/tools/browser-soak.js +75 -0
  116. package/tools/network-reference.js +49 -0
  117. package/tools/process-memory.js +38 -0
  118. package/tools/script-benchmark.js +25 -0
@@ -226,6 +226,69 @@ test("direct eval preserves receivers of lexical calls and tagged templates", as
226
226
  ]);
227
227
  });
228
228
 
229
+ test("pointer movement delivers trusted mouse events to page controls", async () => {
230
+ await load(`<div id="control" style="width:100px;height:100px;background:red"></div><script>
231
+ window.moves = [];
232
+ for (const type of ['mouseenter','mousemove','mouseleave']) {
233
+ document.querySelector('#control').addEventListener(type, e => moves.push([e.type,e.isTrusted]));
234
+ }
235
+ </script>`);
236
+ await renderer.call('move', 30, 30);
237
+ await renderer.call('move', 150, 150);
238
+ expect(await evaluate('moves')).toEqual([['mouseenter',true],['mousemove',true],['mouseleave',true]]);
239
+ });
240
+
241
+ test("assigning element.style forwards to cssText without replacing its identity", async () => {
242
+ await load(`<div id="target"></div><script>
243
+ const target = document.querySelector('#target'), original = target.style;
244
+ target.style = 'width: 73px; color: red';
245
+ window.styleResult = [target.style === original, target.style.width, target.style.color];
246
+ target.style = '';
247
+ styleResult.push(target.style.cssText);
248
+ </script>`);
249
+ expect(JSON.parse(await evaluate("JSON.stringify(styleResult)"))).toEqual([true, '73px', 'red', '']);
250
+ });
251
+
252
+ test("History methods borrowed from an iframe operate on the receiving history", async () => {
253
+ await load(`<iframe></iframe><script>
254
+ const frame = document.querySelector('iframe').contentWindow;
255
+ frame.History.prototype.replaceState.call(history, { step: 1 }, '', '/watch?v=example');
256
+ frame.History.prototype.pushState.call(history, { step: 2 }, '', '#comments');
257
+ window.historyResult = [location.href, history.state.step, history.length, frame.location.href];
258
+ try { frame.History.prototype.replaceState.call(history, {}, '', 'https://other.test/'); }
259
+ catch (e) { window.historyOriginError = e.name; }
260
+ </script>`);
261
+ expect(JSON.parse(await evaluate("JSON.stringify(historyResult)"))).toEqual([
262
+ 'https://page.test/watch?v=example#comments', 2, 2, 'about:blank',
263
+ ]);
264
+ expect(await evaluate("historyOriginError")).toBe('SecurityError');
265
+ });
266
+
267
+ test("custom elements receive observed attribute reactions and can enable deferred initialization", async () => {
268
+ await load(`<script>
269
+ window.reactions = [];
270
+ window.observedReads = 0;
271
+ class DeferredPanel extends HTMLElement {
272
+ static get observedAttributes() { observedReads++; return ['disable-upgrade']; }
273
+ attributeChangedCallback(name, before, after) {
274
+ reactions.push([name, before, after, this === document.querySelector('deferred-panel')]);
275
+ if (after === null) this.textContent = 'ready';
276
+ }
277
+ }
278
+ customElements.define('deferred-panel', DeferredPanel);
279
+ const panel = document.createElement('deferred-panel');
280
+ document.body.appendChild(panel);
281
+ panel.setAttribute('disable-upgrade', '');
282
+ panel.setAttribute('unobserved', 'value');
283
+ panel.removeAttribute('disable-upgrade');
284
+ </script>`);
285
+ expect(JSON.parse(await evaluate("JSON.stringify(reactions)"))).toEqual([
286
+ ['disable-upgrade', null, '', true], ['disable-upgrade', '', null, true],
287
+ ]);
288
+ expect(await evaluate("observedReads")).toBe(1);
289
+ expect(await evaluate("document.querySelector('deferred-panel').textContent")).toBe('ready');
290
+ });
291
+
229
292
  test("documents and shadow roots have adoptedStyleSheets, which page patches of Element.prototype may extend", async () => {
230
293
  await load(`<div id="host"></div><script>
231
294
  // As a popover polyfill does: every new shadow root adopts its sheet.
@@ -354,6 +417,24 @@ test("form.elements is a live named collection of form controls", async () => {
354
417
  expect(await evaluate("document.getElementById('form').elements.length")).toBe(3);
355
418
  });
356
419
 
420
+ test("uncommitted links do not change the base used by subsequent fragment links", async () => {
421
+ for (const href of ["/archive.zip", "/LICENSE"]) {
422
+ for (const scripted of [false, true]) {
423
+ await load(`<a id="away" href="${href}">away</a><a id="toc" href="#contents">TOC</a>
424
+ <h2 id="contents">Contents</h2>`);
425
+ const before = await evaluate("[document.URL, document.baseURI, location.href, document.getElementById('toc').href]");
426
+ if (scripted) await evaluate("document.getElementById('away').click()");
427
+ else {
428
+ const point = await evaluate("(() => { const r=document.getElementById('away').getBoundingClientRect(); return [r.x+r.width/2,r.y+r.height/2]; })()");
429
+ expect(await renderer.call("click", ...point)).toBe(`https://page.test${href}`);
430
+ }
431
+ expect(await evaluate("[document.URL, document.baseURI, location.href, document.getElementById('toc').href]")).toEqual(before);
432
+ await evaluate("document.getElementById('toc').click()");
433
+ expect(await evaluate("[location.href, document.URL, document.baseURI]")).toEqual(Array(3).fill("https://page.test/#contents"));
434
+ }
435
+ }
436
+ });
437
+
357
438
  test("constructable CSSStyleSheet supports feature checks and basic rule mutation", async () => {
358
439
  await load(`<p>cssom</p>`);
359
440
  expect(await evaluate(`(async () => {
@@ -391,6 +472,23 @@ test("document.elementFromPoint finds the topmost element painted there", async
391
472
  document.elementFromPoint(300, 30)?.id, document.elementFromPoint(-1, 5)]`)).toEqual(["text", "over", "under", null]);
392
473
  });
393
474
 
475
+ test("rounded overflow clips a child's pixels rather than only the parent's background", async () => {
476
+ await load('<div style="width:40px;height:40px;border-radius:20px;overflow:hidden"><div style="width:40px;height:40px;background:red"></div></div>');
477
+ const clipped = await renderer.call("screenshot");
478
+ const { default: init } = await import('../usr/lib/canvaskit/canvaskit.js');
479
+ const kit = await init({ locateFile: name => new URL(`../usr/lib/canvaskit/${name}`, import.meta.url).pathname });
480
+ const image = kit.MakeImageFromEncoded(Buffer.from(clipped, 'base64'));
481
+ try {
482
+ const pixel = (x, y) => Array.from(image.readPixels(x, y, {
483
+ width:1, height:1, colorType:kit.ColorType.RGBA_8888, alphaType:kit.AlphaType.Unpremul, colorSpace:kit.ColorSpace.SRGB,
484
+ })).slice(0, 3);
485
+ expect(pixel(0, 0)).toEqual([255, 255, 255]);
486
+ expect(pixel(20, 20)).toEqual([255, 0, 0]);
487
+ } finally { image.delete(); }
488
+ await load('<div style="width:40px;height:40px;background:red"></div>');
489
+ expect(await renderer.call("screenshot")).not.toBe(clipped);
490
+ });
491
+
394
492
  test("scripts change the DOM and the next screenshot shows it", async () => {
395
493
  await load(`<div id="box" style="height:20px"></div>
396
494
  <script>document.getElementById('box').style.background = 'rgb(255, 0, 0)';
@@ -577,6 +675,40 @@ test("CSS.escape produces selectors for leading digits and punctuation", async (
577
675
  expect(await evaluate("CSS.escape('1:a')")).toBe("\\31 \\:a");
578
676
  });
579
677
 
678
+ test("legacy keyboard shortcuts cancel space scrolling on every press", async () => {
679
+ await load(`<div tabindex="0" id="player" style="height:100px"></div><div style="height:2000px"></div>
680
+ <script>var keys=[],player=document.getElementById('player');player.focus();player.addEventListener('keydown',e=>{
681
+ keys.push([e.key,e.code,e.keyCode,e.which,e.isTrusted]);if(e.keyCode===32)e.preventDefault();
682
+ });</script>`);
683
+ for (let i=0;i<6;i++) await renderer.call('press',' ',{code:'Space',windowsVirtualKeyCode:32});
684
+ expect(await evaluate('keys')).toEqual(Array.from({length:6},()=>[' ','Space',32,32,true]));
685
+ expect(await renderer.call('scrollOffset')).toEqual({x:0,y:0});
686
+ await evaluate('player.blur()');
687
+ await renderer.call('press',' ',{code:'Space'});
688
+ expect((await renderer.call('scrollOffset')).y).toBeGreaterThan(0);
689
+ });
690
+
691
+ test("site media controls can repeatedly toggle using playing/pause events and legacy space keys", async () => {
692
+ await load(`<div id="player" tabindex="0" style="height:100px"><video id="v" src="/long-video.mp4" style="width:100px;height:80px"></video></div><div style="height:2000px"></div>
693
+ <script>var state='paused',events=[],player=document.getElementById('player'),v=document.getElementById('v');
694
+ for(const type of ['play','playing','pause'])v.addEventListener(type,()=>{events.push(type);if(type!=='play')state=type;});
695
+ function toggle(){if(state==='playing')v.pause();else v.play();}
696
+ player.addEventListener('click',toggle);
697
+ player.addEventListener('keydown',e=>{if(e.keyCode===32){e.preventDefault();toggle();}});
698
+ player.focus();</script>`);
699
+ for (const input of ['click','press']) {
700
+ for(let i=0;i<6;i++) {
701
+ if(input==='click')await renderer.call('click',40,40);
702
+ else await renderer.call('press',' ',{code:'Space'});
703
+ const expected=i%2===0?'playing':'pause';
704
+ expect(await waitFor(`state===${JSON.stringify(expected)}`,5000)).toBe(true);
705
+ expect(await evaluate('v.paused')).toBe(i%2===1);
706
+ }
707
+ }
708
+ expect(await renderer.call('scrollOffset')).toEqual({x:0,y:0});
709
+ expect(await evaluate('events.slice(0,3)')).toEqual(['play','playing','pause']);
710
+ });
711
+
580
712
  test("a video plays frames and pauses when clicked", async () => {
581
713
  await load(`<video src="/long-video.mp4" controls width="160" height="120"></video>`);
582
714
  const bounds = await evaluate("document.querySelector('video').getBoundingClientRect().toJSON()");
@@ -1084,7 +1216,7 @@ test("ES modules: graphs, live exports, JSON, import.meta, dynamic import, TLA,
1084
1216
  await load(`
1085
1217
  <script type="importmap">{ "imports": { "cdn-lib": "https://cdn.test/lib/index.js" } }</script>
1086
1218
  <script type="module" src="/modules/main.js"></script>
1087
- <script type="module">window.inlineModule = import.meta.url.includes('#inline-module');</script>`);
1219
+ <script type="module">window.inlineModule = import.meta.url === document.baseURI;</script>`);
1088
1220
  expect(await evaluate("moduleResult")).toBe([
1089
1221
  // Imports are live bindings, named and namespace alike.
1090
1222
  "named:2", "namespace:2", "twice:42", "json:42", "loaded lazily from lazy.js",
@@ -1707,7 +1839,8 @@ test("initial MSE playback waits briefly for a separate audio segment", async ()
1707
1839
  await load(`<video id="v" width="160" height="120"></video>`);
1708
1840
  await evaluate(`(() => {
1709
1841
  const video = document.getElementById("v");
1710
- video.addEventListener("play", () => { window.startedAt = performance.now(); });
1842
+ // play is the unpause request; playing is when buffered media can advance.
1843
+ video.addEventListener("playing", () => { window.startedAt = performance.now(); }, { once:true });
1711
1844
  const source = new MediaSource();
1712
1845
  video.src = URL.createObjectURL(source);
1713
1846
  source.addEventListener("sourceopen", async () => {
@@ -1861,6 +1994,31 @@ test("a click inside an iframe goes to the element under it in the frame's docum
1861
1994
  expect(await evaluate(`document.getElementById("frame").contentWindow.clicked`)).toEqual([[20, 20]]);
1862
1995
  });
1863
1996
 
1997
+ test("dynamic linked stylesheets fire load after their rules become available", async () => {
1998
+ await load(`<div id="target"></div><script>
1999
+ window.sheetLoaded = false;
2000
+ const link = document.createElement('link');
2001
+ link.rel = 'stylesheet';
2002
+ link.href = '/slow.css';
2003
+ link.onload = () => { window.sheetLoaded = getComputedStyle(document.body).color; };
2004
+ document.head.appendChild(link);
2005
+ </script>`);
2006
+ for (let i = 0; i < 60 && await evaluate('sheetLoaded') === false; i++) await Bun.sleep(25);
2007
+ expect(await evaluate('sheetLoaded')).toBe('rgba(255, 0, 0, 1)');
2008
+ });
2009
+
2010
+ test("decoded dynamic images report natural dimensions and fire load", async () => {
2011
+ await load(`<script>
2012
+ window.imageLoaded = false;
2013
+ const img = document.createElement('img');
2014
+ img.onload = () => { window.imageLoaded = [img.complete, img.naturalWidth, img.naturalHeight]; };
2015
+ img.src = '/red-2x3.png';
2016
+ document.body.appendChild(img);
2017
+ </script>`);
2018
+ for (let i = 0; i < 60 && await evaluate('imageLoaded') === false; i++) await Bun.sleep(25);
2019
+ expect(await evaluate('imageLoaded')).toEqual([true, 2, 3]);
2020
+ });
2021
+
1864
2022
  test("images a script inserts and measures at once still load", async () => {
1865
2023
  await load(`<div id="host"></div><script>
1866
2024
  const img = document.createElement("img");
@@ -0,0 +1,103 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { cropViewport, encodeImage, encodeImageBase64 } from '../lib/paint/frame.js';
3
+ import { FrameCaptureCache } from '../lib/paint/frame-cache.js';
4
+ import { appendParagraphText } from '../lib/paint/paragraph-text.js';
5
+
6
+ function numberedTile() {
7
+ const bytes = new Uint8Array(7 + 3 * 4 * 4);
8
+ const pixels = bytes.subarray(7);
9
+ for (let i = 0; i < 12; i++) pixels.set([i + 1, 20, 30, 255], i * 4);
10
+ return { pixels, pixelWidth: 3, pixelHeight: 4, x: 0, y: 10, width: 3, height: 4, scale: 1,
11
+ background: [99, 0, 0, 128] };
12
+ }
13
+ const reds = (pixels) => Array.from({ length: pixels.length / 4 }, (_, i) => pixels[i * 4]);
14
+
15
+ test('full-width viewport crops reuse immutable tile bytes and respect the source byte offset', () => {
16
+ const tile = numberedTile();
17
+ const result = cropViewport(tile, 0, 11, 3, 2);
18
+ expect(result.pixels.buffer).toBe(tile.pixels.buffer);
19
+ expect(reds(result.pixels)).toEqual([4, 5, 6, 7, 8, 9]);
20
+ expect(result.width).toBe(3);
21
+ expect(result.height).toBe(2);
22
+ expect(reds(cropViewport(tile, 0, 10, 3, 4).pixels)).toEqual(reds(tile.pixels));
23
+ });
24
+
25
+ test('partial crops preserve columns, background alpha, and scaled outside rows', () => {
26
+ const tile = numberedTile();
27
+ expect(reds(cropViewport(tile, 1, 11, 2, 2).pixels)).toEqual([5, 6, 8, 9]);
28
+ const clipped = cropViewport(tile, -1, 9, 4, 3);
29
+ expect(reds(clipped.pixels)).toEqual([99, 99, 99, 99, 99, 1, 2, 3, 99, 4, 5, 6]);
30
+ expect([...clipped.pixels.subarray(0, 4)]).toEqual([99, 0, 0, 128]);
31
+ const scaled = { ...tile, scale: 2, width: 1.5, height: 2 };
32
+ expect(reds(cropViewport(scaled, 0.5, 10.5, 1, 1).pixels)).toEqual([5, 6, 8, 9]);
33
+ });
34
+
35
+ test('native base64 capture preserves PNG and JPEG bytes', async () => {
36
+ const tile = numberedTile();
37
+ for (const format of ['png', 'jpeg', 'webp']) {
38
+ const bytes = await encodeImage(tile.pixels, 3, 4, { format, quality: 85 });
39
+ const base64 = await encodeImageBase64(tile.pixels, 3, 4, { format, quality: 85 });
40
+ expect(Buffer.from(base64, 'base64')).toEqual(Buffer.from(bytes));
41
+ }
42
+ });
43
+
44
+ test('encoding releases only its temporary bytes on success and failure', async () => {
45
+ const pixels = new Uint8Array([5, 10, 15, 255]);
46
+ const expected = [...pixels];
47
+ const encoded = await encodeImage(pixels, 1, 1);
48
+ expect(encoded.byteLength).toBeGreaterThan(0);
49
+ expect([...pixels]).toEqual(expected);
50
+ await expect(encodeImage(pixels, 100, 100)).rejects.toThrow();
51
+ expect([...pixels]).toEqual(expected);
52
+ expect((await new Bun.Image(encoded).metadata()).width).toBe(1);
53
+ });
54
+
55
+ test('simultaneous captures share work and an older completion cannot replace the latest frame', async () => {
56
+ const cache = new FrameCaptureCache();
57
+ let completeOld;
58
+ let captures = 0;
59
+ const first = cache.get('old', () => { captures++; return new Promise(resolve => { completeOld = resolve; }); });
60
+ expect(cache.get('old', () => { captures++; })).toBe(first);
61
+ expect(captures).toBe(1);
62
+ const second = cache.get('new', () => 'new frame');
63
+ expect(await second).toBe('new frame');
64
+ completeOld('old frame');
65
+ expect(await first).toBe('old frame');
66
+ expect(cache.get('new', () => { throw new Error('must retain new'); })).toBe(second);
67
+ });
68
+
69
+ test('capture failures allow retry without discarding a newer frame', async () => {
70
+ const cache = new FrameCaptureCache();
71
+ await expect(cache.get('bad', () => { throw new Error('encode failed'); })).rejects.toThrow('encode failed');
72
+ expect(await cache.get('bad', () => 'retry')).toBe('retry');
73
+ let fail;
74
+ const pending = cache.get('old', () => new Promise((_, reject) => { fail = reject; }));
75
+ const next = cache.get('new', () => 'new');
76
+ fail(new Error('old failed'));
77
+ await expect(pending).rejects.toThrow('old failed');
78
+ expect(cache.get('new', () => 'wrong')).toBe(next);
79
+ });
80
+
81
+ function textRuns(text) {
82
+ let emoji = false;
83
+ const runs = [];
84
+ appendParagraphText({
85
+ addText: text => runs.push({ text, emoji }),
86
+ pushStyle: () => { emoji = true; },
87
+ pop: () => { emoji = false; },
88
+ }, text, () => ({}));
89
+ return runs;
90
+ }
91
+
92
+ test('paragraphs batch ordinary text while preserving whole emoji graphemes and fallback boundaries', () => {
93
+ const ordinary = 'Latin office العربية हिन्दी 中文 e\u0301 ';
94
+ expect(textRuns(ordinary)).toEqual([{ text: ordinary, emoji: false }]);
95
+ expect(textRuns('')).toEqual([{ text: ' ', emoji: false }]);
96
+ const emojis = ['👨‍👩‍👧‍👦', '❤️', '🇹🇼', '1️⃣', '👍🏽'];
97
+ const mixed = `${ordinary}${emojis.join(' + ')} end`;
98
+ const runs = textRuns(mixed);
99
+ expect(runs.map(run => run.text).join('')).toBe(mixed);
100
+ expect(runs.filter(run => run.emoji).map(run => run.text)).toEqual(emojis);
101
+ expect(runs[0].text).toBe(ordinary);
102
+ expect(runs.at(-1)).toEqual({ text: ' end', emoji: false });
103
+ });
@@ -0,0 +1,87 @@
1
+ import { expect, test } from "bun:test";
2
+ import { createPageRenderer, loadGraphics } from "../lib/renderer/page-renderer.js";
3
+
4
+ test("scroll paints reuse shaped text, evict bounded resources, and release paragraphs on navigation/disposal", async () => {
5
+ const { CanvasKit } = await loadGraphics();
6
+ const original = CanvasKit.ParagraphBuilder.MakeFromFontCollection;
7
+ let built = 0;
8
+ let alive = 0;
9
+ CanvasKit.ParagraphBuilder.MakeFromFontCollection = (...args) => {
10
+ const builder = original(...args);
11
+ const build = builder.build.bind(builder);
12
+ builder.build = () => {
13
+ const paragraph = build();
14
+ const dispose = paragraph.delete.bind(paragraph);
15
+ built++;
16
+ alive++;
17
+ paragraph.delete = () => { alive--; dispose(); };
18
+ return paragraph;
19
+ };
20
+ return builder;
21
+ };
22
+ let renderer;
23
+ try {
24
+ renderer = await createPageRenderer({ fetchResource: async url => ({ ok: false, status: 404, url, body: new Uint8Array() }) });
25
+ renderer.resize(400, 200, 1);
26
+ await renderer.loadDocument({ url: "https://paint.test/", status: 200, contentType: "text/html",
27
+ source: `<!doctype html><style>body { margin:0 } p { margin:0; height:30px }</style>${
28
+ Array.from({ length: 400 }, (_, i) => `<p>Article number ${i}: shared text</p>`).join("")}` });
29
+ await renderer.whenLoaded();
30
+ const first = await renderer.screenshot();
31
+ const before = built;
32
+ renderer.scroll(0, 1);
33
+ await renderer.screenshot();
34
+ // No layout invalidation and the same seven visible paragraph items.
35
+ expect(built - before).toBeLessThanOrEqual(1);
36
+ renderer.scroll(0, -1);
37
+ expect(await renderer.screenshot()).toBe(first);
38
+ // A fresh layout/display list still uses identical text shaping inputs.
39
+ const beforeLayout = built;
40
+ renderer.resize(400, 201, 1);
41
+ await renderer.screenshot();
42
+ expect(built - beforeLayout).toBeLessThanOrEqual(1);
43
+ renderer.resize(400, 200, 1);
44
+ expect(await renderer.screenshot()).toBe(first);
45
+
46
+ for (let i = 0; i < 40; i++) renderer.scroll(0, 250);
47
+ expect(alive).toBeGreaterThan(0);
48
+ expect(alive).toBeLessThanOrEqual(256);
49
+ renderer.resize(400, 200, 2);
50
+ const zoomed = await renderer.screenshot();
51
+ expect((await new Bun.Image(Buffer.from(zoomed, "base64")).metadata()).width).toBe(800);
52
+ await renderer.loadDocument({ url: "https://paint.test/empty", status: 200, contentType: "text/html", source: "<!doctype html><body></body>" });
53
+ await renderer.whenLoaded();
54
+ expect(alive).toBe(0);
55
+ } finally {
56
+ renderer?.dispose();
57
+ CanvasKit.ParagraphBuilder.MakeFromFontCollection = original;
58
+ }
59
+ expect(alive).toBe(0);
60
+ });
61
+
62
+ test("screenshot encoding options are honored and separated in the frame cache", async () => {
63
+ const { CanvasKit: kit } = await loadGraphics();
64
+ const renderer = await createPageRenderer({});
65
+ const images = [];
66
+ try {
67
+ renderer.resize(400, 200, 1);
68
+ await renderer.loadDocument({ url: "https://capture.test/", contentType: "text/html",
69
+ source: "<body style='margin:0;color:#256;background:#def'><h1>Frame format cache</h1><p>Lossless PNG and JPEG capture</p>" });
70
+ await renderer.whenLoaded();
71
+ const png = await renderer.screenshot();
72
+ const jpeg = await renderer.screenshot({ format: "jpeg", quality: 85 });
73
+ expect((await new Bun.Image(Buffer.from(jpeg, "base64")).metadata()).format).toBe("jpeg");
74
+ const fast = await renderer.screenshot({ optimizeForSpeed: true });
75
+ const pixelBytes = value => {
76
+ const image = kit.MakeImageFromEncoded(Buffer.from(value, "base64"));
77
+ images.push(image);
78
+ return image.readPixels(0, 0, { width: 400, height: 200, colorType: kit.ColorType.RGBA_8888,
79
+ alphaType: kit.AlphaType.Unpremul, colorSpace: kit.ColorSpace.SRGB });
80
+ };
81
+ expect(pixelBytes(fast)).toEqual(pixelBytes(png));
82
+ expect(await renderer.screenshot()).toBe(png);
83
+ } finally {
84
+ for (const image of images) image.delete();
85
+ renderer.dispose();
86
+ }
87
+ });
@@ -0,0 +1,44 @@
1
+ import { test, expect } from "bun:test";
2
+ import { handlePianoInput } from "../lib/renderer/piano-input.js";
3
+
4
+ const input = { getAttribute: () => "buninu-piano-input" };
5
+
6
+ test("CDP raw keydown uses the physical key even with an empty text field", () => {
7
+ const notes = [];
8
+ const play = n => notes.push(n);
9
+ for (let i = 0; i < 3; i++) {
10
+ expect(handlePianoInput(input, { key: " ", code: "Space", text: "", modifiers: 0, repeat: false }, play)).toBe(true);
11
+ }
12
+ expect(notes).toEqual([69, 69, 69]);
13
+ expect(handlePianoInput(input, { key: "F1", code: "F1", text: "" }, play)).toBe(true);
14
+ expect(notes.at(-1)).toBe(38);
15
+ expect(handlePianoInput(input, { key: " ", code: "Space", text: " ", repeat: true }, play)).toBe(true);
16
+ expect(notes).toHaveLength(4);
17
+ expect(handlePianoInput(input, { key: "Tab", code: "Tab", text: "" }, play)).toBe(false);
18
+ });
19
+
20
+ test("piano consumes batched terminal/IME text and plays every mapped note", () => {
21
+ const notes = [];
22
+ expect(handlePianoInput(input, { text: "jk L!中" }, n => notes.push(n))).toBe(true);
23
+ expect(notes).toEqual([71, 73, 69, 74, 45]);
24
+ expect(handlePianoInput(input, { text: "jk" }, null)).toBe(true);
25
+ });
26
+
27
+ test("piano consumes repeats without retriggering and supports shifted physical keys", () => {
28
+ const notes = [];
29
+ const play = n => notes.push(n);
30
+ expect(handlePianoInput(input, { key: "j", repeat: true }, play)).toBe(true);
31
+ expect(handlePianoInput(input, { key: " ", repeat: true }, play)).toBe(true);
32
+ expect(handlePianoInput(input, { key: "J", code: "KeyJ", modifiers: 8 }, play)).toBe(true);
33
+ expect(handlePianoInput(input, { key: "j", modifiers: 2 }, play)).toBe(true);
34
+ expect(notes).toEqual([71]);
35
+ expect(handlePianoInput(input, { key: "Tab" }, play)).toBe(false);
36
+ expect(handlePianoInput(input, { key: "Backspace" }, play)).toBe(false);
37
+ });
38
+
39
+ test("ordinary, disabled and readonly editors are unaffected", () => {
40
+ expect(handlePianoInput({ getAttribute: () => "editor" }, { text: "jk" })).toBe(false);
41
+ expect(handlePianoInput({ ...input, disabled: true }, { text: "jk" })).toBe(false);
42
+ expect(handlePianoInput({ ...input, readOnly: true }, { text: "jk" })).toBe(false);
43
+ expect(handlePianoInput(null, { text: "jk" })).toBe(false);
44
+ });
@@ -0,0 +1,31 @@
1
+ import { expect, test } from "bun:test";
2
+ import { PlaybackBuffer } from "../lib/audio/playback-buffer.js";
3
+
4
+ test("media prepares a bounded reserve regardless of server credits", () => {
5
+ const buffer = new PlaybackBuffer(1000, 2, 0.5);
6
+ expect(buffer.request(0, 0, 0, 0)).toBe(2000);
7
+ expect(buffer.request(0, 2000, 0, 500)).toBe(0);
8
+ expect(buffer.request(1500, 0, 500, 0)).toBe(0);
9
+ expect(buffer.request(1100, 0, 500, 40)).toBe(400);
10
+ expect(buffer.request(1100, 400, 500, 40)).toBe(0);
11
+ });
12
+
13
+ test("startup and starvation recovery require playable PCM, not elapsed time", () => {
14
+ const buffer = new PlaybackBuffer(1000, 2, 0.5);
15
+ expect(buffer.ready(40, 0, false)).toBe(false);
16
+ expect(buffer.ready(398, 0, false)).toBe(false);
17
+ expect(buffer.ready(400, 0, false)).toBe(true);
18
+ expect(buffer.ready(40, 300, false)).toBe(true);
19
+ expect(buffer.ready(0, 0, false)).toBe(false);
20
+ expect(buffer.ready(40, 0, false)).toBe(false);
21
+ expect(buffer.ready(400, 0, false)).toBe(true);
22
+ });
23
+
24
+ test("short clips drain and piano stays interactive", () => {
25
+ const media = new PlaybackBuffer(1000, 2, 0.5);
26
+ expect(media.ready(40, 0, true)).toBe(true);
27
+ const piano = new PlaybackBuffer(1000, 2, 0.12);
28
+ expect(piano.ready(40, 0, false)).toBe(true);
29
+ expect(piano.request(0, 0, 0, 120)).toBe(120);
30
+ expect(piano.request(0, 120, 0, 120)).toBe(0);
31
+ });
@@ -65,3 +65,27 @@ test("an ended prefix or failed reopen never claims to have reached the target",
65
65
  const sound = track(pipe, 2);
66
66
  expect(await advanceAudio(() => sound, 8, { needsMore: () => false, reopen: async () => false })).toBe(false);
67
67
  });
68
+
69
+ test("a seek before the first audio epoch cannot keep reopening an unreachable prefix", async () => {
70
+ let sound = { position: 6, decoder: { sampleRate: 1, channels: 1 } }, reopened = 0;
71
+ expect(await advanceAudio(() => sound, 2, {
72
+ needsMore: () => true,
73
+ reopen: async () => { reopened++; sound = { ...sound, position: 6 }; return true; },
74
+ })).toBe(false);
75
+ expect(reopened).toBe(1);
76
+ });
77
+
78
+ test("audio epoch EOF resumes at the next absolute sample cursor", async () => {
79
+ const pipe = createBytePipe(); pipe.finish();
80
+ let sound = { position: 6, pending: null, endTime: 6, snapshotId: 1, openedGeneration: 1, pipe,
81
+ decoder: { sampleRate: 1, channels: 1, nextSamples: () => null } };
82
+ expect(await advanceAudio(() => sound, 7, {
83
+ needsMore: () => true,
84
+ reopen: async () => {
85
+ sound = { ...sound, position: 6, endTime: Infinity, snapshotId: 2, pending: new Float32Array([6, 7]) };
86
+ return true;
87
+ },
88
+ })).toBe(true);
89
+ expect(sound.position).toBe(7);
90
+ expect([...sound.pending]).toEqual([7]);
91
+ });
@@ -107,6 +107,28 @@ test("relative offsets and translate move a box and its content without affectin
107
107
  expect(box(".centered")).toMatchObject({ x: -20, y: 40 + 10 });
108
108
  });
109
109
 
110
+ test("transform lists compose pixel and percentage translations in order", () => {
111
+ const { box } = render(
112
+ `<div class="popup"></div><div class="scaled"></div><div class="reversed"></div><div class="math"></div>`,
113
+ `.popup,.scaled,.reversed,.math { position:absolute;left:0;top:0;width:100px;height:153px }
114
+ .popup { transform:translate(56px,780px) translate(0,-100%) }
115
+ .scaled { transform:scale(2,3) translate(10px,20%) translateX(5px) }
116
+ .reversed { transform:translate(10px,20%) scale(2,3) translateX(5px) }
117
+ .math { transform:translate(calc(25% + 10px),min(20%,40px)) translate(10%,5px) }`,
118
+ );
119
+ expect(box(".popup")).toEqual({x:56,y:627,width:100,height:153});
120
+ expect(box(".scaled")).toMatchObject({x:30,y:91.8});
121
+ expect(box(".reversed")).toMatchObject({x:20,y:30.6});
122
+ expect(box(".math")).toMatchObject({x:45,y:35.6});
123
+ });
124
+
125
+ test("individual translate precedes the transform list and preserves normal flow", () => {
126
+ const {box} = render('<div class="translated"></div><div class="next"></div>',
127
+ '.translated{width:100px;height:80px;translate:10px -50%;transform:scale(2) translate(5px,10px)}.next{height:20px}');
128
+ expect(box('.translated')).toEqual({x:20,y:-20,width:100,height:80});
129
+ expect(box('.next').y).toBe(80);
130
+ });
131
+
110
132
  test("an absolutely positioned ::after arrow is centred on its host's edge", () => {
111
133
  // The buninu.org step arrow: top:50%; right:-.75rem; translate(50%,-50%).
112
134
  const { box } = render(
@@ -0,0 +1,45 @@
1
+ import { expect, test } from "bun:test";
2
+ import { PreparedScriptCache, prepareClassicScript } from "../lib/renderer/prepared-script-cache.js";
3
+ import { escapeSesHtmlComments, rewriteClassicScriptSource } from "../lib/renderer/script-rewrite.js";
4
+
5
+ test("cached preparation preserves source semantics and does not retain evaluated globals", () => {
6
+ const source = "var result = supplied + 1; function read() { return result; }";
7
+ const prepared = prepareClassicScript(source);
8
+ const original = rewriteClassicScriptSource(source);
9
+ expect(prepared).toEqual({ code: escapeSesHtmlComments(original.code), parsed: true, strict: false });
10
+ expect(prepareClassicScript(source)).toBe(prepared);
11
+ const run = supplied => {
12
+ const page = { supplied, $buninu$ref: name => { throw new ReferenceError(name); } };
13
+ page.globalThis = page;
14
+ Function("page", "source", "with(page) { eval(source); }")(page, prepared.code);
15
+ return page;
16
+ };
17
+ const first = run(2), second = run(8);
18
+ expect(first.read()).toBe(3);
19
+ expect(second.read()).toBe(9);
20
+ expect(first.read).not.toBe(second.read);
21
+ expect(Object.isFrozen(prepared)).toBeTrue();
22
+ });
23
+
24
+ test("preparation retains a bounded working set and never keeps oversized sources", () => {
25
+ const calls = [];
26
+ const cache = new PreparedScriptCache({ maxBytes: 40, maxEntries: 2, prepare(source) {
27
+ calls.push(source); return { code: source, parsed: true, strict: false };
28
+ } });
29
+ cache.prepare("first"); cache.prepare("next"); cache.prepare("first"); cache.prepare("third");
30
+ expect(calls).toEqual(["first", "next", "third"]);
31
+ cache.prepare("next");
32
+ expect(calls).toEqual(["first", "next", "third", "next"]);
33
+ const oversized = "a".repeat(11);
34
+ cache.prepare(oversized); cache.prepare(oversized);
35
+ expect(calls.filter(source => source === oversized)).toHaveLength(2);
36
+ expect(cache.prepare("next").code).toBe("next");
37
+ expect(calls.filter(source => source === "next")).toHaveLength(2);
38
+ });
39
+
40
+ test("different strictness and invalid sources never share a prepared result", () => {
41
+ const cache = new PreparedScriptCache();
42
+ expect(cache.prepare("'use strict'; var value = 1;").strict).toBeTrue();
43
+ expect(cache.prepare("var value = 1;").strict).toBeFalse();
44
+ expect(cache.prepare("var = ;").parsed).toBeFalse();
45
+ });
@@ -134,7 +134,14 @@ function header(length, channel) {
134
134
  test("measured device progress governs the media clock and bounded audio queue through starvation", async () => {
135
135
  const { createServer } = await import("node:net");
136
136
  let received = 0, played = 0, reports = 0;
137
- const times = [], errors = [];
137
+ const times = [], errors = [], requests = [];
138
+ // The suite also runs CPU-heavy renderers in parallel. Observe protocol
139
+ // progress instead of assuming a polling timer ran within 120 ms.
140
+ const waitFor = async predicate => {
141
+ const deadline = Date.now() + 2000;
142
+ while (!predicate() && Date.now() < deadline) await Bun.sleep(10);
143
+ expect(predicate()).toBe(true);
144
+ };
138
145
  const scalar64 = (tag, value) => {
139
146
  const bytes = Buffer.alloc(9);
140
147
  bytes[0] = tag;
@@ -171,14 +178,20 @@ test("measured device progress governs the media clock and bounded audio queue t
171
178
  const client = await PulseClient.connect({ port: server.address().port });
172
179
  try {
173
180
  const stream = await client.openPlayback({ rate: 500, channels: 1,
181
+ onRequest: bytes => requests.push(bytes),
174
182
  onTiming: timing => times.push(timing.seconds), onTimingError: error => errors.push(error) });
175
- stream.write(new Uint8Array(2000));
176
- await Bun.sleep(180);
183
+ expect(requests).toEqual([2000]); // controller reserve, not the device queue
184
+ stream.write(new Uint8Array(100));
185
+ await Bun.sleep(80);
186
+ expect(received).toBe(0); // a short prefix does not start/stop playback
187
+ stream.write(new Uint8Array(1900));
188
+ await waitFor(() => reports > 0 && received === 500 && times.length > 0);
177
189
  expect(reports).toBeGreaterThan(0);
178
190
  expect(received).toBe(500); // at most the 0.5 s queue budget, not 2 s
191
+ expect(stream.queued).toBe(1500); // survives a renderer busy spell in the controller
179
192
  expect(times.at(-1)).toBe(0); // server/device hasn't played anything
180
193
  played = 100;
181
- await Bun.sleep(120);
194
+ await waitFor(() => times.at(-1) === 0.1 && received === 600);
182
195
  expect(times.at(-1)).toBeCloseTo(0.1, 5);
183
196
  expect(received).toBe(600);
184
197
  // A long network/producer pause is NOT permission to catch up by queuing seconds.
@@ -186,12 +199,21 @@ test("measured device progress governs the media clock and bounded audio queue t
186
199
  expect(times.at(-1)).toBeCloseTo(0.1, 5);
187
200
  expect(received).toBe(600);
188
201
  played = 600;
189
- await Bun.sleep(120);
202
+ await waitFor(() => times.at(-1) === 0.6 && received > 1000);
190
203
  expect(times.at(-1)).toBeCloseTo(0.6, 5);
191
204
  expect(received).toBeGreaterThan(1000);
192
205
  expect(received).toBeLessThanOrEqual(1100);
206
+ // No more PCM is provided by the producer: continue from the local reserve.
207
+ for (let i = 0; i < 4; i++) {
208
+ played = received;
209
+ await Bun.sleep(150);
210
+ }
211
+ expect(received).toBeGreaterThan(1500);
212
+ expect(received).toBeLessThanOrEqual(2000);
213
+ expect(requests.reduce((sum, value) => sum + value, 0)).toBeLessThanOrEqual(4000);
193
214
  expect(errors).toEqual([]);
194
215
  await stream.close();
216
+ expect(stream.queued).toBe(0); // pause/seek discards reserve; no extra device tail
195
217
  } finally {
196
218
  client.close();
197
219
  await new Promise(resolve => server.close(resolve));