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
@@ -107,6 +107,28 @@ test("relative offsets and translate move a box and its content without affectin
107
107
  expect(box(".centered")).toMatchObject({ x: -20, y: 40 + 10 });
108
108
  });
109
109
 
110
+ test("transform lists compose pixel and percentage translations in order", () => {
111
+ const { box } = render(
112
+ `<div class="popup"></div><div class="scaled"></div><div class="reversed"></div><div class="math"></div>`,
113
+ `.popup,.scaled,.reversed,.math { position:absolute;left:0;top:0;width:100px;height:153px }
114
+ .popup { transform:translate(56px,780px) translate(0,-100%) }
115
+ .scaled { transform:scale(2,3) translate(10px,20%) translateX(5px) }
116
+ .reversed { transform:translate(10px,20%) scale(2,3) translateX(5px) }
117
+ .math { transform:translate(calc(25% + 10px),min(20%,40px)) translate(10%,5px) }`,
118
+ );
119
+ expect(box(".popup")).toEqual({x:56,y:627,width:100,height:153});
120
+ expect(box(".scaled")).toMatchObject({x:30,y:91.8});
121
+ expect(box(".reversed")).toMatchObject({x:20,y:30.6});
122
+ expect(box(".math")).toMatchObject({x:45,y:35.6});
123
+ });
124
+
125
+ test("individual translate precedes the transform list and preserves normal flow", () => {
126
+ const {box} = render('<div class="translated"></div><div class="next"></div>',
127
+ '.translated{width:100px;height:80px;translate:10px -50%;transform:scale(2) translate(5px,10px)}.next{height:20px}');
128
+ expect(box('.translated')).toEqual({x:20,y:-20,width:100,height:80});
129
+ expect(box('.next').y).toBe(80);
130
+ });
131
+
110
132
  test("an absolutely positioned ::after arrow is centred on its host's edge", () => {
111
133
  // The buninu.org step arrow: top:50%; right:-.75rem; translate(50%,-50%).
112
134
  const { box } = render(
@@ -0,0 +1,45 @@
1
+ import { expect, test } from "bun:test";
2
+ import { PreparedScriptCache, prepareClassicScript } from "../lib/renderer/prepared-script-cache.js";
3
+ import { escapeSesHtmlComments, rewriteClassicScriptSource } from "../lib/renderer/script-rewrite.js";
4
+
5
+ test("cached preparation preserves source semantics and does not retain evaluated globals", () => {
6
+ const source = "var result = supplied + 1; function read() { return result; }";
7
+ const prepared = prepareClassicScript(source);
8
+ const original = rewriteClassicScriptSource(source);
9
+ expect(prepared).toEqual({ code: escapeSesHtmlComments(original.code), parsed: true, strict: false });
10
+ expect(prepareClassicScript(source)).toBe(prepared);
11
+ const run = supplied => {
12
+ const page = { supplied, $buninu$ref: name => { throw new ReferenceError(name); } };
13
+ page.globalThis = page;
14
+ Function("page", "source", "with(page) { eval(source); }")(page, prepared.code);
15
+ return page;
16
+ };
17
+ const first = run(2), second = run(8);
18
+ expect(first.read()).toBe(3);
19
+ expect(second.read()).toBe(9);
20
+ expect(first.read).not.toBe(second.read);
21
+ expect(Object.isFrozen(prepared)).toBeTrue();
22
+ });
23
+
24
+ test("preparation retains a bounded working set and never keeps oversized sources", () => {
25
+ const calls = [];
26
+ const cache = new PreparedScriptCache({ maxBytes: 40, maxEntries: 2, prepare(source) {
27
+ calls.push(source); return { code: source, parsed: true, strict: false };
28
+ } });
29
+ cache.prepare("first"); cache.prepare("next"); cache.prepare("first"); cache.prepare("third");
30
+ expect(calls).toEqual(["first", "next", "third"]);
31
+ cache.prepare("next");
32
+ expect(calls).toEqual(["first", "next", "third", "next"]);
33
+ const oversized = "a".repeat(11);
34
+ cache.prepare(oversized); cache.prepare(oversized);
35
+ expect(calls.filter(source => source === oversized)).toHaveLength(2);
36
+ expect(cache.prepare("next").code).toBe("next");
37
+ expect(calls.filter(source => source === "next")).toHaveLength(2);
38
+ });
39
+
40
+ test("different strictness and invalid sources never share a prepared result", () => {
41
+ const cache = new PreparedScriptCache();
42
+ expect(cache.prepare("'use strict'; var value = 1;").strict).toBeTrue();
43
+ expect(cache.prepare("var value = 1;").strict).toBeFalse();
44
+ expect(cache.prepare("var = ;").parsed).toBeFalse();
45
+ });
@@ -135,6 +135,13 @@ test("measured device progress governs the media clock and bounded audio queue t
135
135
  const { createServer } = await import("node:net");
136
136
  let received = 0, played = 0, reports = 0;
137
137
  const times = [], errors = [], requests = [];
138
+ // The suite also runs CPU-heavy renderers in parallel. Observe protocol
139
+ // progress instead of assuming a polling timer ran within 120 ms.
140
+ const waitFor = async predicate => {
141
+ const deadline = Date.now() + 2000;
142
+ while (!predicate() && Date.now() < deadline) await Bun.sleep(10);
143
+ expect(predicate()).toBe(true);
144
+ };
138
145
  const scalar64 = (tag, value) => {
139
146
  const bytes = Buffer.alloc(9);
140
147
  bytes[0] = tag;
@@ -178,13 +185,13 @@ test("measured device progress governs the media clock and bounded audio queue t
178
185
  await Bun.sleep(80);
179
186
  expect(received).toBe(0); // a short prefix does not start/stop playback
180
187
  stream.write(new Uint8Array(1900));
181
- await Bun.sleep(180);
188
+ await waitFor(() => reports > 0 && received === 500 && times.length > 0);
182
189
  expect(reports).toBeGreaterThan(0);
183
190
  expect(received).toBe(500); // at most the 0.5 s queue budget, not 2 s
184
191
  expect(stream.queued).toBe(1500); // survives a renderer busy spell in the controller
185
192
  expect(times.at(-1)).toBe(0); // server/device hasn't played anything
186
193
  played = 100;
187
- await Bun.sleep(120);
194
+ await waitFor(() => times.at(-1) === 0.1 && received === 600);
188
195
  expect(times.at(-1)).toBeCloseTo(0.1, 5);
189
196
  expect(received).toBe(600);
190
197
  // A long network/producer pause is NOT permission to catch up by queuing seconds.
@@ -192,7 +199,7 @@ test("measured device progress governs the media clock and bounded audio queue t
192
199
  expect(times.at(-1)).toBeCloseTo(0.1, 5);
193
200
  expect(received).toBe(600);
194
201
  played = 600;
195
- await Bun.sleep(120);
202
+ await waitFor(() => times.at(-1) === 0.6 && received > 1000);
196
203
  expect(times.at(-1)).toBeCloseTo(0.6, 5);
197
204
  expect(received).toBeGreaterThan(1000);
198
205
  expect(received).toBeLessThanOrEqual(1100);
@@ -0,0 +1,40 @@
1
+ import { expect, test } from "bun:test";
2
+ import { loadGraphics } from "../lib/renderer/page-renderer.js";
3
+ import { RasterReadback } from "../lib/paint/raster-readback.js";
4
+
5
+ test("raster readback reuses native scratch while published shared frames stay immutable", async () => {
6
+ const { CanvasKit: kit } = await loadGraphics();
7
+ const malloc = kit._malloc, free = kit._free;
8
+ const pointers = new Set();
9
+ let allocations = 0, releases = 0;
10
+ kit._malloc = size => { const pointer = malloc(size); pointers.add(pointer); allocations++; return pointer; };
11
+ kit._free = pointer => { if (pointers.delete(pointer)) releases++; free(pointer); };
12
+ const surface = kit.MakeSurface(4, 4);
13
+ const capture = new RasterReadback(kit, { shared: true });
14
+ const read = (size = 4) => {
15
+ const image = surface.makeImageSnapshot();
16
+ try { return capture.read(image, size, size); } finally { image.delete(); }
17
+ };
18
+ try {
19
+ surface.getCanvas().clear(kit.Color(255, 0, 0, 0.5));
20
+ const first = read();
21
+ const afterFirst = allocations;
22
+ expect(first.buffer).toBeInstanceOf(SharedArrayBuffer);
23
+ expect([...first.subarray(0, 4)]).toEqual([255, 0, 0, 128]);
24
+ surface.getCanvas().clear(kit.Color(0, 0, 255, 1));
25
+ const second = read();
26
+ expect(allocations).toBe(afterFirst);
27
+ expect([...second.subarray(0, 4)]).toEqual([0, 0, 255, 255]);
28
+ expect([...first.subarray(0, 4)]).toEqual([255, 0, 0, 128]);
29
+ expect(read(2)).toHaveLength(16);
30
+ expect(allocations).toBe(afterFirst);
31
+ // Structured cloning shares pixels without another allocation or copy.
32
+ const clone = structuredClone(first);
33
+ expect(clone.buffer).toBeInstanceOf(SharedArrayBuffer);
34
+ expect([...clone]).toEqual([...first]);
35
+ } finally {
36
+ capture.dispose(); capture.dispose(); surface.dispose();
37
+ kit._malloc = malloc; kit._free = free;
38
+ }
39
+ expect(releases).toBe(allocations);
40
+ });
@@ -40,6 +40,34 @@ test("keeps node IDs stable across generations of the same DOM", () => {
40
40
  window.happyDOM.abort();
41
41
  });
42
42
 
43
+ test("closed details show only their first summary and update when opened", async () => {
44
+ const { StyleEngine } = await import("../lib/style/computed-style.js");
45
+ const { document, window } = parseHTMLDocument(`<details><p>Before summary</p><summary>Menu</summary><summary>Extra summary</summary><p>Contents</p></details>`);
46
+ const builder = new RenderTreeBuilder();
47
+ const styles = new StyleEngine();
48
+ const first = builder.build(document, styles.compute(document));
49
+ expect(renderTreeText(first)).toBe("▶ Menu");
50
+ document.querySelector("details").setAttribute("open", "");
51
+ const second = builder.build(document, styles.compute(document));
52
+ expect(renderTreeText(second)).toContain("▼ Menu");
53
+ expect(renderTreeText(second)).toContain("Extra summary");
54
+ expect(renderTreeText(second)).toContain("Contents");
55
+ expect(renderTreeText(second)).toContain("Before summary");
56
+ window.happyDOM.abort();
57
+ });
58
+
59
+ test("details without summary generate the default label and author CSS can override hidden", async () => {
60
+ const { StyleEngine } = await import("../lib/style/computed-style.js");
61
+ const { document, window } = parseHTMLDocument(`<details style="padding:20px;border:3px solid;color:red">Contents</details><p hidden style="display:block">Visible</p>`);
62
+ const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
63
+ expect(renderTreeText(tree)).toBe("▶ DetailsVisible");
64
+ const summary = [...tree.nodesById.values()].find(node => node.tagName === "SUMMARY");
65
+ expect(summary.style.padding).toEqual([0, 0, 0, 0]);
66
+ expect(summary.style.borderWidths).toEqual([0, 0, 0, 0]);
67
+ expect(summary.style.color).toBe("rgba(255, 0, 0, 1)");
68
+ window.happyDOM.abort();
69
+ });
70
+
43
71
  test("includes visible elements appended beside body under html", async () => {
44
72
  const { StyleEngine } = await import("../lib/style/computed-style.js");
45
73
  const { document, window } = parseHTMLDocument(`<html><head></head><body><p>body</p></body></html>`);
@@ -0,0 +1,11 @@
1
+ import { expect, test } from 'bun:test';
2
+
3
+ test('CanvasKit renderer lifetimes release native surfaces and failed video readbacks', async () => {
4
+ const child = Bun.spawn([process.execPath, new URL('./fixtures/renderer-memory.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('renderer memory lifecycle passed');
11
+ }, 30_000);
@@ -17,3 +17,27 @@ test("finds :has() compounds and sibling-target compounds in a sheet", () => {
17
17
  expect(traits.descendantFeatures.has(".esc:a")).toBe(true);
18
18
  expect(traits.sibling && traits.firstChild && traits.lastChild).toBe(true);
19
19
  });
20
+
21
+ test("focus pseudo-class features retain their complete names", () => {
22
+ const traits = sheetTraits('.a:focus .x{}.b:focus-visible .x{}.c:focus-within .x{}');
23
+ expect(traits.descendantStateCompounds.map(entry=>entry.states)).toEqual([[":focus"],[":focus-visible"],[":focus-within"]]);
24
+ });
25
+
26
+ test("nested rule groups retain their first selector's invalidation dependencies", () => {
27
+ const traits=sheetTraits('@media(min-width:1px){@layer app{.panel:focus-within .label{color:red}.panel:has([data-open]){color:blue}}}');
28
+ expect(traits.descendantStateCompounds.map(entry=>entry.states)).toEqual([[":focus-within"]]);
29
+ expect([...traits.hasCompounds]).toEqual(['.panel:has([data-open])']);
30
+ expect(traits.descendantFeatures.has('.panel')).toBe(true);
31
+ });
32
+
33
+ test("functional alternatives constrain state invalidation without assuming negated requirements", () => {
34
+ const traits = sheetTraits(':is(.a,.b):focus-within .x{}:where(.outer > .target,button):focus .x{}:not(:is(.a,.b)):focus .x{}');
35
+ expect(traits.descendantStateCompounds.map(entry=>entry.alternatives))
36
+ .toEqual([[[[".a"],[".b"]]],[[[".target"],["t:button"]]],[]]);
37
+ });
38
+
39
+ test("observed attributes include nested, subject and escaped selectors", () => {
40
+ const traits = sheetTraits(String.raw`@media (min-width:1px){[data-open] .x:has([aria-expanded="true"]){} }
41
+ :is([title],[data-foo\:bar]){}.x[DATA-STATE]{}[ns|data-mode]{}[|data-empty]{}[data\2d escaped]{}`);
42
+ expect([...traits.observedAttributes].sort()).toEqual(["aria-expanded","data-empty","data-escaped","data-foo:bar","data-mode","data-open","data-state","title"]);
43
+ });
@@ -0,0 +1,63 @@
1
+ import { expect, test } from "bun:test";
2
+ import { SharedFramePool, FrameLeaseTracker } from "../lib/paint/shared-frame-pool.js";
3
+
4
+ test("pixel reuse waits for both current renderer ownership and every compositor lease", () => {
5
+ const pool = new SharedFramePool();
6
+ const first = pool.acquire(16); first.fill(7); pool.publish(1, first);
7
+ pool.publish(2, first); // Scroll publication with the same current pixels.
8
+ pool.release(1);
9
+ const second = pool.acquire(16); second.fill(9); pool.publish(3, second);
10
+ const third = pool.acquire(16);
11
+ expect(third.buffer).not.toBe(first.buffer);
12
+ expect([...first]).toEqual(Array(16).fill(7));
13
+ pool.release(2);
14
+ expect(pool.acquire(16).buffer).toBe(first.buffer);
15
+ pool.release(3);
16
+ expect(pool.acquire(16).buffer).not.toBe(second.buffer); // Still renderer's current tile.
17
+ pool.clear();
18
+ });
19
+
20
+ test("an old frame stays immutable while its asynchronous screenshot encoder is reading it", async () => {
21
+ const pool = new SharedFramePool();
22
+ const released = [];
23
+ const tracker = new FrameLeaseTracker(serial => { released.push(serial); pool.release(serial); });
24
+ const first = pool.acquire(8); first.fill(1); pool.publish(1, first); tracker.replace(1, first);
25
+ let finish;
26
+ const captured = tracker.capture(async pixels => { await new Promise(resolve => finish = resolve); return [...pixels]; });
27
+ const second = pool.acquire(8); second.fill(2); pool.publish(2, second); tracker.replace(2, second);
28
+ expect(released).toEqual([]);
29
+ const third = pool.acquire(8); third.fill(3); pool.publish(3, third); tracker.replace(3, third);
30
+ expect(released).toEqual([2]);
31
+ expect(pool.acquire(8).buffer).toBe(second.buffer);
32
+ finish();
33
+ expect(await captured).toEqual(Array(8).fill(1));
34
+ expect(released).toEqual([2, 1]);
35
+ expect(pool.acquire(8).buffer).toBe(first.buffer);
36
+ pool.clear();
37
+ });
38
+
39
+ test("idle pixel cache stays bounded and failed captures release their retired frames", async () => {
40
+ const pool = new SharedFramePool({ maxBuffers: 1, maxBytes: 8 });
41
+ const frames = Array.from({ length: 4 }, (_, i) => {
42
+ const pixels = pool.acquire(8); pool.publish(i, pixels); return pixels;
43
+ });
44
+ pool.release(0); pool.release(1); pool.release(2);
45
+ expect(pool.acquire(8).buffer).toBe(frames[2].buffer);
46
+ expect(frames.some(frame => pool.acquire(8).buffer === frame.buffer)).toBe(false);
47
+ const released = [];
48
+ const tracker = new FrameLeaseTracker(serial => released.push(serial));
49
+ tracker.replace(10, "old");
50
+ const pending = tracker.capture(() => { throw new Error("encoder failed"); });
51
+ tracker.replace(11, "new");
52
+ await expect(pending).rejects.toThrow("encoder failed");
53
+ expect(released).toEqual([10]);
54
+ });
55
+
56
+ test("a resize replaces idle warm-up buffers with reusable current-size buffers", () => {
57
+ const pool = new SharedFramePool({ maxBuffers: 2 });
58
+ const small = pool.acquire(4); pool.publish(1, small);
59
+ const medium = pool.acquire(8); pool.publish(2, medium); pool.release(1);
60
+ const large = pool.acquire(32); pool.publish(3, large); pool.release(2);
61
+ const next = pool.acquire(32); pool.publish(4, next); pool.release(3);
62
+ expect(pool.acquire(32).buffer).toBe(large.buffer);
63
+ });
@@ -0,0 +1,51 @@
1
+ import { afterAll, beforeAll, expect, test } from "bun:test";
2
+ import { RendererHost } from "../lib/renderer/host.js";
3
+
4
+ let renderer;
5
+ const requests = [];
6
+ beforeAll(async () => {
7
+ renderer = new RendererHost({ timeout: 20_000, fetch: async url => {
8
+ requests.push(String(url));
9
+ return new Response("#target { color: blue }", { headers: { "content-type": "text/css" } });
10
+ }});
11
+ await renderer.start();
12
+ });
13
+ afterAll(() => renderer?.close());
14
+ const read = (code = "") => renderer.call("evaluate", `(()=>{${code};return getComputedStyle(document.getElementById('target')).color})()`);
15
+ async function load(source) {
16
+ await renderer.call("loadDocument", { url: "https://style.test/", contentType: "text/html", status: 200, source });
17
+ await renderer.call("whenLoaded");
18
+ }
19
+
20
+ test("inline sheet media and type are respected after mutation and viewport changes", async () => {
21
+ await renderer.call("resize", 800, 600, 1);
22
+ await load(`<!doctype html><style>#target{color:red}</style>
23
+ <style media="print">#target{color:purple}</style>
24
+ <style id="responsive" media="screen and (max-width:500px)">#target{color:green}</style>
25
+ <style id="typed" type="text/plain">#target{color:blue}</style><div id="target">Text</div>`);
26
+ expect(await read()).toBe("rgba(255, 0, 0, 1)");
27
+ await renderer.call("resize", 400, 600, 1);
28
+ expect(await read()).toBe("rgba(0, 128, 0, 1)");
29
+ expect(await read("document.getElementById('responsive').setAttribute('media','print')")).toBe("rgba(255, 0, 0, 1)");
30
+ expect(await read("document.getElementById('typed').setAttribute('type','text/css')")).toBe("rgba(0, 0, 255, 1)");
31
+ });
32
+
33
+ test("linked sheet media can change without refetching and disabled sheets fetch on enable", async () => {
34
+ requests.length = 0;
35
+ await load(`<!doctype html><style>#target{color:red}</style>
36
+ <link id="linked" rel="stylesheet" href="/linked.css" media="print"><div id="target">Text</div>`);
37
+ expect(await read()).toBe("rgba(255, 0, 0, 1)");
38
+ expect(requests).toEqual(["https://style.test/linked.css"]);
39
+ expect(await read("document.getElementById('linked').setAttribute('media','screen')")).toBe("rgba(0, 0, 255, 1)");
40
+ expect(requests.length).toBe(1);
41
+ expect(await read("document.getElementById('linked').setAttribute('disabled','')")).toBe("rgba(255, 0, 0, 1)");
42
+ await load(`<!doctype html><style>#target{color:red}</style>
43
+ <link id="linked" rel="stylesheet" disabled href="/disabled.css"><div id="target">Text</div>`);
44
+ expect(await read()).toBe("rgba(255, 0, 0, 1)");
45
+ expect(requests.length).toBe(1);
46
+ await read("document.getElementById('linked').removeAttribute('disabled')");
47
+ const deadline = Date.now() + 3000;
48
+ while (await read() !== "rgba(0, 0, 255, 1)" && Date.now() < deadline) await Bun.sleep(10);
49
+ expect(await read()).toBe("rgba(0, 0, 255, 1)");
50
+ expect(requests).toEqual(["https://style.test/linked.css", "https://style.test/disabled.css"]);
51
+ });
package/test/svg.test.js CHANGED
@@ -39,6 +39,35 @@ test("parses transform lists into one matrix", () => {
39
39
  expect([a, b, c, d].map((value) => Math.round(value))).toEqual([0, 1, -1, 0]);
40
40
  });
41
41
 
42
+ test("SVG paint follows the CSS cascade, inherited HTML fill and descendant currentColor", () => {
43
+ const { document, window } = parseHTMLDocument(`<main style="fill:green;color:blue"><svg viewBox="0 0 24 24" style="opacity:.5">
44
+ <path id="inherited" d="M0 0h3v3z"/>
45
+ <path id="overridden" fill="red" stroke-width="1" d="M4 0h3v3z"/>
46
+ <g style="color:red;fill:currentColor;opacity:.5"><path d="M8 0h3v3z"/></g>
47
+ <path id="inline" fill="red" style="fill:yellow" d="M12 0h3v3z"/>
48
+ </svg></main>`);
49
+ const styles = new StyleEngine().compute(document, ["#overridden {fill:blue;stroke:currentColor;stroke-width:2;fill-opacity:50%;fill-rule:evenodd}"]);
50
+ const tree = new RenderTreeBuilder().build(document, styles);
51
+ const svg = [...tree.nodesById.values()].find(node => node.tagName === "SVG").svg;
52
+ expect(svg.shapes.map(shape => shape.fill)).toEqual([
53
+ "rgba(0, 128, 0, 1)", "rgba(0, 0, 255, 1)", "rgba(255, 0, 0, 1)", "rgba(255, 255, 0, 1)",
54
+ ]);
55
+ expect(svg.shapes[1]).toMatchObject({ stroke: "rgba(0, 0, 255, 1)", strokeWidth: 2, fillOpacity: .5, fillRule: "evenodd" });
56
+ expect(svg.shapes[0].fillOpacity).toBe(1); // Root opacity is applied by its CSS box, once.
57
+ expect(svg.shapes[2].fillOpacity).toBe(.5);
58
+ window.happyDOM.abort();
59
+ });
60
+
61
+ test("SVG CSS display removes a subtree while visible descendants can override visibility", () => {
62
+ const { document, window } = parseHTMLDocument(`<svg><g class="hidden"><path d="M0 0h3v3z"/><path class="visible" d="M4 0h3v3z"/></g>
63
+ <g class="none"><path class="visible" d="M8 0h3v3z"/></g></svg>`);
64
+ const styles = new StyleEngine().compute(document, [".hidden {visibility:hidden}.visible{visibility:visible}.none{display:none}"]);
65
+ const svg = extractSvg(document.querySelector("svg"), "black", styles);
66
+ expect(svg.shapes).toHaveLength(1);
67
+ expect(svg.shapes[0].d).toBe("M4 0h3v3z");
68
+ window.happyDOM.abort();
69
+ });
70
+
42
71
  test("inline SVG is a replaced box with margins and vertical-align in the line", () => {
43
72
  const { document, window } = parseHTMLDocument(`<body style="margin:0"><p style="margin:0">
44
73
  <svg viewBox="0 0 16 16" width="16" height="16" style="margin-right:8px;vertical-align:text-bottom"><path d="M0 0h16v16z"/></svg>Readme</p></body>`);
@@ -8,6 +8,32 @@ function boxFor(tree, layout, tagName) {
8
8
  return layout.boxes.find((box) => tree.nodesById.get(box.nodeId)?.tagName === tagName);
9
9
  }
10
10
 
11
+ test("display contents removes the wrapper box while preserving inherited text styles", () => {
12
+ const { document, window } = parseHTMLDocument(`<body style="margin:0;font-size:10px;line-height:10px">
13
+ before <div id="wrapper" style="display:contents;padding:50px;border:10px solid;position:absolute;color:red"><span>inside</span></div> after
14
+ </body>`);
15
+ const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
16
+ const layout = layoutText(tree, { x:0, y:0, width:200, measureText:text=>text.length });
17
+ expect(layout.boxes.some(box => tree.nodesById.get(box.nodeId)?.domNode?.id === "wrapper")).toBe(false);
18
+ expect(layout.fragments.length).toBe(1);
19
+ expect(layout.fragments[0].text.trim()).toBe("before inside after");
20
+ expect([...tree.nodesById.values()].find(node => node.type === "text" && node.text === "inside").style.color).toBe("rgba(255, 0, 0, 1)");
21
+ window.happyDOM.abort();
22
+ });
23
+
24
+ test("display contents promotes nested children into flex and grid item placement", () => {
25
+ for (const display of ["flex", "grid"]) {
26
+ const { document, window } = parseHTMLDocument(`<body style="margin:0"><main style="display:${display};grid-template-columns:20px 20px 20px;gap:5px">
27
+ <div style="display:contents"><i style="width:20px;height:10px"></i><span style="display:contents"><i style="width:20px;height:10px"></i></span></div>
28
+ <i style="width:20px;height:10px"></i></main></body>`);
29
+ const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
30
+ const layout = layoutText(tree, { x:0, y:0, width:200 });
31
+ expect(layout.boxes.filter(box => tree.nodesById.get(box.nodeId)?.tagName === "I").map(box => [box.x,box.y,box.width,box.height]))
32
+ .toEqual([[0,0,20,10],[25,0,20,10],[50,0,20,10]]);
33
+ window.happyDOM.abort();
34
+ }
35
+ });
36
+
11
37
  test("percentage and calc padding reserve an aspect-ratio box and reflow on resize", () => {
12
38
  const { document, window } = parseHTMLDocument(`<body style="margin:0">
13
39
  <div id="ratio" style="position:relative;padding-bottom:calc(100% * 9 / 16)">
@@ -339,6 +365,86 @@ function layoutFixture(html, css = "", width = 400) {
339
365
  return { ...parsed, tree, layout, box };
340
366
  }
341
367
 
368
+ test("column flex distributes growth, scaled shrink and constrained free space", () => {
369
+ for (const [children, expected] of [
370
+ ['<i style="flex:1;min-height:0"></i><b style="flex:2;min-height:0"></b>', [100,200]],
371
+ ['<i style="height:200px;min-height:0"></i><b style="height:400px;min-height:0"></b>', [100,200]],
372
+ ['<i style="flex:1;max-height:70px"></i><b style="flex:1"></b>', [70,230]],
373
+ ['<i style="flex:.25"></i><b style="flex:.25"></b>', [75,75]],
374
+ ]) {
375
+ const {window,box} = layoutFixture(`<main>${children}</main>`, 'main{display:flex;flex-direction:column;height:300px}');
376
+ expect([box('i').height,box('b').height]).toEqual(expected);
377
+ expect(box('b').y).toBe(expected[0]);
378
+ window.happyDOM.abort();
379
+ }
380
+ });
381
+
382
+ test("column flex automatic minimum protects content, while scrollable items can shrink", () => {
383
+ for (const [overflow,height] of [["visible",100],["auto",80],["hidden",80]]) {
384
+ const {window,box} = layoutFixture('<main><section><i></i></section></main>',
385
+ `main{display:flex;flex-direction:column;height:80px}section{overflow:${overflow}}i{display:block;height:100px}`);
386
+ expect(box('section').height).toBe(height);
387
+ window.happyDOM.abort();
388
+ }
389
+ });
390
+
391
+ test("an omitted flex basis uses content in an auto-height column capped by max-height", () => {
392
+ for (const [flex,expected] of [["1",250],["1 1",250],["1 1 0",0],["1 1 0%",250]]) {
393
+ const {window,box} = layoutFixture('<main><header></header><section><i></i></section></main>',
394
+ `main{display:flex;flex-direction:column;max-height:300px}header{height:50px;flex:none}
395
+ section{flex:${flex};min-height:0;overflow-y:auto}i{display:block;height:800px}`);
396
+ expect(box('section').height).toBe(expected);
397
+ expect(box('main').height).toBe(expected+50);
398
+ window.happyDOM.abort();
399
+ }
400
+ });
401
+
402
+ test("column auto margins absorb free space before justify-content", () => {
403
+ const {window,box} = layoutFixture('<main><i></i><b></b></main>',
404
+ 'main{display:flex;flex-direction:column;height:300px;justify-content:space-between}i{height:40px;margin-top:auto}b{height:60px;margin-bottom:auto}');
405
+ expect([box('i').y,box('b').y]).toEqual([100,140]);
406
+ window.happyDOM.abort();
407
+ });
408
+
409
+ test("stretched and inset-sized flex columns center within their final used height", () => {
410
+ for (const css of [
411
+ 'main{display:flex;height:300px}section{display:flex;flex-direction:column;justify-content:center}',
412
+ 'main{position:relative;height:300px}section{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center}',
413
+ ]) {
414
+ const {window,box} = layoutFixture('<main><section><i></i></section></main>',css+'i{display:block;height:60px;width:30px}');
415
+ expect(box('section').height).toBe(300);
416
+ expect(box('i').y).toBe(120);
417
+ window.happyDOM.abort();
418
+ }
419
+ });
420
+
421
+ test("nested column flex growth fills the viewport-sized modal wrapper", () => {
422
+ const {window,box} = layoutFixture('<main><section><article><i></i></article></section></main>',
423
+ 'main{display:flex;flex-direction:column;height:800px}section{display:flex;flex-direction:column;flex-grow:1;justify-content:center}article{display:flex;justify-content:center}i{height:300px;width:200px}');
424
+ expect(box('section').height).toBe(800);
425
+ expect(box('i')).toMatchObject({x:100,y:250,width:200,height:300});
426
+ window.happyDOM.abort();
427
+ });
428
+
429
+ test("percentage HTML image dimensions size flex items within their container", () => {
430
+ const {window,box,layout} = layoutFixture('<main><img width="100%" height="100%"></main>',
431
+ 'main{display:flex;width:84px;height:84px}');
432
+ expect(box('img')).toMatchObject({width:84,height:84});
433
+ const image=layout.fragments.flatMap(fragment=>fragment.runs).find(run=>run.type==='image');
434
+ expect(image).toMatchObject({width:84,height:84});
435
+ window.happyDOM.abort();
436
+ });
437
+
438
+ test("author auto sizing overrides HTML dimension hints and uses the image's intrinsic size", () => {
439
+ const {document,window}=parseHTMLDocument('<body style="margin:0"><img width="30" height="20" style="width:auto;height:auto"></body>');
440
+ const element=document.querySelector('img'),resources=new Map([[element,{width:120,height:60}]]);
441
+ const styles=new StyleEngine().compute(document);
442
+ const tree=new RenderTreeBuilder().build(document,styles,resources);
443
+ const layout=layoutText(tree,{width:400,lineHeight:20,measureText:text=>text.length*10,fontMetrics:()=>({ascent:16,height:20})});
444
+ expect(layout.fragments.flatMap(fragment=>fragment.runs).find(run=>run.type==='image')).toMatchObject({width:120,height:60});
445
+ window.happyDOM.abort();
446
+ });
447
+
342
448
  test("nests block boxes and includes ancestor padding and borders", () => {
343
449
  const { window, layout, box } = layoutFixture(
344
450
  `<section><div>text</div></section>`,
@@ -0,0 +1,61 @@
1
+ import { expect, test } from 'bun:test';
2
+ import wasmpeg from '../vendor/wasmpeg/src/js/index.js';
3
+ import { seekVideoKeyframe } from '../lib/media/video-keyframe.js';
4
+
5
+ test('seeking within a GOP longer than ten seconds preserves the decoded frame timestamp', async () => {
6
+ await wasmpeg.load();
7
+ const bytes = new Uint8Array(await Bun.file(new URL('./fixtures/long-video.mp4', import.meta.url)).arrayBuffer());
8
+ const decoder = await wasmpeg.decode(bytes);
9
+ try {
10
+ // This thirty-second fixture has a different picture at every frame,
11
+ // but only its first frame is a keyframe.
12
+ const firstHash = Bun.hash(decoder.nextFrame(32, 18));
13
+ for (const target of [15, 20, 25, 29]) {
14
+ const timestamp = seekVideoKeyframe(decoder, target);
15
+ expect(Bun.hash(decoder.nextFrame(32, 18))).toBe(firstHash);
16
+ expect(timestamp).toBe(0);
17
+ }
18
+ } finally { decoder.close(); }
19
+ });
20
+
21
+ test('a later MP4 initialization keeps absolute seek times, including seeks back across its first GOP', async () => {
22
+ await wasmpeg.load();
23
+ const bytes = new Uint8Array(await Bun.file(new URL('./fixtures/mse-switch-large.mp4', import.meta.url)).arrayBuffer());
24
+ const view = new DataView(bytes.buffer);
25
+ const visit = (from, to, callback) => {
26
+ for (let offset = from; offset + 8 <= to;) {
27
+ const size = view.getUint32(offset);
28
+ if (size < 8 || offset + size > to) break;
29
+ const type = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8));
30
+ callback(type, offset + 8);
31
+ if (['moov', 'trak', 'mdia', 'moof', 'traf'].includes(type)) visit(offset + 8, offset + size, callback);
32
+ offset += size;
33
+ }
34
+ };
35
+ let timescale;
36
+ visit(0, bytes.length, (type, data) => {
37
+ if (type === 'mdhd') timescale = view.getUint32(data + (bytes[data] === 1 ? 20 : 12));
38
+ });
39
+ let fragments = 0;
40
+ visit(0, bytes.length, (type, data) => {
41
+ if (type !== 'tfdt') return;
42
+ fragments++;
43
+ if (bytes[data] === 1) view.setBigUint64(data + 4, view.getBigUint64(data + 4) + BigInt(6 * timescale));
44
+ else view.setUint32(data + 4, view.getUint32(data + 4) + 6 * timescale);
45
+ });
46
+ expect(fragments).toBeGreaterThan(0);
47
+ const decoder = await wasmpeg.decode(bytes, { format: 'mp4' });
48
+ try {
49
+ const hashes = [];
50
+ for (let pixels; (pixels = decoder.nextFrame(32, 18));) hashes.push(Bun.hash(pixels));
51
+ expect(hashes).toHaveLength(60);
52
+ // The fixture's keyframes are 2s apart. After moving tfdt by six seconds,
53
+ // seek(8s) must return frame 20, not frame 0 or an offset-relative frame.
54
+ for (const [target, frame, expectedTime] of [[6, 0, 6], [7.9, 0, 6], [8, 20, 8], [11, 40, 10], [6.2, 0, 6], [5, 0, 6]]) {
55
+ const timestamp = seekVideoKeyframe(decoder, target, 6);
56
+ expect(Bun.hash(decoder.nextFrame(32, 18))).toBe(hashes[frame]);
57
+ expect(timestamp).toBeGreaterThanOrEqual(expectedTime);
58
+ expect(timestamp).toBeLessThan(expectedTime + 1 / decoder.fps);
59
+ }
60
+ } finally { decoder.close(); }
61
+ });
@@ -54,6 +54,75 @@ test("reopening at a keyframe does not show the prefix before the resume positio
54
54
  } finally { video.stop(); }
55
55
  });
56
56
 
57
+ test("a presentation keeps absolute epoch times and ends before replaced frames", async () => {
58
+ let at = 0;
59
+ const seeks = [], reports = [];
60
+ const video = new VideoPresentation({ decode: async () => ({ width: 2, height: 2, fps: 10,
61
+ seek(ms) { seeks.push(ms); at = 0; },
62
+ nextFrame(w, h) { return new Uint8Array(w * h * 4).fill(++at); }, close() {} }) },
63
+ () => {}, report => reports.push(report));
64
+ const tick = seconds => {
65
+ clearTimeout(video.timer);
66
+ video.timing(9, seconds - 12);
67
+ video.step();
68
+ };
69
+ try {
70
+ video.timing(9, 0.2);
71
+ await video.open({ id: 3, time: 12.2, startTime: 12, endTime: 12.5,
72
+ hasAudio: true, audioId: 9, audioStart: 12 });
73
+ expect(Math.min(...seeks)).toBe(12000);
74
+ expect(video.frame.time).toBeCloseTo(12.2);
75
+ expect(reports.some(report => report.end !== undefined)).toBe(false);
76
+ tick(12.49);
77
+ expect(video.frame.time).toBeCloseTo(12.4);
78
+ expect(reports.some(report => report.end !== undefined)).toBe(false);
79
+ tick(12.5);
80
+ tick(13);
81
+ expect(reports.filter(report => report.end !== undefined)).toEqual([{ id: 3, end: 12.5 }]);
82
+ expect(reports.filter(report => report.time !== undefined).every(report => report.time >= 12.2 && report.time < 12.5)).toBe(true);
83
+ } finally { video.stop(); }
84
+ });
85
+
86
+ test("a short epoch snapshot reports its actual EOF so playback can request more bytes", async () => {
87
+ let at = 0;
88
+ const reports = [];
89
+ const video = new VideoPresentation({ decode: async () => ({ width: 2, height: 2, fps: 10,
90
+ nextFrame(w, h) { return ++at <= 2 ? new Uint8Array(w * h * 4).fill(at) : null; }, close() {} }) },
91
+ () => {}, report => reports.push(report));
92
+ try {
93
+ video.timing(10, 0.25);
94
+ await video.open({ id: 4, time: 40, startTime: 40, endTime: 45,
95
+ hasAudio: true, audioId: 10, audioStart: 40 });
96
+ expect(video.frame.time).toBeCloseTo(40.1);
97
+ expect(reports.find(report => report.end !== undefined)?.end).toBeCloseTo(40.2);
98
+ } finally { video.stop(); }
99
+ });
100
+
101
+ test("a later epoch discards queued old frames at its boundary without accepting stale bindings", async () => {
102
+ const reports = [];
103
+ const video = new VideoPresentation({ decode: async () => ({ width: 2, height: 2, fps: 10,
104
+ nextFrame(w, h) { return new Uint8Array(w * h * 4); }, close() {} }) },
105
+ () => {}, report => reports.push(report));
106
+ try {
107
+ video.timing(11, 0);
108
+ await video.open({ id: 5, time: 10, startTime: 10,
109
+ hasAudio: true, audioId: 11, audioStart: 10 });
110
+ expect(video.queue.some(frame => frame.time > 10.3)).toBe(true);
111
+ video.bind(4, 11, 10, 10, 10.1);
112
+ expect(video.config.endTime).toBeUndefined();
113
+ video.bind(5, undefined, undefined, undefined, 10.3);
114
+ expect(video.config.audioId).toBe(11);
115
+ expect(video.config.audioStart).toBe(10);
116
+ expect(video.config.time).toBe(10);
117
+ clearTimeout(video.timer);
118
+ video.timing(11, 0.3);
119
+ video.step();
120
+ expect(video.frame.time).toBeCloseTo(10.2);
121
+ expect(reports.find(report => report.end !== undefined)).toEqual({ id: 5, end: 10.3 });
122
+ expect(video.queue).toHaveLength(0);
123
+ } finally { video.stop(); }
124
+ });
125
+
57
126
  test("video layer preserves opaque controls, translucent overlays and clipped pixels", () => {
58
127
  const base = new Uint8Array([0,0,0,255, 255,255,255,255, 0,0,128,255, 10,20,30,255]);
59
128
  const mask = new Uint8Array([255,255,255,0, 0,0,0,0, 127,127,127,0, 0,0,0,0]);