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.
Files changed (39) hide show
  1. package/ai-debug.log +5537 -0
  2. package/lib/audio/playback-buffer.js +27 -0
  3. package/lib/audio/pulse-client.js +18 -8
  4. package/lib/cdp-server.js +6 -0
  5. package/lib/happy-dom/parser.js +5 -0
  6. package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
  7. package/lib/headless-shell.js +3 -0
  8. package/lib/layout/text-layout.js +76 -40
  9. package/lib/media/video-keyframe.js +29 -0
  10. package/lib/paint/display-list.js +20 -5
  11. package/lib/render-tree/index.js +2 -0
  12. package/lib/renderer/bindings.js +11 -2
  13. package/lib/renderer/compositor.js +16 -1
  14. package/lib/renderer/custom-element-reactions.js +18 -0
  15. package/lib/renderer/history.js +42 -0
  16. package/lib/renderer/host.js +8 -1
  17. package/lib/renderer/image-loading.js +27 -0
  18. package/lib/renderer/keyboard-input.js +12 -0
  19. package/lib/renderer/main.js +3 -1
  20. package/lib/renderer/media-playback-events.js +13 -0
  21. package/lib/renderer/page-realm.js +9 -50
  22. package/lib/renderer/page-renderer.js +206 -79
  23. package/lib/renderer/piano-input.js +22 -0
  24. package/lib/renderer/pointer-move.js +29 -0
  25. package/lib/renderer/video-presentation.js +126 -0
  26. package/lib/style/computed-style.js +24 -7
  27. package/package.json +1 -1
  28. package/test/compositor.test.js +38 -0
  29. package/test/computed-style.test.js +21 -0
  30. package/test/display-list.test.js +14 -0
  31. package/test/download.test.js +8 -0
  32. package/test/headless-shell.test.js +22 -0
  33. package/test/media-playback-events.test.js +22 -0
  34. package/test/page-scripts.test.js +159 -1
  35. package/test/piano-input.test.js +44 -0
  36. package/test/playback-buffer.test.js +31 -0
  37. package/test/pulse-client.test.js +17 -2
  38. package/test/text-layout.test.js +62 -0
  39. 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
- stream.write(new Uint8Array(2000));
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));
@@ -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
+ });