buninu-browser 0.2.3 → 0.3.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 +86 -1
- package/lib/cdp-server.js +47 -14
- package/lib/clipboard.js +22 -0
- package/lib/happy-dom/document-base.js +33 -0
- package/lib/happy-dom/parser.js +15 -6
- package/lib/happy-dom/selector-matcher.js +2 -2
- package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
- package/lib/happy-dom/svg-attribute-adapter.js +50 -0
- package/lib/headless-shell.js +40 -11
- package/lib/input/hit-test.js +67 -19
- package/lib/layout/text-layout.js +213 -91
- 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 +17 -4
- package/lib/media/webm-stream.js +106 -0
- package/lib/network/network-service.js +268 -120
- package/lib/paint/display-list.js +8 -28
- package/lib/paint/frame-cache.js +25 -0
- package/lib/paint/frame.js +77 -16
- package/lib/paint/paragraph-text.js +24 -0
- package/lib/paint/raster-readback.js +41 -0
- package/lib/paint/raw-frames.js +47 -0
- package/lib/paint/rounded-rect.js +82 -0
- package/lib/paint/shared-frame-pool.js +105 -0
- package/lib/render-tree/index.js +38 -6
- package/lib/render-tree/svg.js +15 -7
- package/lib/renderer/bindings.js +233 -80
- 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-channel.js +103 -0
- package/lib/renderer/compositor.js +34 -40
- package/lib/renderer/custom-element-reactions.js +25 -1
- package/lib/renderer/flags.js +1 -1
- package/lib/renderer/font-loading.js +227 -0
- package/lib/renderer/host.js +133 -98
- package/lib/renderer/image-resources.js +65 -0
- package/lib/renderer/interfaces.js +28 -0
- package/lib/renderer/layout-observers.js +236 -0
- package/lib/renderer/main.js +52 -5
- package/lib/renderer/media-source.js +126 -55
- package/lib/renderer/page-realm.js +287 -99
- package/lib/renderer/page-renderer.js +663 -239
- 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 +23 -4
- package/lib/renderer/workers.js +2 -2
- package/lib/style/computed-style.js +611 -104
- 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/clipboard.test.js +35 -0
- package/test/computed-style-binding.test.js +151 -0
- package/test/computed-style.test.js +258 -1
- package/test/display-list.test.js +12 -0
- package/test/document-base.test.js +87 -0
- package/test/dom-platform.test.js +461 -0
- package/test/fetch-lifecycle.test.js +161 -0
- package/test/fixtures/browser-raw-demo.html +36 -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/raw-capture-benchmark.json +76 -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/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-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 +1 -1
- package/test/paint-frame.test.js +103 -0
- package/test/paint-paragraph-cache.test.js +87 -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 +10 -3
- package/test/raster-readback.test.js +40 -0
- package/test/raw-capture.test.js +152 -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 +106 -0
- package/test/video-keyframe.test.js +61 -0
- package/test/video-presentation.test.js +69 -0
- package/test/window-resize.test.js +36 -0
- package/tools/bench-raw-capture.js +32 -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
- package/ai-debug.log +0 -5537
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { DocumentFonts, neededFontFaces } from "../lib/renderer/font-loading.js";
|
|
3
|
+
import { createPageRenderer, loadGraphics } from "../lib/renderer/page-renderer.js";
|
|
4
|
+
|
|
5
|
+
const face = (family, weight = 400, ranges = [[0, 0x10ffff]], extra = {}) => ({ family, weight: [weight, weight],
|
|
6
|
+
style: "normal", unicodeRanges: ranges, sources: [{ url: `https://font.test/${family}-${weight}.ttf` }], ...extra });
|
|
7
|
+
const run = (text, fontFamily, fontWeight = 400, fontStyle = "normal") => ({ text, style: { fontFamily, fontWeight, fontStyle } });
|
|
8
|
+
|
|
9
|
+
test("font selection requests only used families, styles, weights, and Unicode subsets", () => {
|
|
10
|
+
const latin = face("Example", 400, [[0, 127]]);
|
|
11
|
+
const cjk = face("Example", 400, [[0x4e00, 0x9fff]]);
|
|
12
|
+
const bold = face("Example", 700);
|
|
13
|
+
const italic = face("Example", 400, [[0, 127]], { style: "italic" });
|
|
14
|
+
const unused = face("Unused");
|
|
15
|
+
const all = [latin, cjk, bold, italic, unused];
|
|
16
|
+
expect([...neededFontFaces(all, [run("hello", ["Example", "Unused"])])]).toEqual([latin]);
|
|
17
|
+
expect([...neededFontFaces(all, [run("中文", ["Example"])])]).toEqual([cjk]);
|
|
18
|
+
expect([...neededFontFaces(all, [run("hello", ["Example"], 600)])]).toEqual([bold]);
|
|
19
|
+
expect([...neededFontFaces(all, [run("hello", ["Example"], 400, "italic")])]).toEqual([italic]);
|
|
20
|
+
expect([...neededFontFaces(all, [run("hello", ["system-ui", "Unused"])], new Set(["system-ui"]))]).toEqual([]);
|
|
21
|
+
const earlier = face("Later");
|
|
22
|
+
const later = face("Later");
|
|
23
|
+
expect([...neededFontFaces([earlier, later], [run("hello", ["Later"])])][0]).toBe(later);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("document web fonts share builtin typefaces, use CORS, deduplicate sources, and honor Unicode aliases", async () => {
|
|
27
|
+
const { CanvasKit, builtinTypefaces, fontCollection } = await loadGraphics();
|
|
28
|
+
const bytes = await Bun.file(new URL("../usr/share/fonts/DejaVuSansMono.ttf", import.meta.url)).bytes();
|
|
29
|
+
const requests = [], errors = [];
|
|
30
|
+
let changed = 0;
|
|
31
|
+
const fonts = new DocumentFonts({ CanvasKit, builtins: builtinTypefaces, defaultCollection: fontCollection,
|
|
32
|
+
fetch: async request => { requests.push(request); return { status: 200, body: bytes }; },
|
|
33
|
+
changed: () => changed++, error: error => errors.push(error) });
|
|
34
|
+
const latin = face("Example", 400, [[0, 127]]);
|
|
35
|
+
const cjk = face("Example", 400, [[0x4e00, 0x9fff]]);
|
|
36
|
+
fonts.update([latin, cjk], [run("Hello中文", ["Example"])]);
|
|
37
|
+
await fonts.ready();
|
|
38
|
+
expect(requests).toHaveLength(1);
|
|
39
|
+
expect(requests[0]).toMatchObject({ mode: "cors", credentials: "same-origin", redirect: "follow" });
|
|
40
|
+
expect(changed).toBe(2);
|
|
41
|
+
expect(errors).toEqual([]);
|
|
42
|
+
expect(fonts.collection).not.toBe(fontCollection);
|
|
43
|
+
expect(fonts.resolve("Example", 400, "normal", 65)).toHaveLength(1);
|
|
44
|
+
expect(fonts.resolve("Example", 400, "normal", 0x4e00)).toHaveLength(1);
|
|
45
|
+
expect(fonts.resolve("Example", 400, "normal", 0x400)).toEqual([]);
|
|
46
|
+
fonts.dispose();
|
|
47
|
+
fonts.dispose();
|
|
48
|
+
expect(fonts.collection).toBe(fontCollection);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("font sources try local/fetch alternatives and pending requests abort on document disposal", async () => {
|
|
52
|
+
const { CanvasKit, builtinTypefaces, fontCollection } = await loadGraphics();
|
|
53
|
+
let fetched = 0, aborted = 0;
|
|
54
|
+
const fonts = new DocumentFonts({ CanvasKit, builtins: builtinTypefaces, defaultCollection: fontCollection,
|
|
55
|
+
fetch: () => {
|
|
56
|
+
fetched++;
|
|
57
|
+
let reject;
|
|
58
|
+
const pending = new Promise((_, fail) => reject = fail);
|
|
59
|
+
pending.abort = () => { aborted++; reject(new Error("disposed")); };
|
|
60
|
+
return pending;
|
|
61
|
+
} });
|
|
62
|
+
const local = face("Local", 400, undefined, { sources: [{ local: "missing" }, { local: builtinTypefaces[0].family }] });
|
|
63
|
+
fonts.update([local], [run("hello", ["Local"])]);
|
|
64
|
+
await fonts.ready();
|
|
65
|
+
expect(fonts.resolve("Local")).toHaveLength(1);
|
|
66
|
+
expect(fetched).toBe(0);
|
|
67
|
+
const remote = face("Remote");
|
|
68
|
+
fonts.update([remote], [run("hello", ["Remote"])]);
|
|
69
|
+
expect(fetched).toBe(1);
|
|
70
|
+
fonts.dispose();
|
|
71
|
+
await fonts.ready();
|
|
72
|
+
expect(aborted).toBe(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("loaded web font updates geometry, respects Unicode ranges, and resets on navigation", async () => {
|
|
76
|
+
const bytes = await Bun.file(new URL("../usr/share/fonts/DejaVuSansMono.ttf", import.meta.url)).bytes();
|
|
77
|
+
let finish;
|
|
78
|
+
const fontResponse = new Promise(resolve => finish = resolve);
|
|
79
|
+
let requests = 0;
|
|
80
|
+
const renderer = await createPageRenderer({ fetchResource: async url => ({ ok: false, status: 404, url, body: new Uint8Array() }),
|
|
81
|
+
pageFetch: () => { requests++; return fontResponse; } });
|
|
82
|
+
try {
|
|
83
|
+
const source = `<style>@font-face {font-family:TestMono;src:url(/font.ttf);unicode-range:U+0069} span{font-family:TestMono,sans-serif;font-size:30px}</style><span id=i>iiii</span><span id=m>mmmm</span>`;
|
|
84
|
+
await renderer.loadDocument({ url: "https://font.test/", status: 200, contentType: "text/html", source });
|
|
85
|
+
const before = renderer.inspect("span").map(item => item.box.width);
|
|
86
|
+
finish({ status: 200, body: bytes });
|
|
87
|
+
await renderer.whenLoaded();
|
|
88
|
+
const after = renderer.inspect("span").map(item => item.box.width);
|
|
89
|
+
expect(requests).toBe(1);
|
|
90
|
+
expect(after[0]).toBeGreaterThan(before[0] * 2);
|
|
91
|
+
expect(after[1]).toBe(before[1]);
|
|
92
|
+
await renderer.loadDocument({ url: "https://font.test/next", status: 200, contentType: "text/html",
|
|
93
|
+
source: `<span style="font-family:TestMono,sans-serif;font-size:30px">iiii</span>` });
|
|
94
|
+
await renderer.whenLoaded();
|
|
95
|
+
expect(renderer.inspect("span")[0].box.width).toBe(before[0]);
|
|
96
|
+
} finally { renderer.dispose(); }
|
|
97
|
+
});
|
package/test/grid.test.js
CHANGED
|
@@ -220,6 +220,64 @@ test("implicit columns are created past the explicit grid", () => {
|
|
|
220
220
|
expect(item(0)).toEqual({ left: 4, top: 0, width: 6, height: 1 });
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
test("column auto-flow fills each explicit row before adding a column", () => {
|
|
224
|
+
const { items } = render(grid("grid-template-rows: 2px 3px; grid-auto-columns: 4px; grid-auto-flow: column", letters(5)));
|
|
225
|
+
expect(items().map((box) => [box.left, box.top, box.width, box.height])).toEqual([
|
|
226
|
+
[0, 0, 4, 2], [0, 2, 4, 3], [4, 0, 4, 2], [4, 2, 4, 3], [8, 0, 4, 2],
|
|
227
|
+
]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("column auto-flow honors row spans and dense packing", () => {
|
|
231
|
+
const content = `<i style="grid-row: 2 / 4">a</i><i style="grid-row: span 2">b</i><i>c</i>`;
|
|
232
|
+
const sparse = render(grid("grid-template-rows: repeat(3, 2px); grid-auto-columns: 4px; grid-auto-flow: column", content));
|
|
233
|
+
const dense = render(grid("grid-template-rows: repeat(3, 2px); grid-auto-columns: 4px; grid-auto-flow: column dense", content));
|
|
234
|
+
expect(sparse.items().map((box) => [box.left, box.top])).toEqual([[0, 2], [4, 0], [4, 4]]);
|
|
235
|
+
expect(dense.items().map((box) => [box.left, box.top])).toEqual([[0, 2], [4, 0], [0, 0]]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("named areas place source-order-independent content and spanning cells", () => {
|
|
239
|
+
const { items } = render(grid('grid-template-columns: 4px 20px 6px; grid-template-rows: 2px 3px; grid-template-areas: "icon title meta" "icon body meta"',
|
|
240
|
+
'<i style="grid-area: title">title</i><i style="grid-area: meta">m</i><i style="grid-area: icon">i</i><i style="grid-area: body">body</i>'));
|
|
241
|
+
expect(items()).toEqual([
|
|
242
|
+
{ left: 4, top: 0, width: 20, height: 2 }, { left: 24, top: 0, width: 6, height: 5 },
|
|
243
|
+
{ left: 0, top: 0, width: 4, height: 5 }, { left: 4, top: 2, width: 20, height: 3 },
|
|
244
|
+
]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("named areas create explicit tracks and implicit start/end line names", () => {
|
|
248
|
+
const { items } = render(grid('grid-template-areas: "a a ." "b b ."; grid-auto-columns: 4px; grid-auto-rows: 2px',
|
|
249
|
+
'<i style="grid-column: a; grid-row: 1">a</i><i style="grid-column: b-start / b-end; grid-row: b">b</i><i style="grid-column: -2 / -1; grid-row: 1">c</i>'));
|
|
250
|
+
expect(items()).toEqual([
|
|
251
|
+
{ left: 0, top: 0, width: 8, height: 2 }, { left: 0, top: 2, width: 8, height: 2 },
|
|
252
|
+
{ left: 8, top: 0, width: 4, height: 2 },
|
|
253
|
+
]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("grid-template shorthand combines area strings, row sizes and columns", () => {
|
|
257
|
+
const { items } = render(grid('grid-template:"icon title"2px"icon body"3px/4px 20px',
|
|
258
|
+
'<i style="grid-area: title">title</i><i style="grid-area: icon">i</i><i style="grid-area: body">body</i>'));
|
|
259
|
+
expect(items()).toEqual([
|
|
260
|
+
{ left: 4, top: 0, width: 20, height: 2 }, { left: 0, top: 0, width: 4, height: 5 },
|
|
261
|
+
{ left: 4, top: 2, width: 20, height: 3 },
|
|
262
|
+
]);
|
|
263
|
+
const defaults = render(grid('grid-template: "a b" / 4px 6px', '<i style="grid-area:b">b</i>'));
|
|
264
|
+
expect(defaults.item(0)).toEqual({ left: 4, top: 0, width: 6, height: 1 });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("grid-template track shorthand resets old area placement", () => {
|
|
268
|
+
const { items } = render(grid('grid-template-areas:"b a";grid-template:2px / 4px 6px', letters(2)));
|
|
269
|
+
expect(items()).toEqual([
|
|
270
|
+
{ left: 0, top: 0, width: 4, height: 2 }, { left: 4, top: 0, width: 6, height: 2 },
|
|
271
|
+
]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("nonrectangular or unequal-width area declarations are discarded", () => {
|
|
275
|
+
for (const invalid of ['"a a" "a b"', '"a a" "b"', '"a ?"']) {
|
|
276
|
+
const { items } = render(grid(`grid-template-columns: 4px 6px; grid-template-areas: ${invalid}`, letters(2)));
|
|
277
|
+
expect(items().map((box) => box.width)).toEqual([4, 6]);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
223
281
|
// Gaps (css-align-3)
|
|
224
282
|
|
|
225
283
|
test("column-gap separates the columns and comes off the free space", () => {
|
package/test/hit-test.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
2
|
import { parseHTMLDocument } from "../lib/happy-dom/parser.js";
|
|
3
|
-
import { elementAtPoint, elementBounds, hitTest, interactiveRegions } from "../lib/input/hit-test.js";
|
|
3
|
+
import { elementAtPoint, elementBounds, elementBox, hitTest, interactiveRegions } from "../lib/input/hit-test.js";
|
|
4
4
|
import { buildDisplayList } from "../lib/paint/display-list.js";
|
|
5
5
|
import { layoutText } from "../lib/layout/text-layout.js";
|
|
6
6
|
import { RenderTreeBuilder } from "../lib/render-tree/index.js";
|
|
@@ -35,6 +35,26 @@ test("finds content bounds for fragment navigation targets", () => {
|
|
|
35
35
|
window.happyDOM.abort();
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
test("geometry lookups combine inline descendants and follow replacement layouts", () => {
|
|
39
|
+
const { document, window } = parseHTMLDocument('<div id="box"><span id="outer">one <b>two</b></span><span id="other">tail</span></div><div id="hidden" style="display:none">hidden</div>');
|
|
40
|
+
const styles = new StyleEngine().compute(document, ['body{margin:0} #box{width:70px}']);
|
|
41
|
+
const tree = new RenderTreeBuilder().build(document, styles);
|
|
42
|
+
const options = { width:200, x:0, y:0, measureText:text=>text.length*10, lineHeight:20, fontMetrics:()=>({ascent:16,height:20}) };
|
|
43
|
+
const layout = layoutText(tree, options);
|
|
44
|
+
const box = document.getElementById('box'), outer = document.getElementById('outer');
|
|
45
|
+
expect(elementBox(tree, layout, box).box.width).toBe(70);
|
|
46
|
+
expect(elementBox(tree, layout, outer)).toBe(null);
|
|
47
|
+
const bounds = elementBounds(tree, layout, outer);
|
|
48
|
+
expect(bounds.width).toBeGreaterThan(0);
|
|
49
|
+
expect(elementBounds(tree, layout, box).bottom).toBeGreaterThanOrEqual(bounds.bottom);
|
|
50
|
+
expect(elementBounds(tree, layout, document.getElementById('hidden'))).toBe(null);
|
|
51
|
+
const shifted = layoutText(tree, { ...options, x:40, y:100 });
|
|
52
|
+
expect(elementBox(tree, shifted, box).box.y).toBe(elementBox(tree, layout, box).box.y + 100);
|
|
53
|
+
expect(elementBounds(tree, shifted, outer).left).toBe(bounds.left + 40);
|
|
54
|
+
expect(elementBounds(tree, shifted, outer).top).toBe(bounds.top + 100);
|
|
55
|
+
window.happyDOM.abort();
|
|
56
|
+
});
|
|
57
|
+
|
|
38
58
|
test("hit testing accounts for viewport scrolling", () => {
|
|
39
59
|
const { document, window } = parseHTMLDocument(
|
|
40
60
|
`<body><pre>line 0\nline 1\nline 2\n<a href="target">target</a></pre></body>`,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
test("dynamic image bursts batch paint and release removed, replaced, and late resources", async () => {
|
|
4
|
+
const child = Bun.spawn([process.execPath, new URL("./fixtures/image-lifetime.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("image lifetime integration passed");
|
|
12
|
+
}, 30_000);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { beforeAll, expect, test } from "bun:test";
|
|
2
|
+
import CanvasKitInit from "../usr/lib/canvaskit/canvaskit.js";
|
|
3
|
+
import { createImageResourceLoader } from "../lib/renderer/image-resources.js";
|
|
4
|
+
|
|
5
|
+
const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAAA2iEnWAAAAEElEQVR4nGP4z8AARAwoFABE0AX7pM/egAAAAABJRU5ErkJggg==", "base64");
|
|
6
|
+
let CanvasKit;
|
|
7
|
+
beforeAll(async () => {
|
|
8
|
+
CanvasKit = await CanvasKitInit({ wasmBinary: await Bun.file(new URL("../usr/lib/canvaskit/canvaskit.wasm", import.meta.url)).arrayBuffer() });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("concurrent image consumers share one fetch and encoded image while owning independent handles", async () => {
|
|
12
|
+
let fetched = 0;
|
|
13
|
+
let decoded = 0;
|
|
14
|
+
const makeImage = CanvasKit.MakeImageFromEncoded;
|
|
15
|
+
CanvasKit.MakeImageFromEncoded = (bytes) => { decoded++; return makeImage(bytes); };
|
|
16
|
+
const load = createImageResourceLoader(CanvasKit, async url => {
|
|
17
|
+
fetched++;
|
|
18
|
+
return { ok: true, url, body: PNG };
|
|
19
|
+
});
|
|
20
|
+
const resources = [];
|
|
21
|
+
try {
|
|
22
|
+
const document = {};
|
|
23
|
+
resources.push(...await Promise.all(Array.from({ length: 20 }, () => load(document, "https://image.test/icon.png"))));
|
|
24
|
+
expect(fetched).toBe(1);
|
|
25
|
+
expect(decoded).toBe(1);
|
|
26
|
+
expect(resources[0]).not.toBe(resources[1]);
|
|
27
|
+
expect(resources[0].image).not.toBe(resources[1].image);
|
|
28
|
+
expect(resources[0].image.isAliasOf(resources[1].image)).toBe(true);
|
|
29
|
+
expect([resources[0].width, resources[0].height]).toEqual([2, 3]);
|
|
30
|
+
resources.shift().image.delete();
|
|
31
|
+
expect(resources[0].image.width()).toBe(2);
|
|
32
|
+
// Completed requests are not a permanent cache of possibly stale bytes.
|
|
33
|
+
resources.push(await load(document, "https://image.test/icon.png"));
|
|
34
|
+
expect(fetched).toBe(2);
|
|
35
|
+
expect(decoded).toBe(2);
|
|
36
|
+
} finally {
|
|
37
|
+
for (const resource of resources) resource.image.delete();
|
|
38
|
+
CanvasKit.MakeImageFromEncoded = makeImage;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("image failures retry and requests from different documents do not coalesce", async () => {
|
|
43
|
+
let fetched = 0;
|
|
44
|
+
const load = createImageResourceLoader(CanvasKit, async url => {
|
|
45
|
+
fetched++;
|
|
46
|
+
return { ok: fetched > 1, status: fetched > 1 ? 200 : 404, url, body: PNG };
|
|
47
|
+
});
|
|
48
|
+
const document = {};
|
|
49
|
+
const failures = await Promise.allSettled([load(document, "https://image.test/a.png"), load(document, "https://image.test/a.png")]);
|
|
50
|
+
expect(fetched).toBe(1);
|
|
51
|
+
expect(failures.every(result => result.status === "rejected")).toBe(true);
|
|
52
|
+
const resources = await Promise.all([load(document, "https://image.test/a.png"), load({}, "https://image.test/a.png")]);
|
|
53
|
+
try {
|
|
54
|
+
expect(fetched).toBe(3);
|
|
55
|
+
expect(resources[0].image.isAliasOf(resources[1].image)).toBe(false);
|
|
56
|
+
} finally {
|
|
57
|
+
for (const resource of resources) resource.image.delete();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("CORS image policies remain separate from unfiltered images and pending CORS loads cancel with their document", async () => {
|
|
62
|
+
let unfiltered = 0, aborted = 0;
|
|
63
|
+
const requests = [];
|
|
64
|
+
const load = createImageResourceLoader(CanvasKit, async url => {
|
|
65
|
+
unfiltered++;
|
|
66
|
+
return { ok: true, url: `${url}?redirected`, body: PNG };
|
|
67
|
+
}, request => {
|
|
68
|
+
requests.push(request);
|
|
69
|
+
if (request.url.endsWith("pending")) {
|
|
70
|
+
const deferred = Promise.withResolvers();
|
|
71
|
+
deferred.promise.abort = () => { aborted++; deferred.reject(new Error("aborted")); };
|
|
72
|
+
return deferred.promise;
|
|
73
|
+
}
|
|
74
|
+
return Promise.resolve({ status: 200, url: request.url, body: new Blob([PNG]).stream() });
|
|
75
|
+
});
|
|
76
|
+
const document = {};
|
|
77
|
+
const url = "https://foreign.test/icon.png";
|
|
78
|
+
const resources = await Promise.all([load(document, url), load(document, url, "same-origin"),
|
|
79
|
+
load(document, url, "same-origin"), load(document, url, "include")]);
|
|
80
|
+
try {
|
|
81
|
+
expect(unfiltered).toBe(1);
|
|
82
|
+
expect(requests.map(request => [request.mode, request.credentials])).toEqual([["cors", "same-origin"], ["cors", "include"]]);
|
|
83
|
+
expect(resources.map(resource => resource.originClean)).toEqual([undefined, true, true, true]);
|
|
84
|
+
expect(resources[0].requestedURL).toBe(url);
|
|
85
|
+
expect(resources[0].url).toBe(`${url}?redirected`);
|
|
86
|
+
expect(resources[1].image.isAliasOf(resources[2].image)).toBe(true);
|
|
87
|
+
const pending = load(document, "https://foreign.test/pending", "same-origin");
|
|
88
|
+
load.cancelDocument(document);
|
|
89
|
+
await expect(pending).rejects.toThrow("aborted");
|
|
90
|
+
expect(aborted).toBe(1);
|
|
91
|
+
} finally {
|
|
92
|
+
for (const resource of resources) resource.image.delete();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { connect } from "node:net";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createBrowser } from "../lib/headless-shell.js";
|
|
7
|
+
|
|
8
|
+
const pulseAvailable = await new Promise((resolve) => {
|
|
9
|
+
const socket = connect({ host: "127.0.0.1", port: 4713 });
|
|
10
|
+
const finish = (available) => { clearTimeout(timer); socket.destroy(); resolve(available); };
|
|
11
|
+
const timer = setTimeout(() => finish(false), 1000);
|
|
12
|
+
socket.once("connect", () => finish(true));
|
|
13
|
+
socket.once("error", () => finish(false));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
let directory, browser, server, videoURL, audioURL;
|
|
17
|
+
beforeAll(async () => {
|
|
18
|
+
directory = await mkdtemp(join(tmpdir(), "buninu-local-media-"));
|
|
19
|
+
videoURL = Bun.pathToFileURL(join(directory, "影片 空白.mp4")).href;
|
|
20
|
+
audioURL = Bun.pathToFileURL(join(directory, "音訊 空白.mp3")).href;
|
|
21
|
+
await Bun.write(Bun.fileURLToPath(videoURL), Bun.file(new URL("./fixtures/long-video.mp4", import.meta.url)));
|
|
22
|
+
await Bun.write(Bun.fileURLToPath(audioURL), Bun.file(new URL("./fixtures/tone.mp3", import.meta.url)));
|
|
23
|
+
server = Bun.serve({ port: 0, fetch: () => new Response(
|
|
24
|
+
`<!doctype html><video src="${videoURL}"></video>`, { headers: { "content-type": "text/html" } },
|
|
25
|
+
) });
|
|
26
|
+
browser = await createBrowser({ spareRenderer: false });
|
|
27
|
+
});
|
|
28
|
+
afterAll(async () => {
|
|
29
|
+
browser?.close();
|
|
30
|
+
server?.stop(true);
|
|
31
|
+
if (directory) await rm(directory, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const evaluate = (source) => browser.context.evaluate(source);
|
|
35
|
+
async function until(expression) {
|
|
36
|
+
const deadline = Date.now() + 5000;
|
|
37
|
+
while (Date.now() < deadline) {
|
|
38
|
+
if (await evaluate(expression)) return;
|
|
39
|
+
await Bun.sleep(25);
|
|
40
|
+
}
|
|
41
|
+
expect(await evaluate(expression)).toBe(true);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test("a standalone file video with spaces and Unicode in its URL decodes and plays", async () => {
|
|
45
|
+
await browser.context.navigate(videoURL);
|
|
46
|
+
expect(await evaluate("document.querySelector('video').src")).toBe(videoURL);
|
|
47
|
+
await evaluate("document.querySelector('video').play()");
|
|
48
|
+
await until("(() => { const v = document.querySelector('video'); return v.readyState >= 2 && v.currentTime > 0.1; })()");
|
|
49
|
+
expect(await evaluate("document.querySelector('video').error")).toBe(null);
|
|
50
|
+
expect(await evaluate("document.querySelector('video').duration")).toBeGreaterThan(1);
|
|
51
|
+
await evaluate("document.querySelector('video').pause()");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a standalone file audio document decodes and seeks without requiring an output server", async () => {
|
|
55
|
+
await browser.context.navigate(audioURL);
|
|
56
|
+
expect(await evaluate("document.querySelector('audio').src")).toBe(audioURL);
|
|
57
|
+
await evaluate("document.querySelector('audio').currentTime = 0.1");
|
|
58
|
+
await until("document.querySelector('audio').readyState >= 2");
|
|
59
|
+
expect(await evaluate("document.querySelector('audio').error")).toBe(null);
|
|
60
|
+
expect(await evaluate("document.querySelector('audio').duration")).toBeGreaterThan(0.5);
|
|
61
|
+
expect(await evaluate("document.querySelector('audio').currentTime")).toBeCloseTo(0.1);
|
|
62
|
+
// Local rendering permission does not grant JavaScript file fetch access.
|
|
63
|
+
expect(await evaluate(`fetch(${JSON.stringify(audioURL)}).then(() => 'read', () => 'blocked')`)).toBe("blocked");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test.skipIf(!pulseAvailable)("a standalone file audio document advances playback with an output server", async () => {
|
|
67
|
+
await browser.context.navigate(audioURL);
|
|
68
|
+
await evaluate("document.querySelector('audio').play()");
|
|
69
|
+
await until("(() => { const a = document.querySelector('audio'); return a.readyState >= 2 && a.currentTime > 0.1; })()");
|
|
70
|
+
expect(await evaluate("document.querySelector('audio').error")).toBe(null);
|
|
71
|
+
await evaluate("document.querySelector('audio').pause()");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("HTTP and data documents cannot load file media or fetch local bytes after local navigation", async () => {
|
|
75
|
+
const html = `<!doctype html><video src="${videoURL}"></video>`;
|
|
76
|
+
for (const url of [`http://127.0.0.1:${server.port}/`, `data:text/html,${encodeURIComponent(html)}`]) {
|
|
77
|
+
await browser.context.navigate(url);
|
|
78
|
+
await evaluate("document.querySelector('video').play()");
|
|
79
|
+
await until("document.querySelector('video').error !== null");
|
|
80
|
+
expect(await evaluate("document.querySelector('video').error.message")).toBe("blocked video request for file:");
|
|
81
|
+
expect(await evaluate("document.querySelector('video').readyState")).toBe(0);
|
|
82
|
+
expect(await evaluate(`fetch(${JSON.stringify(audioURL)}).then(() => 'read', () => 'blocked')`)).toBe("blocked");
|
|
83
|
+
}
|
|
84
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
test('seeking uses media bytes appended after the current decoder was opened', async () => {
|
|
4
|
+
const child = Bun.spawn([process.execPath, new URL('./fixtures/media-seek-snapshot.js', import.meta.url).pathname],
|
|
5
|
+
{ stdout: 'pipe', stderr: 'pipe' });
|
|
6
|
+
const [code, stdout, stderr] = await Promise.all([
|
|
7
|
+
child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
|
|
8
|
+
]);
|
|
9
|
+
expect({ code, stderr }).toEqual({ code: 0, stderr: '' });
|
|
10
|
+
expect(stdout).toContain('latest video snapshot seek passed');
|
|
11
|
+
}, 30_000);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { beforeAll, expect, test } from 'bun:test';
|
|
2
|
+
import wasmpeg from '../vendor/wasmpeg/src/js/index.js';
|
|
3
|
+
import { createMediaSourceScope } from '../lib/renderer/media-source.js';
|
|
4
|
+
|
|
5
|
+
// These synthetic fMP4 fixtures were generated from ffmpeg's testsrc2 filter:
|
|
6
|
+
// 6 seconds, 10 fps, x264/yuv420p, 2-second GOPs, fragmented empty-moov MP4;
|
|
7
|
+
// dimensions are 64x48 and 128x96. They contain no third-party media.
|
|
8
|
+
let small, large;
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
await wasmpeg.load();
|
|
11
|
+
[small, large] = await Promise.all(['small', 'large'].map(async name =>
|
|
12
|
+
new Uint8Array(await Bun.file(new URL(`./fixtures/mse-switch-${name}.mp4`, import.meta.url)).arrayBuffer())));
|
|
13
|
+
});
|
|
14
|
+
function visit(bytes, callback, from = 0, to = bytes.length) {
|
|
15
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.length);
|
|
16
|
+
for (let offset = from; offset + 8 <= to;) {
|
|
17
|
+
const size = view.getUint32(offset), type = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8));
|
|
18
|
+
if (size < 8 || offset + size > to) break;
|
|
19
|
+
callback(type, offset + 8, offset + size, offset);
|
|
20
|
+
if (['moov', 'trak', 'mdia', 'moof', 'traf'].includes(type)) visit(bytes, callback, offset + 8, offset + size);
|
|
21
|
+
offset += size;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function shifted(original, seconds) {
|
|
25
|
+
const bytes = original.slice(), view = new DataView(bytes.buffer);
|
|
26
|
+
let timescale;
|
|
27
|
+
visit(bytes, (type, data) => { if (type === 'mdhd') timescale = view.getUint32(data + (bytes[data] ? 20 : 12)); });
|
|
28
|
+
visit(bytes, (type, data) => {
|
|
29
|
+
if (type !== 'tfdt') return;
|
|
30
|
+
if (bytes[data]) view.setBigUint64(data + 4, view.getBigUint64(data + 4) + BigInt(seconds * timescale));
|
|
31
|
+
else view.setUint32(data + 4, view.getUint32(data + 4) + seconds * timescale);
|
|
32
|
+
});
|
|
33
|
+
return bytes;
|
|
34
|
+
}
|
|
35
|
+
function context() {
|
|
36
|
+
const tasks = [], scope = createMediaSourceScope({ origin: 'https://media.test', schedule: task => tasks.push(task), DOMException });
|
|
37
|
+
const flush = () => { while (tasks.length) tasks.shift()(); };
|
|
38
|
+
const source = new scope.MediaSource(); source.attach('blob:https://media.test/clip'); flush();
|
|
39
|
+
const buffer = source.addSourceBuffer('video/mp4; codecs="avc1.64000a"');
|
|
40
|
+
return { source, pipe: buffer._pipe, append(bytes) { buffer.appendBuffer(bytes); flush(); } };
|
|
41
|
+
}
|
|
42
|
+
async function frameHashes(bytes) {
|
|
43
|
+
const decoder = await wasmpeg.decode(bytes, { format: 'mp4' }), hashes = [];
|
|
44
|
+
try { for (let frame; (frame = decoder.nextFrame(128, 96));) hashes.push(Bun.hash(frame)); }
|
|
45
|
+
finally { decoder.close(); }
|
|
46
|
+
return hashes;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test('MSE configuration changes preserve every frame and allow backward selection across A-B-A epochs', async () => {
|
|
50
|
+
const c = context(), second = shifted(large, 6), third = shifted(small, 12);
|
|
51
|
+
c.append(small); c.append(second); c.append(third); c.source.endOfStream();
|
|
52
|
+
const a = c.pipe.snapshotAt(2), b = c.pipe.snapshotAt(8), a2 = c.pipe.snapshotAt(14);
|
|
53
|
+
expect([a.startTime, a.endTime, b.startTime, b.endTime, a2.startTime, a2.endTime]).toEqual([0, 6, 6, 12, 12, Infinity]);
|
|
54
|
+
expect(new Set([a.id, b.id, a2.id]).size).toBe(3);
|
|
55
|
+
expect(await frameHashes(a.bytes)).toEqual(await frameHashes(small));
|
|
56
|
+
expect(await frameHashes(b.bytes)).toEqual(await frameHashes(second));
|
|
57
|
+
expect(await frameHashes(a2.bytes)).toEqual(await frameHashes(third));
|
|
58
|
+
expect(await frameHashes(c.pipe.snapshotAt(1).bytes)).toEqual(await frameHashes(small));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('identical init retries do not create an epoch or duplicate media, while split init headers remain usable', async () => {
|
|
62
|
+
const c = context();
|
|
63
|
+
let initEnd;
|
|
64
|
+
visit(small, (type, data, end) => { if (type === 'moov') initEnd = end; });
|
|
65
|
+
c.append(small.subarray(0, initEnd));
|
|
66
|
+
const pendingId = c.pipe.snapshotAt(0).id;
|
|
67
|
+
c.append(small.subarray(0, initEnd));
|
|
68
|
+
expect(c.pipe.snapshotAt(0).id).toBe(pendingId);
|
|
69
|
+
for (let offset = 0; offset < small.length; offset += 37) c.append(small.subarray(offset, offset + 37));
|
|
70
|
+
const initial = c.pipe.snapshotAt(2);
|
|
71
|
+
c.append(small);
|
|
72
|
+
const retry = c.pipe.snapshotAt(2);
|
|
73
|
+
expect(retry.id).toBe(initial.id);
|
|
74
|
+
expect(retry.endTime).toBe(Infinity);
|
|
75
|
+
expect(await frameHashes(retry.bytes)).toEqual(await frameHashes(small));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('overlapping replacement fragments are deduplicated only within their initialization', async () => {
|
|
79
|
+
const c = context(); c.append(small);
|
|
80
|
+
const second = shifted(large, 2); c.append(second);
|
|
81
|
+
expect(c.pipe.snapshotAt(1).endTime).toBe(2);
|
|
82
|
+
expect(c.pipe.snapshotAt(3).startTime).toBe(2);
|
|
83
|
+
expect(await frameHashes(c.pipe.snapshotAt(3).bytes)).toEqual(await frameHashes(second));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('ordinary MP4 including a zero-sized final mdat keeps its complete bytes across appends', async () => {
|
|
87
|
+
const bytes = new Uint8Array(await Bun.file(new URL('./fixtures/video.mp4', import.meta.url)).arrayBuffer());
|
|
88
|
+
const expected = await frameHashes(bytes);
|
|
89
|
+
for (const zeroSized of [false, true]) {
|
|
90
|
+
const input = bytes.slice();
|
|
91
|
+
if (zeroSized) visit(input, (type, data, end, offset) => { if (type === 'mdat') new DataView(input.buffer).setUint32(offset, 0); });
|
|
92
|
+
const c = context();
|
|
93
|
+
for (let offset = 0; offset < input.length; offset += 113) c.append(input.subarray(offset, offset + 113));
|
|
94
|
+
c.source.endOfStream();
|
|
95
|
+
expect(c.pipe.snapshotAt(0).bytes).toEqual(input);
|
|
96
|
+
expect(await frameHashes(c.pipe.snapshotAt(0).bytes)).toEqual(expected);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { createMediaSourceScope } from "../lib/renderer/media-source.js";
|
|
3
|
+
|
|
4
|
+
function context(appendQuota = 1000) {
|
|
5
|
+
const tasks = [];
|
|
6
|
+
const media = createMediaSourceScope({
|
|
7
|
+
origin: "https://media.test", DOMException, appendQuota,
|
|
8
|
+
schedule: task => tasks.push(task),
|
|
9
|
+
});
|
|
10
|
+
const flush = () => { while (tasks.length) tasks.shift()(); };
|
|
11
|
+
const source = new media.MediaSource();
|
|
12
|
+
source.attach("blob:https://media.test/audio");
|
|
13
|
+
flush();
|
|
14
|
+
const buffer = source.addSourceBuffer('audio/webm; codecs="opus"');
|
|
15
|
+
const append = bytes => { buffer.appendBuffer(bytes); flush(); };
|
|
16
|
+
return { source, buffer, flush, append };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// One finite Cluster, timecode 0, with a SimpleBlock at 1000 ms.
|
|
20
|
+
const cluster = Uint8Array.from([
|
|
21
|
+
0x1f, 0x43, 0xb6, 0x75, 0x8a, 0xe7, 0x81, 0x00,
|
|
22
|
+
0xa3, 0x85, 0x81, 0x03, 0xe8, 0x00, 0x00,
|
|
23
|
+
]);
|
|
24
|
+
const header = Uint8Array.from([0x1a, 0x45, 0xdf, 0xa3, 0x80]);
|
|
25
|
+
|
|
26
|
+
test("idle abort discards an incomplete WebM element before the next initialization", () => {
|
|
27
|
+
const { buffer, flush, append } = context();
|
|
28
|
+
append(cluster.subarray(0, 7));
|
|
29
|
+
expect(buffer._pipe.size).toBe(0);
|
|
30
|
+
const events = [];
|
|
31
|
+
buffer.addEventListener("abort", () => events.push("abort"));
|
|
32
|
+
buffer.addEventListener("updateend", () => events.push("updateend"));
|
|
33
|
+
buffer.abort();
|
|
34
|
+
flush();
|
|
35
|
+
expect(buffer._accountedBytes).toBe(0);
|
|
36
|
+
expect(events).toEqual([]);
|
|
37
|
+
const next = new Uint8Array(Bun.concatArrayBuffers([header, cluster]));
|
|
38
|
+
append(next);
|
|
39
|
+
expect(buffer._pipe.bytes()).toEqual(next);
|
|
40
|
+
expect(buffer.buffered.end(0)).toBeCloseTo(1.02, 5);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("abort cancels a queued WebM append and clears a previous incomplete element", () => {
|
|
44
|
+
const { buffer, flush, append } = context();
|
|
45
|
+
append(cluster.subarray(0, 7));
|
|
46
|
+
const events = [];
|
|
47
|
+
buffer.addEventListener("abort", () => events.push("abort"));
|
|
48
|
+
buffer.addEventListener("updateend", () => events.push("updateend"));
|
|
49
|
+
buffer.appendBuffer(cluster.subarray(7));
|
|
50
|
+
buffer.abort();
|
|
51
|
+
flush();
|
|
52
|
+
expect(buffer.updating).toBe(false);
|
|
53
|
+
expect(buffer._accountedBytes).toBe(0);
|
|
54
|
+
expect(buffer._pipe.size).toBe(0);
|
|
55
|
+
expect(events).toEqual(["abort", "updateend"]);
|
|
56
|
+
append(cluster);
|
|
57
|
+
expect(buffer._pipe.bytes()).toEqual(cluster);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a split retry completed after eviction cannot refund another buffer's quota", () => {
|
|
61
|
+
const { source, buffer, flush, append } = context(100);
|
|
62
|
+
const other = source.addSourceBuffer('audio/webm; codecs="opus"');
|
|
63
|
+
// An independent 50-byte Void element reserves the other buffer's quota.
|
|
64
|
+
const padding = new Uint8Array(50);
|
|
65
|
+
padding.set([0xec, 0xb0]);
|
|
66
|
+
other.appendBuffer(padding);
|
|
67
|
+
flush();
|
|
68
|
+
append(cluster);
|
|
69
|
+
append(cluster.subarray(0, 7));
|
|
70
|
+
buffer.remove(0, 2);
|
|
71
|
+
flush();
|
|
72
|
+
append(cluster.subarray(7));
|
|
73
|
+
expect(buffer._accountedBytes).toBe(0);
|
|
74
|
+
expect(other._accountedBytes).toBe(50);
|
|
75
|
+
expect(buffer._pipe.bytes()).toEqual(cluster);
|
|
76
|
+
expect(() => buffer.appendBuffer(new Uint8Array(51))).toThrow("The SourceBuffer is full.");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("repeated unknown Clusters retain only the current unused prefix until abort or EOF", () => {
|
|
80
|
+
for (const finish of ["abort", "endOfStream"]) {
|
|
81
|
+
const { source, buffer, append } = context();
|
|
82
|
+
const live = cluster.slice();
|
|
83
|
+
live[4] = 0xff;
|
|
84
|
+
append(live);
|
|
85
|
+
expect(buffer._accountedBytes).toBe(live.length);
|
|
86
|
+
for (let retry = 0; retry < 3; retry++) {
|
|
87
|
+
append(live);
|
|
88
|
+
// The current prefix remains necessary if a later Block is new.
|
|
89
|
+
expect(buffer._webmStream.pendingSize).toBe(8);
|
|
90
|
+
expect(buffer._accountedBytes).toBe(live.length + 8);
|
|
91
|
+
expect(buffer._pipe.bytes()).toEqual(live);
|
|
92
|
+
}
|
|
93
|
+
if (finish === "abort") buffer.abort();
|
|
94
|
+
else source.endOfStream();
|
|
95
|
+
expect(buffer._webmStream.pendingSize).toBe(0);
|
|
96
|
+
expect(buffer._accountedBytes).toBe(live.length);
|
|
97
|
+
}
|
|
98
|
+
});
|