buninu-browser 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/lib/audio/playback-buffer.js +27 -0
  2. package/lib/audio/pulse-client.js +18 -8
  3. package/lib/cdp-server.js +53 -14
  4. package/lib/happy-dom/document-base.js +33 -0
  5. package/lib/happy-dom/parser.js +20 -6
  6. package/lib/happy-dom/selector-matcher.js +2 -2
  7. package/lib/happy-dom/selector-pseudo-adapter.js +33 -0
  8. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  9. package/lib/headless-shell.js +12 -6
  10. package/lib/input/hit-test.js +67 -19
  11. package/lib/layout/text-layout.js +289 -131
  12. package/lib/media/byte-pipe.js +2 -2
  13. package/lib/media/position-audio.js +10 -1
  14. package/lib/media/source-snapshots.js +126 -0
  15. package/lib/media/video-keyframe.js +42 -0
  16. package/lib/media/webm-stream.js +106 -0
  17. package/lib/network/network-service.js +268 -120
  18. package/lib/paint/display-list.js +22 -27
  19. package/lib/paint/frame-cache.js +25 -0
  20. package/lib/paint/frame.js +42 -13
  21. package/lib/paint/paragraph-text.js +24 -0
  22. package/lib/paint/raster-readback.js +41 -0
  23. package/lib/paint/rounded-rect.js +82 -0
  24. package/lib/paint/shared-frame-pool.js +105 -0
  25. package/lib/render-tree/index.js +40 -6
  26. package/lib/render-tree/svg.js +15 -7
  27. package/lib/renderer/bindings.js +244 -82
  28. package/lib/renderer/broadcast-channel.js +76 -0
  29. package/lib/renderer/canvas-2d.js +257 -14
  30. package/lib/renderer/canvas-text.js +120 -0
  31. package/lib/renderer/compositor.js +27 -10
  32. package/lib/renderer/custom-element-reactions.js +42 -0
  33. package/lib/renderer/font-loading.js +227 -0
  34. package/lib/renderer/history.js +42 -0
  35. package/lib/renderer/host.js +36 -5
  36. package/lib/renderer/image-loading.js +27 -0
  37. package/lib/renderer/image-resources.js +65 -0
  38. package/lib/renderer/interfaces.js +28 -0
  39. package/lib/renderer/keyboard-input.js +12 -0
  40. package/lib/renderer/layout-observers.js +236 -0
  41. package/lib/renderer/main.js +33 -5
  42. package/lib/renderer/media-playback-events.js +13 -0
  43. package/lib/renderer/media-source.js +126 -55
  44. package/lib/renderer/page-realm.js +278 -147
  45. package/lib/renderer/page-renderer.js +840 -301
  46. package/lib/renderer/piano-input.js +22 -0
  47. package/lib/renderer/pointer-move.js +29 -0
  48. package/lib/renderer/prepared-script-cache.js +50 -0
  49. package/lib/renderer/rendered-text.js +108 -0
  50. package/lib/renderer/rewrite-worker.js +4 -3
  51. package/lib/renderer/script-rewrite.js +13 -2
  52. package/lib/renderer/scroll-into-view.js +28 -0
  53. package/lib/renderer/slot-assignment.js +60 -0
  54. package/lib/renderer/video-presentation.js +145 -0
  55. package/lib/renderer/workers.js +2 -2
  56. package/lib/style/computed-style.js +632 -108
  57. package/lib/style/selector-traits.js +49 -5
  58. package/package.json +1 -1
  59. package/test/broadcast-channel.test.js +26 -0
  60. package/test/canvas-2d.test.js +83 -2
  61. package/test/canvas-paint.test.js +9 -0
  62. package/test/canvas-text.test.js +41 -0
  63. package/test/cdp-stream.test.js +56 -0
  64. package/test/compositor.test.js +38 -0
  65. package/test/computed-style-binding.test.js +151 -0
  66. package/test/computed-style.test.js +279 -1
  67. package/test/display-list.test.js +26 -0
  68. package/test/document-base.test.js +87 -0
  69. package/test/dom-platform.test.js +461 -0
  70. package/test/download.test.js +8 -0
  71. package/test/fetch-lifecycle.test.js +161 -0
  72. package/test/fixtures/canvas-paint.js +117 -0
  73. package/test/fixtures/canvas-text.js +42 -0
  74. package/test/fixtures/image-lifetime.js +90 -0
  75. package/test/fixtures/media-seek-snapshot.js +77 -0
  76. package/test/fixtures/mse-audio-chirp.webm +0 -0
  77. package/test/fixtures/mse-switch-large.mp4 +0 -0
  78. package/test/fixtures/mse-switch-small.mp4 +0 -0
  79. package/test/fixtures/renderer-memory.js +110 -0
  80. package/test/fixtures/summary-style.js +28 -0
  81. package/test/font-loading.test.js +97 -0
  82. package/test/grid.test.js +58 -0
  83. package/test/headless-shell.test.js +22 -0
  84. package/test/hit-test.test.js +21 -1
  85. package/test/image-lifetime.test.js +12 -0
  86. package/test/image-resources.test.js +94 -0
  87. package/test/local-media.test.js +84 -0
  88. package/test/media-playback-events.test.js +22 -0
  89. package/test/media-seek-snapshot.test.js +11 -0
  90. package/test/media-source-config.test.js +98 -0
  91. package/test/media-source-webm-accounting.test.js +98 -0
  92. package/test/media-source-webm.test.js +108 -0
  93. package/test/navigation.test.js +1 -0
  94. package/test/network-service.test.js +388 -0
  95. package/test/page-scripts.test.js +160 -2
  96. package/test/paint-frame.test.js +103 -0
  97. package/test/paint-paragraph-cache.test.js +87 -0
  98. package/test/piano-input.test.js +44 -0
  99. package/test/playback-buffer.test.js +31 -0
  100. package/test/position-audio.test.js +24 -0
  101. package/test/positioning.test.js +22 -0
  102. package/test/prepared-script-cache.test.js +45 -0
  103. package/test/pulse-client.test.js +27 -5
  104. package/test/raster-readback.test.js +40 -0
  105. package/test/render-tree.test.js +28 -0
  106. package/test/renderer-memory.test.js +11 -0
  107. package/test/selector-traits.test.js +24 -0
  108. package/test/shared-frame-pool.test.js +63 -0
  109. package/test/style-sheets.test.js +51 -0
  110. package/test/svg.test.js +29 -0
  111. package/test/text-layout.test.js +168 -0
  112. package/test/video-keyframe.test.js +61 -0
  113. package/test/video-presentation.test.js +136 -0
  114. package/tools/browser-lab.js +78 -0
  115. package/tools/browser-soak.js +75 -0
  116. package/tools/network-reference.js +49 -0
  117. package/tools/process-memory.js +38 -0
  118. package/tools/script-benchmark.js +25 -0
@@ -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", () => {
@@ -126,6 +126,8 @@ test("top-level shell exposes the parsed document through Bun.WebView", async ()
126
126
  await view.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", x: demoKey.x + demoKey.width / 2,
127
127
  y: demoKey.y + demoKey.height - 15, button: "left", clickCount: 1 });
128
128
  expect(await view.evaluate("document.activeElement.id")).toBe("buninu-piano-input");
129
+ await view.cdp("Input.insertText", { text: "jkl JKL!" });
130
+ expect(await view.evaluate("document.activeElement.value")).toBe("");
129
131
  await view.resize(480, 240);
130
132
  await view.navigate(`http://127.0.0.1:${fixture.port}/`);
131
133
 
@@ -198,12 +200,32 @@ test("top-level shell exposes the parsed document through Bun.WebView", async ()
198
200
  await view.cdp("Input.dispatchMouseEvent", { type: "mousePressed", x: pianoRect.x + 10, y: pianoRect.y + 10, button: "left", clickCount: 1 });
199
201
  await view.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", x: pianoRect.x + 10, y: pianoRect.y + 10, button: "left", clickCount: 1 });
200
202
  await view.cdp("Input.insertText", { text: "j" });
203
+ await view.cdp("Input.insertText", { text: "jk lJK!中" });
204
+ for (const key of ["j", "k", " "]) {
205
+ await view.cdp("Input.dispatchKeyEvent", { type: "rawKeyDown", key, autoRepeat: true });
206
+ await view.cdp("Input.dispatchKeyEvent", { type: "keyUp", key });
207
+ }
201
208
  await view.cdp("Input.dispatchKeyEvent", { type: "rawKeyDown", key: "k", code: "KeyK" });
202
209
  await view.cdp("Input.dispatchKeyEvent", { type: "keyUp", key: "k", code: "KeyK" });
210
+ for (const type of ["rawKeyDown", "char", "keyUp"]) {
211
+ for (const key of ["j", "k", "l"]) {
212
+ await view.cdp("Input.dispatchKeyEvent", { type, key, code: `Key${key.toUpperCase()}`, text: type === "char" ? key : undefined });
213
+ }
214
+ }
203
215
  expect(await view.evaluate("document.getElementById('buninu-piano-input').value")).toBe("");
204
216
  await view.navigate(`http://127.0.0.1:${fixture.port}/`);
205
217
 
206
218
  expect(await view.evaluate("scrollY")).toBe(0);
219
+ await view.evaluate(`window.spaceKeys=[];document.addEventListener('keydown',event=>{
220
+ if(event.keyCode===32){event.preventDefault();spaceKeys.push([event.keyCode,event.which]);}
221
+ });`);
222
+ for(let i=0;i<6;i++) {
223
+ for(const type of ['rawKeyDown','char','keyUp']) {
224
+ await view.cdp('Input.dispatchKeyEvent', {type,key:' ',code:'Space',windowsVirtualKeyCode:32,text:type==='char'?' ':undefined});
225
+ }
226
+ }
227
+ expect(await view.evaluate('spaceKeys')).toEqual(Array.from({length:6},()=>[32,32]));
228
+ expect(await view.evaluate('scrollY')).toBe(0);
207
229
  await view.cdp("Input.dispatchMouseEvent", {
208
230
  type: "mouseWheel",
209
231
  x: 100,
@@ -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,22 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { playbackEvent } from '../lib/renderer/media-playback-events.js';
3
+
4
+ test('playback notifications distinguish unpaused, advancing and starved states', () => {
5
+ const state = {playing:true}, events=[];
6
+ const dispatch=(_state,type)=>events.push(type);
7
+ for(const phase of ['play','playing','playing','waiting','waiting','playing','playing','pause','play','playing']) {
8
+ playbackEvent(state,phase,dispatch);
9
+ }
10
+ expect(events).toEqual(['play','playing','waiting','canplay','playing','pause','play','playing']);
11
+ });
12
+
13
+ test('pausing in canplay does not report playback resumed', () => {
14
+ const state={playing:true}, events=[];
15
+ const dispatch=(_state,type)=>{
16
+ events.push(type);
17
+ if(type==='canplay') {state.playing=false;playbackEvent(state,'pause',dispatch);}
18
+ };
19
+ playbackEvent(state,'waiting',dispatch);
20
+ playbackEvent(state,'playing',dispatch);
21
+ expect(events).toEqual(['waiting','canplay','pause']);
22
+ });
@@ -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
+ });