buninu-browser 0.5.0 → 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.
- package/README.md +9 -3
- package/lib/audio/pcm.js +11 -0
- package/lib/audio/pulse-client.js +7 -2
- package/lib/cdp-server.js +31 -7
- package/lib/happy-dom/document-base.js +29 -1
- package/lib/happy-dom/inert-link.js +26 -0
- package/lib/happy-dom/mutation-observer.js +157 -0
- package/lib/happy-dom/node-iterator-removal.js +36 -0
- package/lib/happy-dom/node-replacement.js +60 -0
- package/lib/happy-dom/parser.js +6 -1
- package/lib/headless-shell.js +49 -9
- package/lib/layout/repaint.js +89 -0
- package/lib/layout/text-layout.js +4 -2
- package/lib/media/mp4-keyframes.js +144 -0
- package/lib/media/video-keyframe.js +29 -1
- package/lib/media/webm-snapshots.js +282 -0
- package/lib/network/cookie-jar.js +18 -2
- package/lib/paint/display-list.js +4 -3
- package/lib/paint/frame.js +12 -5
- package/lib/render-tree/index.js +3 -0
- package/lib/renderer/animation-frames.js +76 -0
- package/lib/renderer/bindings.js +107 -3
- package/lib/renderer/compositor-channel.js +95 -10
- package/lib/renderer/compositor.js +54 -18
- package/lib/renderer/document-cookies.js +87 -0
- package/lib/renderer/font-loading.js +60 -16
- package/lib/renderer/host.js +48 -19
- package/lib/renderer/idle-callbacks.js +109 -0
- package/lib/renderer/main.js +5 -1
- package/lib/renderer/media-source.js +4 -0
- package/lib/renderer/page-realm.js +159 -49
- package/lib/renderer/page-renderer.js +577 -201
- package/lib/renderer/resource-elements.js +88 -0
- package/lib/renderer/script-rewrite.js +18 -1
- package/lib/renderer/seccomp.js +6 -4
- package/lib/renderer/timers.js +71 -0
- package/lib/renderer/video-channel.js +94 -0
- package/lib/renderer/video-mask.js +115 -0
- package/lib/renderer/video-presentation.js +304 -39
- package/lib/renderer/video-worker.js +46 -0
- package/lib/renderer/workers.js +7 -17
- package/lib/style/computed-style.js +257 -61
- package/package.json +1 -1
- package/test/animation-frames.test.js +67 -0
- package/test/canvas-style-snapshot.test.js +18 -0
- package/test/cdp-stream.test.js +96 -3
- package/test/compare-node-iterator.js +42 -0
- package/test/compositor-channel-ownership.test.js +194 -0
- package/test/compositor.test.js +27 -1
- package/test/computed-style-binding.test.js +49 -0
- package/test/computed-style-connectivity.test.js +13 -0
- package/test/computed-style.test.js +86 -0
- package/test/cookie-sync.test.js +82 -0
- package/test/document-base-cache.test.js +103 -0
- package/test/document-cookies.test.js +117 -0
- package/test/duplicate-style-keys.test.js +11 -0
- package/test/fixtures/animation-frames.js +56 -0
- package/test/fixtures/canvas-style-snapshot.js +81 -0
- package/test/fixtures/computed-style-connectivity.js +101 -0
- package/test/fixtures/computed-style-lazy.js +135 -0
- package/test/fixtures/duplicate-style-keys.js +97 -0
- package/test/fixtures/fake-renderer.js +12 -0
- package/test/fixtures/font-definition-invalidation.js +97 -0
- package/test/fixtures/font-metrics-invalidation.js +87 -0
- package/test/fixtures/fractional-text-scale.js +38 -0
- package/test/fixtures/full-style-identity.js +36 -0
- package/test/fixtures/hidden-style-engine.js +260 -0
- package/test/fixtures/hidden-style-reentry.js +232 -0
- package/test/fixtures/hidden-style-renderer.js +203 -0
- package/test/fixtures/layout-repaint.js +69 -0
- package/test/fixtures/media-presentation-continuation.js +141 -0
- package/test/fixtures/media-presentation-refill.js +113 -0
- package/test/fixtures/media-probe.js +148 -0
- package/test/fixtures/media-refill-cancel.js +79 -0
- package/test/fixtures/media-repaint.js +48 -0
- package/test/fixtures/media-resource-reselection.js +94 -0
- package/test/fixtures/media-source-attachment.js +136 -0
- package/test/fixtures/media-source-no-decoder.js +17 -0
- package/test/fixtures/media-track-refill.js +132 -0
- package/test/fixtures/media-webm-seek.js +51 -0
- package/test/fixtures/mutation-observer.js +201 -0
- package/test/fixtures/node-iterator-cases.js +59 -0
- package/test/fixtures/node-iterator-chromium.json +315 -0
- package/test/fixtures/observer-invalidation.js +105 -0
- package/test/fixtures/prepaint-scheduling.js +73 -0
- package/test/fixtures/resource-discovery.js +32 -0
- package/test/fixtures/spare-renderer-scheduling.js +124 -0
- package/test/fixtures/style-propagation.js +26 -0
- package/test/fixtures/svg-node-reuse.js +83 -0
- package/test/fixtures/video-black-replay.js +140 -0
- package/test/fixtures/video-controls-invalidation.js +71 -0
- package/test/fixtures/viewport-bounds.js +148 -0
- package/test/font-loading.test.js +77 -0
- package/test/font-metrics-invalidation.test.js +60 -0
- package/test/fractional-text-scale.test.js +6 -0
- package/test/full-style-identity.test.js +191 -0
- package/test/hidden-style-lazy.test.js +31 -0
- package/test/hidden-style-reentry.test.js +20 -0
- package/test/idle-callback.test.js +89 -0
- package/test/idle-scheduler.test.js +148 -0
- package/test/inert-link.test.js +37 -0
- package/test/inline-style-cascade.test.js +63 -0
- package/test/layout-repaint-renderer.test.js +9 -0
- package/test/layout-repaint.test.js +222 -0
- package/test/media-presentation-continuation.test.js +14 -0
- package/test/media-presentation-refill.test.js +8 -0
- package/test/media-probe.test.js +16 -0
- package/test/media-repaint.test.js +8 -0
- package/test/media-source-attachment.test.js +34 -0
- package/test/media-track-refill.test.js +26 -0
- package/test/media-webm-seek.test.js +11 -0
- package/test/mp4-keyframes.test.js +36 -0
- package/test/mutation-observer.test.js +91 -0
- package/test/node-iterator.test.js +81 -0
- package/test/observer-invalidation.test.js +12 -0
- package/test/page-scripts.test.js +151 -1
- package/test/page-timers.test.js +67 -0
- package/test/paint-frame.test.js +18 -1
- package/test/pcm.test.js +63 -0
- package/test/prepaint-scheduling.test.js +13 -0
- package/test/pulse-client.test.js +165 -0
- package/test/raw-capture.test.js +34 -5
- package/test/renderer-host.test.js +51 -0
- package/test/resource-discovery.test.js +12 -0
- package/test/resource-elements.test.js +234 -0
- package/test/root-self-style.test.js +104 -0
- package/test/script-rewrite.test.js +35 -0
- package/test/seccomp.test.js +45 -0
- package/test/spare-renderer-scheduling.test.js +8 -0
- package/test/style-propagation.test.js +225 -0
- package/test/style-sheets.test.js +19 -0
- package/test/svg.test.js +45 -0
- package/test/text-layout.test.js +15 -0
- package/test/timers.test.js +58 -0
- package/test/video-black-replay.test.js +34 -0
- package/test/video-capture.test.js +170 -0
- package/test/video-channel.test.js +253 -0
- package/test/video-continuation.test.js +57 -0
- package/test/video-controls-invalidation.test.js +12 -0
- package/test/video-keyframe.test.js +104 -4
- package/test/video-mask.test.js +94 -0
- package/test/video-presentation.test.js +239 -1
- package/test/video-timing-presentation.test.js +115 -0
- package/test/viewport-bounds.test.js +13 -0
- package/test/webm-snapshots.test.js +372 -0
- package/test/window-resize.test.js +29 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterAll, beforeAll, expect, test } from 'bun:test';
|
|
2
|
+
import { RendererHost } from '../lib/renderer/host.js';
|
|
3
|
+
import { nodeIteratorCases } from './fixtures/node-iterator-cases.js';
|
|
4
|
+
import chromium from './fixtures/node-iterator-chromium.json';
|
|
5
|
+
|
|
6
|
+
const host = new RendererHost({timeout: 20000, fetch: async () => new Response('')});
|
|
7
|
+
beforeAll(async () => {
|
|
8
|
+
await host.call('loadDocument', {url:'https://iterator.test/',source:'<body></body>',contentType:'text/html',status:200});
|
|
9
|
+
await host.call('whenLoaded');
|
|
10
|
+
});
|
|
11
|
+
afterAll(() => host.close());
|
|
12
|
+
const evaluate = async expression => JSON.parse(await host.call('evaluate', `JSON.stringify(${expression})`));
|
|
13
|
+
|
|
14
|
+
test('NodeIterator traversal, replacement and WebIDL conversion match Chromium and DOM', async () => {
|
|
15
|
+
const actual = await evaluate(`(${nodeIteratorCases})()`);
|
|
16
|
+
const expected = chromium.map(row => {
|
|
17
|
+
// Chromium retains a detached reference when replacing a node with its
|
|
18
|
+
// following sibling while the pointer is before it. DOM replace adopts
|
|
19
|
+
// (detaches) that sibling first, then removes the old node: the repaired
|
|
20
|
+
// reference must be root, not the now-detached old node/descendant.
|
|
21
|
+
// https://dom.spec.whatwg.org/#concept-node-replace steps 6–7
|
|
22
|
+
if (row[1] === 'sibling' && [1,2].includes(row[2]) && row[3]) {
|
|
23
|
+
return [...row.slice(0,4), 'root', false, 'b', 'b', 'b'];
|
|
24
|
+
}
|
|
25
|
+
return row;
|
|
26
|
+
});
|
|
27
|
+
expect(actual).toEqual(expected);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('failed replacements leave the tree and iterator position unchanged', async () => {
|
|
31
|
+
expect(await evaluate(`(() => {
|
|
32
|
+
const results=[];
|
|
33
|
+
for(const kind of ['ancestor','missing','document','textDocument','twoElements']) {
|
|
34
|
+
const root=document.createElement('div');root.id='root';root.innerHTML='<b id="a"></b><i id="b"></i>';
|
|
35
|
+
const doc=document.implementation.createHTMLDocument('');
|
|
36
|
+
const parent=kind==='textDocument'||kind==='twoElements'?doc:root;
|
|
37
|
+
const old=parent.firstChild;
|
|
38
|
+
const it=document.createNodeIterator(parent);it.nextNode();it.nextNode();
|
|
39
|
+
const reference=it.referenceNode,before=it.pointerBeforeReferenceNode;
|
|
40
|
+
const children=[...parent.childNodes];
|
|
41
|
+
let incoming=kind==='ancestor'?root:kind==='document'?document:document.createElement('em');
|
|
42
|
+
if(kind==='textDocument')incoming=document.createTextNode('invalid');
|
|
43
|
+
if(kind==='twoElements'){incoming=document.createDocumentFragment();incoming.append(document.createElement('i'),document.createElement('b'));}
|
|
44
|
+
let error;
|
|
45
|
+
try{parent.replaceChild(incoming,kind==='missing'?document.createElement('b'):old)}catch(e){error=e.name}
|
|
46
|
+
results.push([kind,error,it.referenceNode===reference,it.pointerBeforeReferenceNode===before,
|
|
47
|
+
children.length===parent.childNodes.length&&children.every((n,i)=>n===parent.childNodes[i])]);
|
|
48
|
+
}
|
|
49
|
+
return results;
|
|
50
|
+
})()`)).toEqual([
|
|
51
|
+
['ancestor','HierarchyRequestError',true,true,true],['missing','NotFoundError',true,true,true],
|
|
52
|
+
['document','HierarchyRequestError',true,true,true],['textDocument','HierarchyRequestError',true,true,true],
|
|
53
|
+
['twoElements','HierarchyRequestError',true,true,true]
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('filter replacement repairs both active and stored iterator cursors', async () => {
|
|
58
|
+
expect(await evaluate(`(() => {
|
|
59
|
+
const root=document.createElement('div');root.id='root';root.innerHTML='<b id="a"></b><i id="b"></i>';
|
|
60
|
+
const it=document.createNodeIterator(root,1,n=>{
|
|
61
|
+
if(n.id==='a'){const replacement=document.createElement('em');replacement.id='x';root.replaceChild(replacement,n)}
|
|
62
|
+
return 1;
|
|
63
|
+
});
|
|
64
|
+
const got=[];for(let n,i=0;i<8&&(n=it.nextNode());i++)got.push(n.id);
|
|
65
|
+
return got;
|
|
66
|
+
})()`)).toEqual(['root','a','x','b']);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('batch removal follows DOM tree order and keeps references attached', async () => {
|
|
70
|
+
expect(await evaluate(`(() => {
|
|
71
|
+
const results=[];
|
|
72
|
+
for(const operation of ['replaceChildren','innerHTML','textContent'])for(const before of [false,true]) {
|
|
73
|
+
const root=document.createElement('div');root.innerHTML='<i>a</i><i>b</i>';
|
|
74
|
+
const it=document.createNodeIterator(root);it.nextNode();it.nextNode();it.nextNode();
|
|
75
|
+
if(before)it.previousNode();
|
|
76
|
+
if(operation==='replaceChildren')root.replaceChildren();else root[operation]='';
|
|
77
|
+
results.push([it.referenceNode===root,it.pointerBeforeReferenceNode,it.nextNode()]);
|
|
78
|
+
}
|
|
79
|
+
return results;
|
|
80
|
+
})()`)).toEqual(Array.from({length:6},()=>[true,false,null]));
|
|
81
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
test("unchanged geometry skips observer measurements without hiding real changes", async () => {
|
|
4
|
+
const child = Bun.spawn([process.execPath, new URL("./fixtures/observer-invalidation.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("observer invalidation integration passed");
|
|
12
|
+
}, 30_000);
|
|
@@ -1186,6 +1186,41 @@ test("page RegExp instances can override their own toString without changing the
|
|
|
1186
1186
|
expect(await evaluate("regexpOverride")).toEqual(["custom", "/other/"]);
|
|
1187
1187
|
});
|
|
1188
1188
|
|
|
1189
|
+
test("import methods keep their names and receivers alongside real dynamic imports", async () => {
|
|
1190
|
+
scripts.set("https://page.test/import-method.js", `
|
|
1191
|
+
export class Module {
|
|
1192
|
+
static import(value) { return this.prefix + value }
|
|
1193
|
+
static prefix = 'module:';
|
|
1194
|
+
}
|
|
1195
|
+
export const answer = 42;
|
|
1196
|
+
`);
|
|
1197
|
+
await load(`<script>
|
|
1198
|
+
class Registry extends HTMLElement {
|
|
1199
|
+
static import(value) { return this.prefix + value }
|
|
1200
|
+
static prefix = 'class:';
|
|
1201
|
+
}
|
|
1202
|
+
customElements.define('import-registry', Registry);
|
|
1203
|
+
const object = { prefix: 'object:', import(value) { return this.prefix + value } };
|
|
1204
|
+
window.importNames = [
|
|
1205
|
+
Registry.import('a'), Registry . import /* gap */ ('b'),
|
|
1206
|
+
object.import('c'), object . /* gap */ import ('d'),
|
|
1207
|
+
customElements.get('import-registry').import.name,
|
|
1208
|
+
Object.hasOwn(Registry, 'import'), Object.hasOwn(Registry, '__import__'),
|
|
1209
|
+
Registry.import.toString(),
|
|
1210
|
+
];
|
|
1211
|
+
Promise.all([
|
|
1212
|
+
import /* gap */ ('./import-method.js'),
|
|
1213
|
+
import\n('./import-method.js'),
|
|
1214
|
+
]).then(([a, b]) => { window.dynamicImportNames = [a.answer, a === b, a.Module.import('e')]; });
|
|
1215
|
+
</script>`);
|
|
1216
|
+
expect(await evaluate("importNames")).toEqual([
|
|
1217
|
+
"class:a", "class:b", "object:c", "object:d", "import", true, false,
|
|
1218
|
+
"import(value) { return this.prefix + value }",
|
|
1219
|
+
]);
|
|
1220
|
+
await waitFor("typeof dynamicImportNames !== 'undefined'");
|
|
1221
|
+
expect(await evaluate("dynamicImportNames")).toEqual([42, true, "module:e"]);
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1189
1224
|
test("ES modules: graphs, live exports, JSON, import.meta, dynamic import, TLA, cycles, import maps", async () => {
|
|
1190
1225
|
const base = "https://page.test/modules/";
|
|
1191
1226
|
scripts.set(`${base}math.js`, `
|
|
@@ -1997,14 +2032,24 @@ test("a click inside an iframe goes to the element under it in the frame's docum
|
|
|
1997
2032
|
test("dynamic linked stylesheets fire load after their rules become available", async () => {
|
|
1998
2033
|
await load(`<div id="target"></div><script>
|
|
1999
2034
|
window.sheetLoaded = false;
|
|
2035
|
+
window.sheetEvents = [];
|
|
2000
2036
|
const link = document.createElement('link');
|
|
2001
2037
|
link.rel = 'stylesheet';
|
|
2002
2038
|
link.href = '/slow.css';
|
|
2003
|
-
|
|
2039
|
+
window.styleTestLink = link;
|
|
2040
|
+
link.onerror = () => sheetEvents.push('error');
|
|
2041
|
+
link.onload = () => { sheetEvents.push('load'); window.sheetLoaded = getComputedStyle(document.body).color; };
|
|
2004
2042
|
document.head.appendChild(link);
|
|
2005
2043
|
</script>`);
|
|
2006
2044
|
for (let i = 0; i < 60 && await evaluate('sheetLoaded') === false; i++) await Bun.sleep(25);
|
|
2007
2045
|
expect(await evaluate('sheetLoaded')).toBe('rgba(255, 0, 0, 1)');
|
|
2046
|
+
expect(await evaluate('sheetEvents.join()')).toBe('load');
|
|
2047
|
+
await evaluate("styleTestLink.href='/missing.css'; window.eventsAfterHref=sheetEvents.join()");
|
|
2048
|
+
expect(await evaluate('eventsAfterHref')).toBe('load');
|
|
2049
|
+
expect(await waitFor("sheetEvents.join() === 'load,error'")).toBe(true);
|
|
2050
|
+
await evaluate("styleTestLink.href='/slow.css'; window.eventsAfterHref=sheetEvents.join()");
|
|
2051
|
+
expect(await evaluate('eventsAfterHref')).toBe('load,error');
|
|
2052
|
+
expect(await waitFor("sheetEvents.join() === 'load,error,load'")).toBe(true);
|
|
2008
2053
|
});
|
|
2009
2054
|
|
|
2010
2055
|
test("decoded dynamic images report natural dimensions and fire load", async () => {
|
|
@@ -2154,3 +2199,108 @@ test("the page scrolls over content that overflows a 100vh layout, not over clip
|
|
|
2154
2199
|
expect(await evaluate(`(scrollTo(0, 99999), [scrollY, Math.round(document.getElementById("end").getBoundingClientRect().bottom)])`))
|
|
2155
2200
|
.toEqual([2090 - 300, 300]);
|
|
2156
2201
|
});
|
|
2202
|
+
|
|
2203
|
+
test("NodeIterator includes its root, preserves direction, and skips rejected nodes without pruning", async () => {
|
|
2204
|
+
await load('<div id="root"><p id="a"><b id="child"></b></p><!--note--><p id="b"></p></div>');
|
|
2205
|
+
expect(await evaluate(`(() => {
|
|
2206
|
+
const root = document.getElementById('root');
|
|
2207
|
+
const filter = {acceptNode(n) {return n.id === 'a' ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT}};
|
|
2208
|
+
const it = document.createNodeIterator(root, NodeFilter.SHOW_ELEMENT, filter);
|
|
2209
|
+
const initial = [it instanceof NodeIterator,it.root === root,it.referenceNode === root,it.pointerBeforeReferenceNode,it.filter === filter];
|
|
2210
|
+
const forward=[];let n;while(n=it.nextNode())forward.push(n.id);
|
|
2211
|
+
const reverse=[];while(n=it.previousNode())reverse.push(n.id);
|
|
2212
|
+
it.detach();
|
|
2213
|
+
return JSON.stringify([initial,forward,reverse,it.nextNode().id]);
|
|
2214
|
+
})()`)).toBe(JSON.stringify([[true,true,true,true,true],['root','child','b'],['b','child','root'],'root']));
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2217
|
+
test("NodeIterator survives sanitizer removals, reparenting, and rejected reentrant filters", async () => {
|
|
2218
|
+
await load('<div id="root"><i id="a"><b id="child"></b></i><i id="b"></i><i id="c"></i></div>');
|
|
2219
|
+
expect(await evaluate(`(() => {
|
|
2220
|
+
const root=document.getElementById('root'),it=document.createNodeIterator(root,1);
|
|
2221
|
+
it.nextNode();it.nextNode();it.nextNode();
|
|
2222
|
+
root.firstChild.remove();
|
|
2223
|
+
const removed=[it.referenceNode.id,it.nextNode().id];
|
|
2224
|
+
it.previousNode();root.firstChild.remove();
|
|
2225
|
+
const before=[it.referenceNode.id,it.pointerBeforeReferenceNode,it.nextNode().id];
|
|
2226
|
+
document.body.appendChild(root.firstChild);
|
|
2227
|
+
const moved=[it.referenceNode.id,it.nextNode()];
|
|
2228
|
+
let error;const recur=document.createNodeIterator(root,1,()=>{try{recur.nextNode()}catch(e){error=e.name}return 1});
|
|
2229
|
+
recur.nextNode();
|
|
2230
|
+
const fresh=document.createNodeIterator(root,NodeFilter.SHOW_TEXT);
|
|
2231
|
+
root.innerHTML='hello<b>world</b>';
|
|
2232
|
+
return JSON.stringify([removed,before,moved,error,fresh.nextNode().data,fresh.nextNode().data]);
|
|
2233
|
+
})()`)).toBe(JSON.stringify([['root','b'],['c',true,'c'],['root',null],'InvalidStateError','hello','world']));
|
|
2234
|
+
});
|
|
2235
|
+
|
|
2236
|
+
// Expectations independently checked against Chromium Headless Shell.
|
|
2237
|
+
test("NodeIterator repairs an active filter cursor when the candidate or its ancestor is removed", async () => {
|
|
2238
|
+
await load("<body></body>");
|
|
2239
|
+
expect(await evaluate(`JSON.stringify((()=>{const results=[];for(const direction of ['nextNode','previousNode'])for(const remove of ['self','ancestor'])for(const verdict of [1,3]){
|
|
2240
|
+
const root=document.createElement('div');root.id='root';root.innerHTML='<i id="a"></i><section id="parent"><b id="b"></b><b id="c"></b></section><i id="d"></i>';document.body.append(root);
|
|
2241
|
+
let armed=false,once=false;const seen=[];const it=document.createNodeIterator(root,1,n=>{if(armed)seen.push(n.id);if(armed&&!once&&n.id==='b'){once=true;(remove==='self'?n:n.parentNode).remove();return verdict;}return 1});
|
|
2242
|
+
if(direction==='previousNode')while(it.nextNode());armed=true;
|
|
2243
|
+
const got=[];for(let n,k=0;k<15&&(n=it[direction]());k++)got.push(n.id);
|
|
2244
|
+
results.push({direction,remove,verdict,seen,got,reference:it.referenceNode.id,before:it.pointerBeforeReferenceNode});root.remove();
|
|
2245
|
+
}return results.map(r=>[r.direction,r.remove,r.verdict,r.seen.join(','),r.got.join(','),r.reference,r.before])})())`)).toBe(JSON.stringify([
|
|
2246
|
+
["nextNode", "self", 1, "root,a,parent,b,c,d", "root,a,parent,b,c,d", "d", false],
|
|
2247
|
+
["nextNode", "self", 3, "root,a,parent,b,c,d", "root,a,parent,c,d", "d", false],
|
|
2248
|
+
["nextNode", "ancestor", 1, "root,a,parent,b,d", "root,a,parent,b,d", "d", false],
|
|
2249
|
+
["nextNode", "ancestor", 3, "root,a,parent,b,d", "root,a,parent,d", "d", false],
|
|
2250
|
+
["previousNode", "self", 1, "d,c,b,parent,a,root", "d,c,b,parent,a,root", "root", true],
|
|
2251
|
+
["previousNode", "self", 3, "d,c,b,parent,a,root", "d,c,parent,a,root", "root", true],
|
|
2252
|
+
["previousNode", "ancestor", 1, "d,c,b,a,root", "d,c,b,a,root", "root", true],
|
|
2253
|
+
["previousNode", "ancestor", 3, "d,c,b,a,root", "d,c,a,root", "root", true]
|
|
2254
|
+
]));
|
|
2255
|
+
});
|
|
2256
|
+
|
|
2257
|
+
test("NodeIterator releases its active cursor after a filter throws", async () => {
|
|
2258
|
+
await load('<div id="root"><b id="child"></b></div>');
|
|
2259
|
+
expect(await evaluate(`(() => {
|
|
2260
|
+
const root=document.getElementById('root');let first=true;
|
|
2261
|
+
const it=document.createNodeIterator(root,1,()=>{if(first){first=false;throw Error('filter failed')}return 1});
|
|
2262
|
+
let message;try{it.nextNode()}catch(e){message=e.message}
|
|
2263
|
+
return [message,it.nextNode().id,it.nextNode().id].join(',');
|
|
2264
|
+
})()`)).toBe('filter failed,root,child');
|
|
2265
|
+
});
|
|
2266
|
+
|
|
2267
|
+
test("NodeIterator repairs removals through proxy-backed select and form parents", async () => {
|
|
2268
|
+
await load('<body></body>');
|
|
2269
|
+
expect(await evaluate(`(() => {
|
|
2270
|
+
const results=[];
|
|
2271
|
+
for(const tag of ['div','select','form']) for(const outerRoot of [false,true])
|
|
2272
|
+
for(const operation of ['remove','removeChild','innerHTML','reparent']) {
|
|
2273
|
+
const outer=document.createElement('div'),parent=document.createElement(tag);
|
|
2274
|
+
outer.append(parent);document.body.append(outer);
|
|
2275
|
+
parent.innerHTML='<option id="a"></option><option id="b"></option>';
|
|
2276
|
+
const it=document.createNodeIterator(outerRoot?outer:parent,1);
|
|
2277
|
+
while(it.nextNode()?.id!=='a') {}
|
|
2278
|
+
const child=parent.firstChild;
|
|
2279
|
+
if(operation==='remove')child.remove();
|
|
2280
|
+
else if(operation==='removeChild')parent.removeChild(child);
|
|
2281
|
+
else if(operation==='innerHTML')parent.innerHTML='';
|
|
2282
|
+
else outer.append(child);
|
|
2283
|
+
results.push([it.referenceNode===parent,it.nextNode()?.id??null]);
|
|
2284
|
+
outer.remove();
|
|
2285
|
+
}
|
|
2286
|
+
return JSON.stringify(results);
|
|
2287
|
+
})()`)).toBe(JSON.stringify(Array.from({length:6},()=>[
|
|
2288
|
+
[true,'b'],[true,'b'],[true,null],[true,'b']
|
|
2289
|
+
]).flat()));
|
|
2290
|
+
});
|
|
2291
|
+
|
|
2292
|
+
test("NodeIterator validates nullable callback-interface filters at creation", async () => {
|
|
2293
|
+
await load('<body></body>');
|
|
2294
|
+
expect(await evaluate(`(() => {
|
|
2295
|
+
const results=[];
|
|
2296
|
+
for(const filter of [0,1,false,true,'','x',Symbol('filter'),1n]) {
|
|
2297
|
+
try { document.createNodeIterator(document.body,0,filter);results.push('accepted'); }
|
|
2298
|
+
catch(error) { results.push(error.name); }
|
|
2299
|
+
}
|
|
2300
|
+
for(const filter of [null,undefined,()=>1,{acceptNode(){return 1}},{}]) {
|
|
2301
|
+
const it=document.createNodeIterator(document.body,0,filter);
|
|
2302
|
+
results.push(it.filter===(filter??null) && it.nextNode()===null);
|
|
2303
|
+
}
|
|
2304
|
+
return JSON.stringify(results);
|
|
2305
|
+
})()`)).toBe(JSON.stringify([...Array(8).fill('TypeError'),...Array(5).fill(true)]));
|
|
2306
|
+
});
|
|
@@ -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
|
+
});
|
package/test/paint-frame.test.js
CHANGED
|
@@ -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
|
+
});
|
package/test/pcm.test.js
ADDED
|
@@ -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
|
+
});
|