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.
- package/lib/cdp-server.js +47 -14
- package/lib/happy-dom/document-base.js +33 -0
- package/lib/happy-dom/parser.js +15 -6
- package/lib/happy-dom/selector-matcher.js +2 -2
- package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
- package/lib/happy-dom/svg-attribute-adapter.js +50 -0
- package/lib/headless-shell.js +9 -6
- package/lib/input/hit-test.js +67 -19
- package/lib/layout/text-layout.js +213 -91
- package/lib/media/byte-pipe.js +2 -2
- package/lib/media/position-audio.js +10 -1
- package/lib/media/source-snapshots.js +126 -0
- package/lib/media/video-keyframe.js +17 -4
- package/lib/media/webm-stream.js +106 -0
- package/lib/network/network-service.js +268 -120
- package/lib/paint/display-list.js +8 -28
- package/lib/paint/frame-cache.js +25 -0
- package/lib/paint/frame.js +42 -13
- package/lib/paint/paragraph-text.js +24 -0
- package/lib/paint/raster-readback.js +41 -0
- package/lib/paint/rounded-rect.js +82 -0
- package/lib/paint/shared-frame-pool.js +105 -0
- package/lib/render-tree/index.js +38 -6
- package/lib/render-tree/svg.js +15 -7
- package/lib/renderer/bindings.js +233 -80
- package/lib/renderer/broadcast-channel.js +76 -0
- package/lib/renderer/canvas-2d.js +257 -14
- package/lib/renderer/canvas-text.js +120 -0
- package/lib/renderer/compositor.js +13 -11
- package/lib/renderer/custom-element-reactions.js +25 -1
- package/lib/renderer/font-loading.js +227 -0
- package/lib/renderer/host.js +28 -4
- package/lib/renderer/image-resources.js +65 -0
- package/lib/renderer/interfaces.js +28 -0
- package/lib/renderer/layout-observers.js +236 -0
- package/lib/renderer/main.js +30 -4
- package/lib/renderer/media-source.js +126 -55
- package/lib/renderer/page-realm.js +270 -98
- package/lib/renderer/page-renderer.js +647 -235
- package/lib/renderer/prepared-script-cache.js +50 -0
- package/lib/renderer/rendered-text.js +108 -0
- package/lib/renderer/rewrite-worker.js +4 -3
- package/lib/renderer/script-rewrite.js +13 -2
- package/lib/renderer/scroll-into-view.js +28 -0
- package/lib/renderer/slot-assignment.js +60 -0
- package/lib/renderer/video-presentation.js +23 -4
- package/lib/renderer/workers.js +2 -2
- package/lib/style/computed-style.js +611 -104
- package/lib/style/selector-traits.js +49 -5
- package/package.json +1 -1
- package/test/broadcast-channel.test.js +26 -0
- package/test/canvas-2d.test.js +83 -2
- package/test/canvas-paint.test.js +9 -0
- package/test/canvas-text.test.js +41 -0
- package/test/cdp-stream.test.js +56 -0
- package/test/computed-style-binding.test.js +151 -0
- package/test/computed-style.test.js +258 -1
- package/test/display-list.test.js +12 -0
- package/test/document-base.test.js +87 -0
- package/test/dom-platform.test.js +461 -0
- package/test/fetch-lifecycle.test.js +161 -0
- package/test/fixtures/canvas-paint.js +117 -0
- package/test/fixtures/canvas-text.js +42 -0
- package/test/fixtures/image-lifetime.js +90 -0
- package/test/fixtures/media-seek-snapshot.js +77 -0
- package/test/fixtures/mse-audio-chirp.webm +0 -0
- package/test/fixtures/mse-switch-large.mp4 +0 -0
- package/test/fixtures/mse-switch-small.mp4 +0 -0
- package/test/fixtures/renderer-memory.js +110 -0
- package/test/fixtures/summary-style.js +28 -0
- package/test/font-loading.test.js +97 -0
- package/test/grid.test.js +58 -0
- package/test/hit-test.test.js +21 -1
- package/test/image-lifetime.test.js +12 -0
- package/test/image-resources.test.js +94 -0
- package/test/local-media.test.js +84 -0
- package/test/media-seek-snapshot.test.js +11 -0
- package/test/media-source-config.test.js +98 -0
- package/test/media-source-webm-accounting.test.js +98 -0
- package/test/media-source-webm.test.js +108 -0
- package/test/navigation.test.js +1 -0
- package/test/network-service.test.js +388 -0
- package/test/page-scripts.test.js +1 -1
- package/test/paint-frame.test.js +103 -0
- package/test/paint-paragraph-cache.test.js +87 -0
- package/test/position-audio.test.js +24 -0
- package/test/positioning.test.js +22 -0
- package/test/prepared-script-cache.test.js +45 -0
- package/test/pulse-client.test.js +10 -3
- package/test/raster-readback.test.js +40 -0
- package/test/render-tree.test.js +28 -0
- package/test/renderer-memory.test.js +11 -0
- package/test/selector-traits.test.js +24 -0
- package/test/shared-frame-pool.test.js +63 -0
- package/test/style-sheets.test.js +51 -0
- package/test/svg.test.js +29 -0
- package/test/text-layout.test.js +106 -0
- package/test/video-keyframe.test.js +61 -0
- package/test/video-presentation.test.js +69 -0
- package/tools/browser-lab.js +78 -0
- package/tools/browser-soak.js +75 -0
- package/tools/network-reference.js +49 -0
- package/tools/process-memory.js +38 -0
- package/tools/script-benchmark.js +25 -0
- package/ai-debug.log +0 -5537
package/test/positioning.test.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
+
});
|
package/test/render-tree.test.js
CHANGED
|
@@ -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>`);
|
package/test/text-layout.test.js
CHANGED
|
@@ -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]);
|