buninu-browser 0.2.2 → 0.2.3
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/ai-debug.log +5537 -0
- package/lib/audio/playback-buffer.js +27 -0
- package/lib/audio/pulse-client.js +18 -8
- package/lib/cdp-server.js +6 -0
- package/lib/happy-dom/parser.js +5 -0
- package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
- package/lib/headless-shell.js +3 -0
- package/lib/layout/text-layout.js +76 -40
- package/lib/media/video-keyframe.js +29 -0
- package/lib/paint/display-list.js +20 -5
- package/lib/render-tree/index.js +2 -0
- package/lib/renderer/bindings.js +11 -2
- package/lib/renderer/compositor.js +16 -1
- package/lib/renderer/custom-element-reactions.js +18 -0
- package/lib/renderer/history.js +42 -0
- package/lib/renderer/host.js +8 -1
- package/lib/renderer/image-loading.js +27 -0
- package/lib/renderer/keyboard-input.js +12 -0
- package/lib/renderer/main.js +3 -1
- package/lib/renderer/media-playback-events.js +13 -0
- package/lib/renderer/page-realm.js +9 -50
- package/lib/renderer/page-renderer.js +206 -79
- package/lib/renderer/piano-input.js +22 -0
- package/lib/renderer/pointer-move.js +29 -0
- package/lib/renderer/video-presentation.js +126 -0
- package/lib/style/computed-style.js +24 -7
- package/package.json +1 -1
- package/test/compositor.test.js +38 -0
- package/test/computed-style.test.js +21 -0
- package/test/display-list.test.js +14 -0
- package/test/download.test.js +8 -0
- package/test/headless-shell.test.js +22 -0
- package/test/media-playback-events.test.js +22 -0
- package/test/page-scripts.test.js +159 -1
- package/test/piano-input.test.js +44 -0
- package/test/playback-buffer.test.js +31 -0
- package/test/pulse-client.test.js +17 -2
- package/test/text-layout.test.js +62 -0
- package/test/video-presentation.test.js +67 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { handlePianoInput } from "../lib/renderer/piano-input.js";
|
|
3
|
+
|
|
4
|
+
const input = { getAttribute: () => "buninu-piano-input" };
|
|
5
|
+
|
|
6
|
+
test("CDP raw keydown uses the physical key even with an empty text field", () => {
|
|
7
|
+
const notes = [];
|
|
8
|
+
const play = n => notes.push(n);
|
|
9
|
+
for (let i = 0; i < 3; i++) {
|
|
10
|
+
expect(handlePianoInput(input, { key: " ", code: "Space", text: "", modifiers: 0, repeat: false }, play)).toBe(true);
|
|
11
|
+
}
|
|
12
|
+
expect(notes).toEqual([69, 69, 69]);
|
|
13
|
+
expect(handlePianoInput(input, { key: "F1", code: "F1", text: "" }, play)).toBe(true);
|
|
14
|
+
expect(notes.at(-1)).toBe(38);
|
|
15
|
+
expect(handlePianoInput(input, { key: " ", code: "Space", text: " ", repeat: true }, play)).toBe(true);
|
|
16
|
+
expect(notes).toHaveLength(4);
|
|
17
|
+
expect(handlePianoInput(input, { key: "Tab", code: "Tab", text: "" }, play)).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("piano consumes batched terminal/IME text and plays every mapped note", () => {
|
|
21
|
+
const notes = [];
|
|
22
|
+
expect(handlePianoInput(input, { text: "jk L!中" }, n => notes.push(n))).toBe(true);
|
|
23
|
+
expect(notes).toEqual([71, 73, 69, 74, 45]);
|
|
24
|
+
expect(handlePianoInput(input, { text: "jk" }, null)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("piano consumes repeats without retriggering and supports shifted physical keys", () => {
|
|
28
|
+
const notes = [];
|
|
29
|
+
const play = n => notes.push(n);
|
|
30
|
+
expect(handlePianoInput(input, { key: "j", repeat: true }, play)).toBe(true);
|
|
31
|
+
expect(handlePianoInput(input, { key: " ", repeat: true }, play)).toBe(true);
|
|
32
|
+
expect(handlePianoInput(input, { key: "J", code: "KeyJ", modifiers: 8 }, play)).toBe(true);
|
|
33
|
+
expect(handlePianoInput(input, { key: "j", modifiers: 2 }, play)).toBe(true);
|
|
34
|
+
expect(notes).toEqual([71]);
|
|
35
|
+
expect(handlePianoInput(input, { key: "Tab" }, play)).toBe(false);
|
|
36
|
+
expect(handlePianoInput(input, { key: "Backspace" }, play)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("ordinary, disabled and readonly editors are unaffected", () => {
|
|
40
|
+
expect(handlePianoInput({ getAttribute: () => "editor" }, { text: "jk" })).toBe(false);
|
|
41
|
+
expect(handlePianoInput({ ...input, disabled: true }, { text: "jk" })).toBe(false);
|
|
42
|
+
expect(handlePianoInput({ ...input, readOnly: true }, { text: "jk" })).toBe(false);
|
|
43
|
+
expect(handlePianoInput(null, { text: "jk" })).toBe(false);
|
|
44
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { PlaybackBuffer } from "../lib/audio/playback-buffer.js";
|
|
3
|
+
|
|
4
|
+
test("media prepares a bounded reserve regardless of server credits", () => {
|
|
5
|
+
const buffer = new PlaybackBuffer(1000, 2, 0.5);
|
|
6
|
+
expect(buffer.request(0, 0, 0, 0)).toBe(2000);
|
|
7
|
+
expect(buffer.request(0, 2000, 0, 500)).toBe(0);
|
|
8
|
+
expect(buffer.request(1500, 0, 500, 0)).toBe(0);
|
|
9
|
+
expect(buffer.request(1100, 0, 500, 40)).toBe(400);
|
|
10
|
+
expect(buffer.request(1100, 400, 500, 40)).toBe(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("startup and starvation recovery require playable PCM, not elapsed time", () => {
|
|
14
|
+
const buffer = new PlaybackBuffer(1000, 2, 0.5);
|
|
15
|
+
expect(buffer.ready(40, 0, false)).toBe(false);
|
|
16
|
+
expect(buffer.ready(398, 0, false)).toBe(false);
|
|
17
|
+
expect(buffer.ready(400, 0, false)).toBe(true);
|
|
18
|
+
expect(buffer.ready(40, 300, false)).toBe(true);
|
|
19
|
+
expect(buffer.ready(0, 0, false)).toBe(false);
|
|
20
|
+
expect(buffer.ready(40, 0, false)).toBe(false);
|
|
21
|
+
expect(buffer.ready(400, 0, false)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("short clips drain and piano stays interactive", () => {
|
|
25
|
+
const media = new PlaybackBuffer(1000, 2, 0.5);
|
|
26
|
+
expect(media.ready(40, 0, true)).toBe(true);
|
|
27
|
+
const piano = new PlaybackBuffer(1000, 2, 0.12);
|
|
28
|
+
expect(piano.ready(40, 0, false)).toBe(true);
|
|
29
|
+
expect(piano.request(0, 0, 0, 120)).toBe(120);
|
|
30
|
+
expect(piano.request(0, 120, 0, 120)).toBe(0);
|
|
31
|
+
});
|
|
@@ -134,7 +134,7 @@ function header(length, channel) {
|
|
|
134
134
|
test("measured device progress governs the media clock and bounded audio queue through starvation", async () => {
|
|
135
135
|
const { createServer } = await import("node:net");
|
|
136
136
|
let received = 0, played = 0, reports = 0;
|
|
137
|
-
const times = [], errors = [];
|
|
137
|
+
const times = [], errors = [], requests = [];
|
|
138
138
|
const scalar64 = (tag, value) => {
|
|
139
139
|
const bytes = Buffer.alloc(9);
|
|
140
140
|
bytes[0] = tag;
|
|
@@ -171,11 +171,17 @@ test("measured device progress governs the media clock and bounded audio queue t
|
|
|
171
171
|
const client = await PulseClient.connect({ port: server.address().port });
|
|
172
172
|
try {
|
|
173
173
|
const stream = await client.openPlayback({ rate: 500, channels: 1,
|
|
174
|
+
onRequest: bytes => requests.push(bytes),
|
|
174
175
|
onTiming: timing => times.push(timing.seconds), onTimingError: error => errors.push(error) });
|
|
175
|
-
|
|
176
|
+
expect(requests).toEqual([2000]); // controller reserve, not the device queue
|
|
177
|
+
stream.write(new Uint8Array(100));
|
|
178
|
+
await Bun.sleep(80);
|
|
179
|
+
expect(received).toBe(0); // a short prefix does not start/stop playback
|
|
180
|
+
stream.write(new Uint8Array(1900));
|
|
176
181
|
await Bun.sleep(180);
|
|
177
182
|
expect(reports).toBeGreaterThan(0);
|
|
178
183
|
expect(received).toBe(500); // at most the 0.5 s queue budget, not 2 s
|
|
184
|
+
expect(stream.queued).toBe(1500); // survives a renderer busy spell in the controller
|
|
179
185
|
expect(times.at(-1)).toBe(0); // server/device hasn't played anything
|
|
180
186
|
played = 100;
|
|
181
187
|
await Bun.sleep(120);
|
|
@@ -190,8 +196,17 @@ test("measured device progress governs the media clock and bounded audio queue t
|
|
|
190
196
|
expect(times.at(-1)).toBeCloseTo(0.6, 5);
|
|
191
197
|
expect(received).toBeGreaterThan(1000);
|
|
192
198
|
expect(received).toBeLessThanOrEqual(1100);
|
|
199
|
+
// No more PCM is provided by the producer: continue from the local reserve.
|
|
200
|
+
for (let i = 0; i < 4; i++) {
|
|
201
|
+
played = received;
|
|
202
|
+
await Bun.sleep(150);
|
|
203
|
+
}
|
|
204
|
+
expect(received).toBeGreaterThan(1500);
|
|
205
|
+
expect(received).toBeLessThanOrEqual(2000);
|
|
206
|
+
expect(requests.reduce((sum, value) => sum + value, 0)).toBeLessThanOrEqual(4000);
|
|
193
207
|
expect(errors).toEqual([]);
|
|
194
208
|
await stream.close();
|
|
209
|
+
expect(stream.queued).toBe(0); // pause/seek discards reserve; no extra device tail
|
|
195
210
|
} finally {
|
|
196
211
|
client.close();
|
|
197
212
|
await new Promise(resolve => server.close(resolve));
|
package/test/text-layout.test.js
CHANGED
|
@@ -8,6 +8,68 @@ 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("percentage and calc padding reserve an aspect-ratio box and reflow on resize", () => {
|
|
12
|
+
const { document, window } = parseHTMLDocument(`<body style="margin:0">
|
|
13
|
+
<div id="ratio" style="position:relative;padding-bottom:calc(100% * 9 / 16)">
|
|
14
|
+
<div id="fill" style="position:absolute;inset:0"></div>
|
|
15
|
+
</div><div id="next" style="height:20px"></div>
|
|
16
|
+
</body>`);
|
|
17
|
+
const styles = new StyleEngine().compute(document);
|
|
18
|
+
const tree = new RenderTreeBuilder().build(document, styles);
|
|
19
|
+
for (const width of [320, 640]) {
|
|
20
|
+
const layout = layoutText(tree, { x:0, y:0, width });
|
|
21
|
+
const rect = id => layout.boxes.find(box => tree.nodesById.get(box.nodeId)?.domNode?.id === id);
|
|
22
|
+
expect(rect('ratio').height).toBe(width * 9 / 16);
|
|
23
|
+
expect(rect('fill').height).toBe(width * 9 / 16);
|
|
24
|
+
expect(rect('next').y).toBe(width * 9 / 16);
|
|
25
|
+
}
|
|
26
|
+
window.happyDOM.abort();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("nested absolute percentage heights use their own containing block, including on resize", () => {
|
|
30
|
+
const { document, window } = parseHTMLDocument(`<body style="margin:0">
|
|
31
|
+
<div style="position:relative;height:500px"><div style="position:absolute;bottom:0;width:100%;height:60px">
|
|
32
|
+
<div id="track" style="position:absolute;bottom:56px;width:100%;height:6px">
|
|
33
|
+
<div id="fill" style="position:absolute;bottom:0;width:100%;height:100%"></div>
|
|
34
|
+
</div>
|
|
35
|
+
</div></div></body>`);
|
|
36
|
+
const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
|
|
37
|
+
const engine = new LayoutEngine();
|
|
38
|
+
for (const width of [320, 640]) {
|
|
39
|
+
const layout = engine.layout(tree, { x:0, y:0, width });
|
|
40
|
+
const rect = id => layout.boxes.find(box => tree.nodesById.get(box.nodeId)?.domNode?.id === id);
|
|
41
|
+
expect(rect('fill').height).toBe(6);
|
|
42
|
+
expect(rect('fill').y).toBe(rect('track').y);
|
|
43
|
+
expect(rect('fill').width).toBe(width);
|
|
44
|
+
}
|
|
45
|
+
window.happyDOM.abort();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("legacy vertical line clamping limits lines and adds an ellipsis without changing DOM text", () => {
|
|
49
|
+
for (const display of ["-webkit-box", "block"]) {
|
|
50
|
+
const { document, window } = parseHTMLDocument(`<body style="margin:0"><div style="display:${display};-webkit-box-orient:vertical;-webkit-line-clamp:2;font-size:10px;line-height:10px;width:40px;overflow:hidden;overflow-wrap:anywhere">abcdefghijklmnop</div></body>`);
|
|
51
|
+
const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
|
|
52
|
+
const layout = layoutText(tree, { x:0, y:0, width:200, measureText:text=>text.length*10 });
|
|
53
|
+
expect(layout.fragments.length).toBe(display === "block" ? 4 : 2);
|
|
54
|
+
const painted = layout.fragments.flatMap(f=>f.runs).map(r=>r.text).join('');
|
|
55
|
+
expect(painted).toBe(display === "block" ? "abcdefghijklmnop" : "abcdefg…");
|
|
56
|
+
expect(document.querySelector('div').textContent).toBe("abcdefghijklmnop");
|
|
57
|
+
window.happyDOM.abort();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("empty blocks do not add adjoining vertical margins twice", () => {
|
|
62
|
+
const { document, window } = parseHTMLDocument(`<body style="margin:0"><div style="padding-top:1px">
|
|
63
|
+
<div style="height:20px;margin-bottom:12px"></div>
|
|
64
|
+
<div style="margin-top:12px"></div><div></div>
|
|
65
|
+
<div id="next" style="height:20px;margin-top:12px"></div>
|
|
66
|
+
</div></body>`);
|
|
67
|
+
const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
|
|
68
|
+
const layout = layoutText(tree, { x:0, y:0, width:200 });
|
|
69
|
+
expect(layout.boxes.find(box => tree.nodesById.get(box.nodeId)?.domNode?.id === 'next').y).toBe(33);
|
|
70
|
+
window.happyDOM.abort();
|
|
71
|
+
});
|
|
72
|
+
|
|
11
73
|
test("creates positioned immutable line fragments with fixed-column wrapping", () => {
|
|
12
74
|
const { document, window } = parseHTMLDocument(
|
|
13
75
|
`<body style="margin:0">1234567890ABCDEFGHIJ<br>中文😀abcdef</body>`,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { VideoPresentation, compositeVideo } from "../lib/renderer/video-presentation.js";
|
|
3
|
+
|
|
4
|
+
test("video predecode is bounded and waits for measured audio, skipping stale frames", async () => {
|
|
5
|
+
let frames = 0, closed = 0;
|
|
6
|
+
const reports = [];
|
|
7
|
+
const engine = { decode: async () => ({ width: 2, height: 2, fps: 30,
|
|
8
|
+
nextFrame: () => { frames++; return new Uint8Array(16).fill(frames); }, close: () => closed++ }) };
|
|
9
|
+
const video = new VideoPresentation(engine, () => {}, r => reports.push(r));
|
|
10
|
+
try {
|
|
11
|
+
await video.open({ id: 1, time: 0, hasAudio: true, audioId: 4, audioStart: 0 });
|
|
12
|
+
await Bun.sleep(100);
|
|
13
|
+
expect(frames).toBeLessThanOrEqual(24);
|
|
14
|
+
expect(video.queue.length).toBeLessThanOrEqual(video.limit);
|
|
15
|
+
expect(video.frame.time).toBe(0);
|
|
16
|
+
const first = reports.length;
|
|
17
|
+
await Bun.sleep(80);
|
|
18
|
+
expect(reports).toHaveLength(first);
|
|
19
|
+
video.timing(4, 0.5);
|
|
20
|
+
await Bun.sleep(60);
|
|
21
|
+
expect(video.frame.time).toBeCloseTo(0.5);
|
|
22
|
+
expect(reports.filter(r => r.time > 0).length).toBe(1);
|
|
23
|
+
video.stop(1);
|
|
24
|
+
video.timing(4, 2);
|
|
25
|
+
await Bun.sleep(30);
|
|
26
|
+
expect(video.frame).toBeNull();
|
|
27
|
+
expect(closed).toBe(1);
|
|
28
|
+
} finally { video.stop(); }
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a late decoder open cannot replace a newer playback generation", async () => {
|
|
32
|
+
let resolve, closed = 0;
|
|
33
|
+
const video = new VideoPresentation({ decode: () => new Promise(r => resolve = r) }, () => {}, () => {});
|
|
34
|
+
const opening = video.open({ id: 1, time: 0 });
|
|
35
|
+
video.stop();
|
|
36
|
+
resolve({ close: () => closed++ });
|
|
37
|
+
await opening;
|
|
38
|
+
expect(closed).toBe(1);
|
|
39
|
+
expect(video.config).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("reopening at a keyframe does not show the prefix before the resume position", async () => {
|
|
43
|
+
let at = 0;
|
|
44
|
+
const reports = [];
|
|
45
|
+
const video = new VideoPresentation({ decode: async () => ({ width: 2, height: 2, fps: 10,
|
|
46
|
+
seek() { at = 0; }, nextFrame(w, h) { at++; return new Uint8Array(w * h * 4).fill(at); }, close() {} }) },
|
|
47
|
+
() => {}, report => reports.push(report));
|
|
48
|
+
try {
|
|
49
|
+
video.timing(9, 2);
|
|
50
|
+
await video.open({ id: 2, time: 2, hasAudio: true, audioId: 9, audioStart: 0 });
|
|
51
|
+
await Bun.sleep(100);
|
|
52
|
+
expect(reports.some(report => report.time >= 1.9)).toBe(true);
|
|
53
|
+
expect(reports.every(report => report.time === undefined || report.time >= 1.9)).toBe(true);
|
|
54
|
+
} finally { video.stop(); }
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("video layer preserves opaque controls, translucent overlays and clipped pixels", () => {
|
|
58
|
+
const base = new Uint8Array([0,0,0,255, 255,255,255,255, 0,0,128,255, 10,20,30,255]);
|
|
59
|
+
const mask = new Uint8Array([255,255,255,0, 0,0,0,0, 127,127,127,0, 0,0,0,0]);
|
|
60
|
+
const tile = { pixelWidth: 4, pixelHeight: 1, pixels: base,
|
|
61
|
+
videoLayer: { base, mask, rect: { x: 0, y: 0, width: 4, height: 1 } } };
|
|
62
|
+
const frame = { width: 1, height: 1, pixels: new Uint8Array([200,100,0,255]) };
|
|
63
|
+
expect([...compositeVideo(tile, frame).pixels]).toEqual([
|
|
64
|
+
200,100,0,255, 255,255,255,255, 100,50,128,255, 10,20,30,255,
|
|
65
|
+
]);
|
|
66
|
+
expect(compositeVideo(tile, null)).toBe(tile);
|
|
67
|
+
});
|