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.
- package/lib/audio/playback-buffer.js +27 -0
- package/lib/audio/pulse-client.js +18 -8
- package/lib/cdp-server.js +53 -14
- package/lib/happy-dom/document-base.js +33 -0
- package/lib/happy-dom/parser.js +20 -6
- package/lib/happy-dom/selector-matcher.js +2 -2
- package/lib/happy-dom/selector-pseudo-adapter.js +33 -0
- package/lib/happy-dom/svg-attribute-adapter.js +50 -0
- package/lib/headless-shell.js +12 -6
- package/lib/input/hit-test.js +67 -19
- package/lib/layout/text-layout.js +289 -131
- package/lib/media/byte-pipe.js +2 -2
- package/lib/media/position-audio.js +10 -1
- package/lib/media/source-snapshots.js +126 -0
- package/lib/media/video-keyframe.js +42 -0
- package/lib/media/webm-stream.js +106 -0
- package/lib/network/network-service.js +268 -120
- package/lib/paint/display-list.js +22 -27
- package/lib/paint/frame-cache.js +25 -0
- package/lib/paint/frame.js +42 -13
- package/lib/paint/paragraph-text.js +24 -0
- package/lib/paint/raster-readback.js +41 -0
- package/lib/paint/rounded-rect.js +82 -0
- package/lib/paint/shared-frame-pool.js +105 -0
- package/lib/render-tree/index.js +40 -6
- package/lib/render-tree/svg.js +15 -7
- package/lib/renderer/bindings.js +244 -82
- package/lib/renderer/broadcast-channel.js +76 -0
- package/lib/renderer/canvas-2d.js +257 -14
- package/lib/renderer/canvas-text.js +120 -0
- package/lib/renderer/compositor.js +27 -10
- package/lib/renderer/custom-element-reactions.js +42 -0
- package/lib/renderer/font-loading.js +227 -0
- package/lib/renderer/history.js +42 -0
- package/lib/renderer/host.js +36 -5
- package/lib/renderer/image-loading.js +27 -0
- package/lib/renderer/image-resources.js +65 -0
- package/lib/renderer/interfaces.js +28 -0
- package/lib/renderer/keyboard-input.js +12 -0
- package/lib/renderer/layout-observers.js +236 -0
- package/lib/renderer/main.js +33 -5
- package/lib/renderer/media-playback-events.js +13 -0
- package/lib/renderer/media-source.js +126 -55
- package/lib/renderer/page-realm.js +278 -147
- package/lib/renderer/page-renderer.js +840 -301
- package/lib/renderer/piano-input.js +22 -0
- package/lib/renderer/pointer-move.js +29 -0
- package/lib/renderer/prepared-script-cache.js +50 -0
- package/lib/renderer/rendered-text.js +108 -0
- package/lib/renderer/rewrite-worker.js +4 -3
- package/lib/renderer/script-rewrite.js +13 -2
- package/lib/renderer/scroll-into-view.js +28 -0
- package/lib/renderer/slot-assignment.js +60 -0
- package/lib/renderer/video-presentation.js +145 -0
- package/lib/renderer/workers.js +2 -2
- package/lib/style/computed-style.js +632 -108
- package/lib/style/selector-traits.js +49 -5
- package/package.json +1 -1
- package/test/broadcast-channel.test.js +26 -0
- package/test/canvas-2d.test.js +83 -2
- package/test/canvas-paint.test.js +9 -0
- package/test/canvas-text.test.js +41 -0
- package/test/cdp-stream.test.js +56 -0
- package/test/compositor.test.js +38 -0
- package/test/computed-style-binding.test.js +151 -0
- package/test/computed-style.test.js +279 -1
- package/test/display-list.test.js +26 -0
- package/test/document-base.test.js +87 -0
- package/test/dom-platform.test.js +461 -0
- package/test/download.test.js +8 -0
- package/test/fetch-lifecycle.test.js +161 -0
- package/test/fixtures/canvas-paint.js +117 -0
- package/test/fixtures/canvas-text.js +42 -0
- package/test/fixtures/image-lifetime.js +90 -0
- package/test/fixtures/media-seek-snapshot.js +77 -0
- package/test/fixtures/mse-audio-chirp.webm +0 -0
- package/test/fixtures/mse-switch-large.mp4 +0 -0
- package/test/fixtures/mse-switch-small.mp4 +0 -0
- package/test/fixtures/renderer-memory.js +110 -0
- package/test/fixtures/summary-style.js +28 -0
- package/test/font-loading.test.js +97 -0
- package/test/grid.test.js +58 -0
- package/test/headless-shell.test.js +22 -0
- package/test/hit-test.test.js +21 -1
- package/test/image-lifetime.test.js +12 -0
- package/test/image-resources.test.js +94 -0
- package/test/local-media.test.js +84 -0
- package/test/media-playback-events.test.js +22 -0
- package/test/media-seek-snapshot.test.js +11 -0
- package/test/media-source-config.test.js +98 -0
- package/test/media-source-webm-accounting.test.js +98 -0
- package/test/media-source-webm.test.js +108 -0
- package/test/navigation.test.js +1 -0
- package/test/network-service.test.js +388 -0
- package/test/page-scripts.test.js +160 -2
- package/test/paint-frame.test.js +103 -0
- package/test/paint-paragraph-cache.test.js +87 -0
- package/test/piano-input.test.js +44 -0
- package/test/playback-buffer.test.js +31 -0
- package/test/position-audio.test.js +24 -0
- package/test/positioning.test.js +22 -0
- package/test/prepared-script-cache.test.js +45 -0
- package/test/pulse-client.test.js +27 -5
- package/test/raster-readback.test.js +40 -0
- package/test/render-tree.test.js +28 -0
- package/test/renderer-memory.test.js +11 -0
- package/test/selector-traits.test.js +24 -0
- package/test/shared-frame-pool.test.js +63 -0
- package/test/style-sheets.test.js +51 -0
- package/test/svg.test.js +29 -0
- package/test/text-layout.test.js +168 -0
- package/test/video-keyframe.test.js +61 -0
- package/test/video-presentation.test.js +136 -0
- package/tools/browser-lab.js +78 -0
- package/tools/browser-soak.js +75 -0
- package/tools/network-reference.js +49 -0
- package/tools/process-memory.js +38 -0
- 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.
|
|
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
|
-
|
|
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
|
+
});
|
package/test/positioning.test.js
CHANGED
|
@@ -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
|
-
|
|
176
|
-
|
|
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
|
|
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
|
|
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));
|