buninu-browser 0.2.1 → 0.2.2

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 (72) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +74 -9
  3. package/bunfig.toml +1 -0
  4. package/buninu-browser.js +4 -0
  5. package/index.js +5 -0
  6. package/lib/audio/piano.js +24 -1
  7. package/lib/audio/pulse-client.js +120 -12
  8. package/lib/cdp-server.js +16 -11
  9. package/lib/happy-dom/parser.js +24 -4
  10. package/lib/headless-shell.js +66 -19
  11. package/lib/input/hit-test.js +25 -7
  12. package/lib/layout/text-layout.js +196 -49
  13. package/lib/log.js +5 -2
  14. package/lib/media/byte-pipe.js +82 -0
  15. package/lib/media/position-audio.js +42 -0
  16. package/lib/network/network-service.js +161 -14
  17. package/lib/paint/display-list.js +300 -14
  18. package/lib/paint/gradient.js +11 -5
  19. package/lib/render-tree/index.js +17 -2
  20. package/lib/render-tree/svg.js +2 -1
  21. package/lib/renderer/bindings.js +985 -68
  22. package/lib/renderer/canvas-2d.js +92 -0
  23. package/lib/renderer/host.js +158 -8
  24. package/lib/renderer/interfaces.js +215 -0
  25. package/lib/renderer/main.js +160 -12
  26. package/lib/renderer/media-source.js +819 -0
  27. package/lib/renderer/messaging.js +411 -0
  28. package/lib/renderer/native-functions.js +113 -0
  29. package/lib/renderer/page-realm.js +1405 -198
  30. package/lib/renderer/page-renderer.js +1736 -193
  31. package/lib/renderer/rejections.js +31 -0
  32. package/lib/renderer/rewrite-worker.js +22 -0
  33. package/lib/renderer/script-rewrite.js +805 -57
  34. package/lib/renderer/trusted-types.js +129 -0
  35. package/lib/renderer/workers.js +327 -0
  36. package/lib/screenshot-cli.js +2 -0
  37. package/lib/style/computed-style.js +162 -22
  38. package/lib/user-agent.js +14 -2
  39. package/package.json +4 -1
  40. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  41. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  42. package/patches/happy-dom/README.md +26 -0
  43. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  44. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  45. package/patches/ses/README.md +4 -0
  46. package/test/canvas-2d.test.js +55 -0
  47. package/test/compositor.test.js +3 -1
  48. package/test/computed-style.test.js +38 -1
  49. package/test/display-list.test.js +74 -1
  50. package/test/download.test.js +35 -0
  51. package/test/fixtures/long-video.mp4 +0 -0
  52. package/test/fixtures/media-sync.js +57 -0
  53. package/test/happy-dom-platform-adapters.test.js +25 -0
  54. package/test/headless-shell.test.js +1 -1
  55. package/test/hit-test.test.js +25 -1
  56. package/test/media-source.test.js +156 -0
  57. package/test/media-sync.test.js +12 -0
  58. package/test/mobile.test.js +2 -2
  59. package/test/navigation.test.js +61 -0
  60. package/test/network-service.test.js +14 -0
  61. package/test/page-scripts.test.js +1045 -7
  62. package/test/position-audio.test.js +67 -0
  63. package/test/positioning.test.js +95 -1
  64. package/test/pulse-client.test.js +69 -0
  65. package/test/renderer-host.test.js +5 -2
  66. package/test/screenshot-cli.test.js +25 -1
  67. package/test/script-rewrite.test.js +111 -6
  68. package/test/setup.js +5 -0
  69. package/test/user-agent.test.js +60 -4
  70. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  71. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  72. package/vendor/ses/dist/ses.mjs +52 -6
@@ -0,0 +1,156 @@
1
+ import { expect, test } from "bun:test";
2
+ import { createMediaSourceScope } from "../lib/renderer/media-source.js";
3
+
4
+ function scope(options = {}) {
5
+ const tasks = [];
6
+ const media = createMediaSourceScope({
7
+ origin: "https://page.test",
8
+ schedule: (task) => tasks.push(task),
9
+ DOMException,
10
+ ...options,
11
+ });
12
+ const flush = () => {
13
+ while (tasks.length) tasks.shift()();
14
+ };
15
+ return { media, flush };
16
+ }
17
+
18
+ function openBuffer(context) {
19
+ const source = new context.media.MediaSource();
20
+ source.attach("blob:https://page.test/media");
21
+ context.flush();
22
+ return { source, buffer: source.addSourceBuffer('video/mp4; codecs="avc1.4d401e"') };
23
+ }
24
+
25
+ function box(type, ...parts) {
26
+ const size = 8 + parts.reduce((sum, part) => sum + part.byteLength, 0);
27
+ const bytes = new Uint8Array(size);
28
+ const view = new DataView(bytes.buffer);
29
+ view.setUint32(0, size);
30
+ for (let index = 0; index < 4; index++) bytes[4 + index] = type.charCodeAt(index);
31
+ let offset = 8;
32
+ for (const part of parts) {
33
+ bytes.set(part, offset);
34
+ offset += part.byteLength;
35
+ }
36
+ return bytes;
37
+ }
38
+
39
+ function fragmentedMp4Pieces() {
40
+ const mdhd = new Uint8Array(20);
41
+ new DataView(mdhd.buffer).setUint32(12, 1000);
42
+ const init = box("moov", box("trak", box("mdia", box("mdhd", mdhd))));
43
+ const segment = (base) => {
44
+ const tfhd = new Uint8Array(12);
45
+ tfhd[3] = 8; // default_sample_duration_present
46
+ new DataView(tfhd.buffer).setUint32(4, 1);
47
+ new DataView(tfhd.buffer).setUint32(8, 1000);
48
+ const tfdt = new Uint8Array(8);
49
+ new DataView(tfdt.buffer).setUint32(4, base);
50
+ const trun = new Uint8Array(8);
51
+ new DataView(trun.buffer).setUint32(4, 5);
52
+ return box("moof", box("traf", box("tfhd", tfhd), box("tfdt", tfdt), box("trun", trun)));
53
+ };
54
+ return [init, segment(0), segment(5000)];
55
+ }
56
+
57
+ test("SourceBuffer.abort is harmless while idle and cancels a pending append", () => {
58
+ const context = scope();
59
+ const { buffer } = openBuffer(context);
60
+ expect(() => buffer.abort()).not.toThrow();
61
+
62
+ const events = [];
63
+ buffer.addEventListener("abort", () => events.push("abort"));
64
+ buffer.addEventListener("updateend", () => events.push("updateend"));
65
+ buffer.appendBuffer(new Uint8Array([1, 2, 3]));
66
+ buffer.abort();
67
+ context.flush();
68
+
69
+ expect(buffer.updating).toBe(false);
70
+ expect(buffer._pipe.size).toBe(0);
71
+ expect(events).toEqual(["abort", "updateend"]);
72
+ });
73
+
74
+ test("removing an old buffered prefix releases append quota", () => {
75
+ const context = scope({ appendQuota: 10 });
76
+ const { source, buffer } = openBuffer(context);
77
+ buffer.appendBuffer(box("mdat", new Uint8Array(2)));
78
+ context.flush();
79
+ source.noteBuffered(10);
80
+
81
+ expect(() => buffer.appendBuffer(new Uint8Array(1))).toThrow("SourceBuffer is full");
82
+ buffer.remove(0, 5);
83
+ context.flush();
84
+ expect(buffer.buffered.length).toBe(1);
85
+ expect(buffer.buffered.start(0)).toBe(5);
86
+ expect(buffer.buffered.end(0)).toBe(10);
87
+ expect(() => buffer.appendBuffer(new Uint8Array(5))).not.toThrow();
88
+ context.flush();
89
+ expect(buffer.updating).toBe(false);
90
+ });
91
+
92
+ test("fragmented MP4 buffered end follows appended segments, not presentation duration", () => {
93
+ const context = scope();
94
+ const { source, buffer } = openBuffer(context);
95
+ const [init, first, second] = fragmentedMp4Pieces();
96
+ buffer.appendBuffer(init);
97
+ context.flush();
98
+ // Probing an init segment can expose the full presentation duration before
99
+ // the first media fragment supplies the actual buffered timeline.
100
+ source.noteBuffered(700);
101
+ for (const bytes of [first, second]) {
102
+ buffer.appendBuffer(bytes);
103
+ context.flush();
104
+ }
105
+ expect(buffer.buffered.length).toBe(1);
106
+ expect(buffer.buffered.end(0)).toBe(10);
107
+ });
108
+
109
+ test("WebM buffered end follows Cluster timecodes, not presentation duration", () => {
110
+ const context = scope();
111
+ const source = new context.media.MediaSource();
112
+ source.attach("blob:https://page.test/audio");
113
+ context.flush();
114
+ const buffer = source.addSourceBuffer('audio/webm; codecs="opus"');
115
+ // Cluster timecode 0, one SimpleBlock at +1000 ms, with a dummy payload.
116
+ buffer.appendBuffer(Uint8Array.from([
117
+ 0x1f, 0x43, 0xb6, 0x75, 0x8a,
118
+ 0xe7, 0x81, 0x00,
119
+ 0xa3, 0x85, 0x81, 0x03, 0xe8, 0x00, 0x00,
120
+ ]));
121
+ context.flush();
122
+ source.noteBuffered(700);
123
+ expect(buffer.buffered.end(0)).toBeCloseTo(1.02, 5);
124
+ });
125
+
126
+ test("a media element reports the intersection of separate audio and video buffers", () => {
127
+ const context = scope();
128
+ const { source, buffer: video } = openBuffer(context);
129
+ const audio = source.addSourceBuffer('audio/webm; codecs="opus"');
130
+ video.noteEnd(5);
131
+ audio.noteEnd(8);
132
+ expect(source.bufferedEnd()).toBe(5);
133
+ });
134
+
135
+ test("a media segment appended again is not concatenated twice, while gaps are still filled", () => {
136
+ const context = scope();
137
+ const { buffer } = openBuffer(context);
138
+ const [init, first, second] = fragmentedMp4Pieces();
139
+ let updates = 0;
140
+ buffer.addEventListener("updateend", () => updates++);
141
+ // The second segment arrives first; the first then fills the gap before it.
142
+ for (const bytes of [init, second, first]) {
143
+ buffer.appendBuffer(bytes);
144
+ context.flush();
145
+ }
146
+ const size = buffer._pipe.size;
147
+ expect(size).toBe(init.byteLength + second.byteLength + first.byteLength);
148
+ // A player retrying both segments on a slow network.
149
+ for (const bytes of [first, second]) {
150
+ buffer.appendBuffer(bytes);
151
+ context.flush();
152
+ }
153
+ expect(buffer._pipe.size).toBe(size);
154
+ expect(updates).toBe(5);
155
+ expect(buffer.buffered.end(0)).toBe(10);
156
+ });
@@ -0,0 +1,12 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ test("renderer follows output progress, not wall time or submitted audio", async () => {
4
+ const child = Bun.spawn([process.execPath, new URL("./fixtures/media-sync.js", import.meta.url).pathname], {
5
+ stdout: "pipe", stderr: "pipe",
6
+ });
7
+ const [out, error, code] = await Promise.all([
8
+ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
9
+ ]);
10
+ expect({ code, error }).toEqual({ code: 0, error: "" });
11
+ expect(out).toContain("media clock integration passed");
12
+ }, 30_000);
@@ -52,8 +52,8 @@ test("--mobile sends the mobile user agent, ignores overrides and applies touch
52
52
  });
53
53
  }, 30_000);
54
54
 
55
- test("without --mobile nothing changes: overrides apply and touch rules do not", async () => {
56
- const state = await pageState({});
55
+ test("Chromium desktop allows overrides and does not apply touch rules", async () => {
56
+ const state = await pageState({ webkit: false });
57
57
  expect(state).toMatchObject({
58
58
  requestUserAgent: "Desktop/1.0 (casty override)",
59
59
  navigatorUserAgent: "Desktop/1.0 (casty override)",
@@ -0,0 +1,61 @@
1
+ import { afterAll, beforeAll, expect, test } from "bun:test";
2
+ import { createBrowser } from "../lib/headless-shell.js";
3
+
4
+ let server;
5
+ let browser;
6
+ const posts = [];
7
+ beforeAll(async () => {
8
+ server = Bun.serve({
9
+ port: 0,
10
+ async fetch(request) {
11
+ const { pathname } = new URL(request.url);
12
+ const html = (body, init = {}) => new Response(`<!doctype html>${body}`, { ...init, headers: { "content-type": "text/html" } });
13
+ if (pathname === "/start") return new Response(null, { status: 302, headers: { location: "/dir/form?step=1#top" } });
14
+ if (pathname === "/dir/form") {
15
+ // An error status with a page, as some challenge pages come.
16
+ return html(`<title>form</title><form id="f" action="submit" method="post">
17
+ <input name="q" value="a b&amp;c"><input type="hidden" name="continue" value="/next"></form>`, { status: 429 });
18
+ }
19
+ if (pathname === "/dir/submit") {
20
+ posts.push({
21
+ method: request.method,
22
+ body: await request.text(),
23
+ type: request.headers.get("content-type"),
24
+ origin: request.headers.get("origin"),
25
+ referer: request.headers.get("referer"),
26
+ });
27
+ return new Response(null, { status: 303, headers: { location: "/done" } });
28
+ }
29
+ if (pathname === "/done") return html(`<title>done ${request.method}</title>`);
30
+ return new Response("missing", { status: 404 });
31
+ },
32
+ });
33
+ browser = await createBrowser({ spareRenderer: false });
34
+ });
35
+ afterAll(() => {
36
+ browser?.close();
37
+ server?.stop(true);
38
+ });
39
+
40
+ test("a redirected document has the URL it ended on, and its POST form submits there", async () => {
41
+ const { context } = browser;
42
+ const origin = `http://127.0.0.1:${server.port}`;
43
+ await context.navigate(`${origin}/start`);
44
+ // The final URL, with the request's fragment carried over; relative URLs resolve against it.
45
+ expect(await context.evaluate(`[location.href, document.baseURI, document.getElementById("f").action]`))
46
+ .toEqual([`${origin}/dir/form?step=1#top`, `${origin}/dir/form?step=1#top`, `${origin}/dir/submit`]);
47
+
48
+ await context.evaluate(`document.getElementById("f").submit()`);
49
+ const deadline = Date.now() + 10_000;
50
+ while (await context.title() !== "done GET" && Date.now() < deadline) await Bun.sleep(50);
51
+ expect(await context.title()).toBe("done GET");
52
+ expect(posts).toEqual([{
53
+ method: "POST",
54
+ body: "q=a+b%26c&continue=%2Fnext",
55
+ type: "application/x-www-form-urlencoded",
56
+ origin,
57
+ referer: `${origin}/dir/form?step=1`,
58
+ }]);
59
+ // The 303 after it was followed with a GET, and the document is at its URL.
60
+ expect(await context.evaluate("location.href")).toBe(`${origin}/done`);
61
+ });
@@ -26,6 +26,8 @@ beforeAll(() => {
26
26
  });
27
27
  case "/home":
28
28
  return new Response("home");
29
+ case "/moved":
30
+ return new Response("moved here", { status: 301, headers: { location: "/home", ...cors() } });
29
31
  case "/app/deep/set":
30
32
  return new Response("ok", { headers: [["set-cookie", "scoped=1"]] });
31
33
  case "/api/simple":
@@ -65,6 +67,18 @@ test("follows redirects by hand and stores cookies on every hop", async () => {
65
67
  expect(response.headers.some(([name]) => name === "set-cookie")).toBeFalse();
66
68
  });
67
69
 
70
+ test("a manual redirect gives the page an opaque-redirect response", async () => {
71
+ seen = [];
72
+ const network = new NetworkService();
73
+ const same = await network.pageFetch({ url: local("/moved"), redirect: "manual" }, local("/"));
74
+ expect(same).toMatchObject({ type: "opaqueredirect", status: 0, url: "", headers: [] });
75
+ expect(same.body.byteLength).toBe(0);
76
+ // Cross-origin: the redirect still passes the CORS check.
77
+ const cross = await network.pageFetch({ url: local(`/moved?allow=${encodeURIComponent(loopback(""))}`), redirect: "manual" }, loopback("/"));
78
+ expect(cross).toMatchObject({ type: "opaqueredirect", status: 0 });
79
+ expect(seen.map((entry) => entry.path)).toEqual(["/moved", "/moved"]);
80
+ });
81
+
68
82
  test("applies the default path and SameSite to cross-site requests", async () => {
69
83
  seen = [];
70
84
  const network = new NetworkService();