buninu-browser 0.2.0 → 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 (80) 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 +19 -12
  9. package/lib/happy-dom/parser.js +24 -4
  10. package/lib/happy-dom/selector-matcher.js +83 -0
  11. package/lib/headless-shell.js +120 -28
  12. package/lib/input/hit-test.js +25 -7
  13. package/lib/layout/text-layout.js +365 -54
  14. package/lib/log.js +5 -2
  15. package/lib/media/byte-pipe.js +82 -0
  16. package/lib/media/position-audio.js +42 -0
  17. package/lib/network/network-service.js +326 -46
  18. package/lib/paint/display-list.js +322 -21
  19. package/lib/paint/gradient.js +11 -5
  20. package/lib/render-tree/index.js +62 -2
  21. package/lib/render-tree/svg.js +2 -1
  22. package/lib/renderer/bindings.js +989 -72
  23. package/lib/renderer/canvas-2d.js +92 -0
  24. package/lib/renderer/host.js +159 -9
  25. package/lib/renderer/interfaces.js +215 -0
  26. package/lib/renderer/main.js +160 -12
  27. package/lib/renderer/media-source.js +819 -0
  28. package/lib/renderer/messaging.js +411 -0
  29. package/lib/renderer/native-functions.js +113 -0
  30. package/lib/renderer/page-realm.js +1407 -200
  31. package/lib/renderer/page-renderer.js +1899 -394
  32. package/lib/renderer/rejections.js +31 -0
  33. package/lib/renderer/rewrite-worker.js +22 -0
  34. package/lib/renderer/script-rewrite.js +805 -57
  35. package/lib/renderer/trusted-types.js +129 -0
  36. package/lib/renderer/workers.js +327 -0
  37. package/lib/screenshot-cli.js +2 -0
  38. package/lib/style/computed-style.js +463 -242
  39. package/lib/style/selector-keys.js +302 -0
  40. package/lib/style/selector-traits.js +240 -0
  41. package/lib/user-agent.js +14 -2
  42. package/package.json +4 -1
  43. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  44. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  45. package/patches/happy-dom/README.md +26 -0
  46. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  47. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  48. package/patches/ses/README.md +4 -0
  49. package/test/canvas-2d.test.js +55 -0
  50. package/test/compositor.test.js +3 -1
  51. package/test/computed-style.test.js +98 -1
  52. package/test/display-list.test.js +74 -1
  53. package/test/download.test.js +35 -0
  54. package/test/fixtures/long-video.mp4 +0 -0
  55. package/test/fixtures/media-sync.js +57 -0
  56. package/test/happy-dom-platform-adapters.test.js +25 -0
  57. package/test/headless-shell.test.js +29 -0
  58. package/test/hit-test.test.js +25 -1
  59. package/test/log.test.js +4 -1
  60. package/test/media-source.test.js +156 -0
  61. package/test/media-sync.test.js +12 -0
  62. package/test/mobile.test.js +2 -2
  63. package/test/navigation.test.js +61 -0
  64. package/test/network-service.test.js +81 -0
  65. package/test/page-scripts.test.js +1094 -7
  66. package/test/position-audio.test.js +67 -0
  67. package/test/positioning.test.js +95 -1
  68. package/test/pulse-client.test.js +69 -0
  69. package/test/render-tree.test.js +22 -1
  70. package/test/renderer-host.test.js +5 -2
  71. package/test/screenshot-cli.test.js +25 -1
  72. package/test/script-rewrite.test.js +111 -6
  73. package/test/selector-keys.test.js +39 -0
  74. package/test/selector-traits.test.js +19 -0
  75. package/test/setup.js +5 -0
  76. package/test/text-layout.test.js +69 -1
  77. package/test/user-agent.test.js +60 -4
  78. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  79. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  80. package/vendor/ses/dist/ses.mjs +52 -6
@@ -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();
@@ -149,6 +163,73 @@ test("preflights non-simple requests and strips forbidden headers", async () =>
149
163
  await expect(network.pageFetch({ url: "file:///etc/passwd" }, document)).rejects.toThrow("cannot load file:");
150
164
  });
151
165
 
166
+ test("coalesces identical subresources, caches fresh bodies, and revalidates ETags", async () => {
167
+ let freshRequests = 0;
168
+ let validatedRequests = 0;
169
+ const network = new NetworkService({ fetch: async (input, init = {}) => {
170
+ const url = new URL(typeof input === "string" ? input : input.url);
171
+ const headers = new Headers(init.headers ?? input.headers);
172
+ if (url.pathname === "/fresh") {
173
+ freshRequests++;
174
+ await Bun.sleep(10);
175
+ return new Response("fresh", { headers: { "cache-control": "public, max-age=60" } });
176
+ }
177
+ validatedRequests++;
178
+ if (headers.get("if-none-match") === '"v1"') return new Response(null, { status: 304 });
179
+ return new Response("validated", { headers: { "cache-control": "no-cache", etag: '"v1"' } });
180
+ } });
181
+ const document = "https://page.test/";
182
+ const [left, right] = await Promise.all([
183
+ network.subresource("https://asset.test/fresh", document),
184
+ network.subresource("https://asset.test/fresh", document),
185
+ ]);
186
+ expect([new TextDecoder().decode(left.body), new TextDecoder().decode(right.body), freshRequests])
187
+ .toEqual(["fresh", "fresh", 1]);
188
+ await network.subresource("https://asset.test/fresh", document);
189
+ expect(freshRequests).toBe(1);
190
+
191
+ const first = await network.subresource("https://asset.test/revalidate", document);
192
+ const second = await network.subresource("https://asset.test/revalidate", document);
193
+ expect([new TextDecoder().decode(first.body), new TextDecoder().decode(second.body), validatedRequests])
194
+ .toEqual(["validated", "validated", 2]);
195
+ });
196
+
197
+ test("at most six requests per host are in flight; waiting stylesheets go before scripts", async () => {
198
+ let active = 0;
199
+ let peak = 0;
200
+ const started = [];
201
+ const gates = [];
202
+ const network = new NetworkService({ fetch: async (input) => {
203
+ const url = new URL(typeof input === "string" ? input : input.url);
204
+ started.push(url.host + url.pathname);
205
+ if (url.host === "slow.test") {
206
+ active++;
207
+ peak = Math.max(peak, active);
208
+ await new Promise((resolve) => gates.push(resolve));
209
+ active--;
210
+ }
211
+ return new Response("x", { headers: { "cache-control": "no-store" } });
212
+ } });
213
+ const document = "https://page.test/";
214
+ const scripts = Array.from({ length: 8 }, (_, index) => network.subresource(`https://slow.test/s${index}.js`, document, "script"));
215
+ await Bun.sleep(5);
216
+ const stylesheet = network.subresource("https://slow.test/late.css", document, "stylesheet");
217
+ // Another host is not held up by this one's queue.
218
+ await network.subresource("https://other.test/free.js", document, "script");
219
+ expect(started.filter((entry) => entry.startsWith("slow.test"))).toHaveLength(6);
220
+ gates.shift()();
221
+ await Bun.sleep(5);
222
+ // The freed slot went to the stylesheet that came after the waiting scripts.
223
+ expect(started.at(-1)).toBe("slow.test/late.css");
224
+ while (active || gates.length) {
225
+ gates.shift()?.();
226
+ await Bun.sleep(1);
227
+ }
228
+ await Promise.all([...scripts, stylesheet]);
229
+ expect(peak).toBe(6);
230
+ expect(started.filter((entry) => entry.startsWith("slow.test"))).toHaveLength(9);
231
+ });
232
+
152
233
  /** Opens a page WebSocket and resolves with how it ended. */
153
234
  function socketOutcome(socket) {
154
235
  return new Promise((resolve) => {