buninu-browser 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/lib/cdp-server.js +47 -14
  2. package/lib/happy-dom/document-base.js +33 -0
  3. package/lib/happy-dom/parser.js +15 -6
  4. package/lib/happy-dom/selector-matcher.js +2 -2
  5. package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
  6. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  7. package/lib/headless-shell.js +9 -6
  8. package/lib/input/hit-test.js +67 -19
  9. package/lib/layout/text-layout.js +213 -91
  10. package/lib/media/byte-pipe.js +2 -2
  11. package/lib/media/position-audio.js +10 -1
  12. package/lib/media/source-snapshots.js +126 -0
  13. package/lib/media/video-keyframe.js +17 -4
  14. package/lib/media/webm-stream.js +106 -0
  15. package/lib/network/network-service.js +268 -120
  16. package/lib/paint/display-list.js +8 -28
  17. package/lib/paint/frame-cache.js +25 -0
  18. package/lib/paint/frame.js +42 -13
  19. package/lib/paint/paragraph-text.js +24 -0
  20. package/lib/paint/raster-readback.js +41 -0
  21. package/lib/paint/rounded-rect.js +82 -0
  22. package/lib/paint/shared-frame-pool.js +105 -0
  23. package/lib/render-tree/index.js +38 -6
  24. package/lib/render-tree/svg.js +15 -7
  25. package/lib/renderer/bindings.js +233 -80
  26. package/lib/renderer/broadcast-channel.js +76 -0
  27. package/lib/renderer/canvas-2d.js +257 -14
  28. package/lib/renderer/canvas-text.js +120 -0
  29. package/lib/renderer/compositor.js +13 -11
  30. package/lib/renderer/custom-element-reactions.js +25 -1
  31. package/lib/renderer/font-loading.js +227 -0
  32. package/lib/renderer/host.js +28 -4
  33. package/lib/renderer/image-resources.js +65 -0
  34. package/lib/renderer/interfaces.js +28 -0
  35. package/lib/renderer/layout-observers.js +236 -0
  36. package/lib/renderer/main.js +30 -4
  37. package/lib/renderer/media-source.js +126 -55
  38. package/lib/renderer/page-realm.js +270 -98
  39. package/lib/renderer/page-renderer.js +647 -235
  40. package/lib/renderer/prepared-script-cache.js +50 -0
  41. package/lib/renderer/rendered-text.js +108 -0
  42. package/lib/renderer/rewrite-worker.js +4 -3
  43. package/lib/renderer/script-rewrite.js +13 -2
  44. package/lib/renderer/scroll-into-view.js +28 -0
  45. package/lib/renderer/slot-assignment.js +60 -0
  46. package/lib/renderer/video-presentation.js +23 -4
  47. package/lib/renderer/workers.js +2 -2
  48. package/lib/style/computed-style.js +611 -104
  49. package/lib/style/selector-traits.js +49 -5
  50. package/package.json +1 -1
  51. package/test/broadcast-channel.test.js +26 -0
  52. package/test/canvas-2d.test.js +83 -2
  53. package/test/canvas-paint.test.js +9 -0
  54. package/test/canvas-text.test.js +41 -0
  55. package/test/cdp-stream.test.js +56 -0
  56. package/test/computed-style-binding.test.js +151 -0
  57. package/test/computed-style.test.js +258 -1
  58. package/test/display-list.test.js +12 -0
  59. package/test/document-base.test.js +87 -0
  60. package/test/dom-platform.test.js +461 -0
  61. package/test/fetch-lifecycle.test.js +161 -0
  62. package/test/fixtures/canvas-paint.js +117 -0
  63. package/test/fixtures/canvas-text.js +42 -0
  64. package/test/fixtures/image-lifetime.js +90 -0
  65. package/test/fixtures/media-seek-snapshot.js +77 -0
  66. package/test/fixtures/mse-audio-chirp.webm +0 -0
  67. package/test/fixtures/mse-switch-large.mp4 +0 -0
  68. package/test/fixtures/mse-switch-small.mp4 +0 -0
  69. package/test/fixtures/renderer-memory.js +110 -0
  70. package/test/fixtures/summary-style.js +28 -0
  71. package/test/font-loading.test.js +97 -0
  72. package/test/grid.test.js +58 -0
  73. package/test/hit-test.test.js +21 -1
  74. package/test/image-lifetime.test.js +12 -0
  75. package/test/image-resources.test.js +94 -0
  76. package/test/local-media.test.js +84 -0
  77. package/test/media-seek-snapshot.test.js +11 -0
  78. package/test/media-source-config.test.js +98 -0
  79. package/test/media-source-webm-accounting.test.js +98 -0
  80. package/test/media-source-webm.test.js +108 -0
  81. package/test/navigation.test.js +1 -0
  82. package/test/network-service.test.js +388 -0
  83. package/test/page-scripts.test.js +1 -1
  84. package/test/paint-frame.test.js +103 -0
  85. package/test/paint-paragraph-cache.test.js +87 -0
  86. package/test/position-audio.test.js +24 -0
  87. package/test/positioning.test.js +22 -0
  88. package/test/prepared-script-cache.test.js +45 -0
  89. package/test/pulse-client.test.js +10 -3
  90. package/test/raster-readback.test.js +40 -0
  91. package/test/render-tree.test.js +28 -0
  92. package/test/renderer-memory.test.js +11 -0
  93. package/test/selector-traits.test.js +24 -0
  94. package/test/shared-frame-pool.test.js +63 -0
  95. package/test/style-sheets.test.js +51 -0
  96. package/test/svg.test.js +29 -0
  97. package/test/text-layout.test.js +106 -0
  98. package/test/video-keyframe.test.js +61 -0
  99. package/test/video-presentation.test.js +69 -0
  100. package/tools/browser-lab.js +78 -0
  101. package/tools/browser-soak.js +75 -0
  102. package/tools/network-reference.js +49 -0
  103. package/tools/process-memory.js +38 -0
  104. package/tools/script-benchmark.js +25 -0
  105. package/ai-debug.log +0 -5537
@@ -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
+ });
@@ -0,0 +1,108 @@
1
+ import { beforeAll, expect, test } from 'bun:test';
2
+ import wasmpeg from '../vendor/wasmpeg/src/js/index.js';
3
+ import { floatToS16 } from '../lib/audio/pcm.js';
4
+ import { createMediaSourceScope } from '../lib/renderer/media-source.js';
5
+ import { createWebMStream, ebmlHeader } from '../lib/media/webm-stream.js';
6
+
7
+ // Self-generated eight-second chirp, 48 kHz mono Opus, 2-second Clusters.
8
+ // Unlike a constant tone, its PCM identifies a repeated earlier segment.
9
+ let fixture, elements;
10
+ beforeAll(async () => {
11
+ await wasmpeg.load();
12
+ fixture = new Uint8Array(await Bun.file(new URL('./fixtures/mse-audio-chirp.webm', import.meta.url)).arrayBuffer());
13
+ elements = createWebMStream().append(fixture);
14
+ });
15
+ function context() {
16
+ const tasks = [], scope = createMediaSourceScope({ origin: 'https://media.test', schedule: task => tasks.push(task), DOMException });
17
+ const flush = () => { while (tasks.length) tasks.shift()(); };
18
+ const source = new scope.MediaSource(); source.attach('blob:https://media.test/audio'); flush();
19
+ const buffer = source.addSourceBuffer('audio/webm; codecs="opus"');
20
+ return { source, buffer, append(bytes) { buffer.appendBuffer(bytes); flush(); } };
21
+ }
22
+ const concat = parts => new Uint8Array(Bun.concatArrayBuffers(parts));
23
+ async function pcm(bytes) {
24
+ const decoder = await wasmpeg.decodeAudio(bytes, { format: 'webm' }), parts = [];
25
+ try { for (let samples; (samples = decoder.nextSamples());) parts.push(floatToS16(samples)); }
26
+ finally { decoder.close(); }
27
+ return concat(parts);
28
+ }
29
+ const cluster = element => element.id === 0x1f43b675;
30
+
31
+ test('split WebM Clusters, whole retries, and mixed old/new appends preserve exact chronological PCM', async () => {
32
+ const c = context(), groups = elements.filter(cluster), init = concat(elements.slice(0, elements.indexOf(groups[0])).map(e => e.bytes));
33
+ expect(groups.length).toBeGreaterThanOrEqual(4);
34
+ for (let i = 0; i < init.length; i += 7) c.append(init.subarray(i, i + 7));
35
+ const first = groups[0].bytes;
36
+ // This was the live failure: a partial first Cluster understated coverage,
37
+ // and a later whole retry inserted its opening PCM after the second one.
38
+ for (let i = 0; i < first.length; i += 137) c.append(first.subarray(i, i + 137));
39
+ c.append(groups[1].bytes);
40
+ const beforeRetry = c.buffer._pipe.size;
41
+ c.append(first);
42
+ expect(c.buffer._pipe.size).toBe(beforeRetry);
43
+ c.append(concat([groups[0].bytes, groups[2].bytes]));
44
+ for (const element of elements.slice(elements.indexOf(groups[3]))) c.append(element.bytes);
45
+ c.source.endOfStream();
46
+ const actual = await pcm(c.buffer._pipe.bytes()), reference = await pcm(fixture);
47
+ expect(actual.length).toBe(reference.length);
48
+ expect(Bun.hash(actual)).toBe(Bun.hash(reference));
49
+ expect(c.buffer.buffered.end(0)).toBeGreaterThanOrEqual(8);
50
+ expect(c.buffer.buffered.end(0)).toBeLessThan(8.1);
51
+ expect(Bun.hash(actual.subarray(0, 48000 * 2))).not.toBe(Bun.hash(actual.subarray(4 * 48000 * 2, 5 * 48000 * 2)));
52
+ });
53
+
54
+ test('unknown-size live Clusters expose complete Blocks before the next Cluster and deduplicate retried Blocks', async () => {
55
+ const c = context(), groups = elements.filter(cluster);
56
+ for (const element of elements.slice(0, elements.indexOf(groups[0]))) c.append(element.bytes);
57
+ let initialSize = c.buffer._pipe.size;
58
+ for (const group of groups) {
59
+ const header = ebmlHeader(group.bytes), idLength = 4;
60
+ const live = group.bytes.slice();
61
+ // Preserve the original VINT width while replacing its value with unknown.
62
+ live[idLength] = (1 << (9 - (header.length - idLength))) - 1;
63
+ live.fill(0xff, idLength + 1, header.length);
64
+ for (let i = 0; i < live.length; i += 101) c.append(live.subarray(i, i + 101));
65
+ expect(c.buffer._pipe.size).toBeGreaterThan(initialSize);
66
+ initialSize = c.buffer._pipe.size;
67
+ // The next header closes the previous unknown Cluster. Its old Blocks
68
+ // must not be played again even though no finite size was available.
69
+ c.append(live);
70
+ expect(c.buffer._pipe.size).toBe(initialSize);
71
+ }
72
+ c.source.endOfStream();
73
+ const actual = await pcm(c.buffer._pipe.bytes()), reference = await pcm(fixture);
74
+ expect(actual.length).toBe(reference.length);
75
+ expect(Bun.hash(actual)).toBe(Bun.hash(reference));
76
+ });
77
+
78
+ test('EBML framing never treats packet payload bytes as Cluster boundaries', () => {
79
+ const stream = createWebMStream();
80
+ const bytes = Uint8Array.from([
81
+ 0x1f, 0x43, 0xb6, 0x75, 0xff, // unknown Cluster
82
+ 0xe7, 0x81, 0,
83
+ 0xa3, 0x89, 0x81, 0, 0, 0, 0x1f, 0x43, 0xb6, 0x75, 0,
84
+ ]);
85
+ const emitted = [];
86
+ for (const byte of bytes) emitted.push(...stream.append(Uint8Array.of(byte)));
87
+ expect(emitted.length).toBe(1);
88
+ expect(emitted[0].cluster.time).toBe(0);
89
+ expect([...emitted[0].bytes]).toEqual([...bytes.subarray(8)]);
90
+ // The standalone framer still owns the prefix until a consumer accepts
91
+ // the Block (SourceBuffer records that on the shared cluster token).
92
+ expect(stream.pendingSize).toBe(8);
93
+ stream.reset();
94
+ expect(stream.pendingSize).toBe(0);
95
+ });
96
+
97
+ test('incremental muxed Clusters retain different tracks at the same presentation time', () => {
98
+ const c = context();
99
+ const bytes = Uint8Array.from([
100
+ 0x1f, 0x43, 0xb6, 0x75, 0xff, 0xe7, 0x81, 0,
101
+ 0xa3, 0x85, 0x81, 0, 0, 0, 1,
102
+ 0xa3, 0x85, 0x82, 0, 0, 0, 2,
103
+ ]);
104
+ c.append(bytes);
105
+ expect([...c.buffer._pipe.bytes()]).toEqual([...bytes]);
106
+ c.append(bytes);
107
+ expect([...c.buffer._pipe.bytes()]).toEqual([...bytes]);
108
+ });
@@ -58,4 +58,5 @@ test("a redirected document has the URL it ended on, and its POST form submits t
58
58
  }]);
59
59
  // The 303 after it was followed with a GET, and the document is at its URL.
60
60
  expect(await context.evaluate("location.href")).toBe(`${origin}/done`);
61
+ expect(await context.evaluate("document.referrer")).toBe(`${origin}/dir/form?step=1`);
61
62
  });