buninu-browser 0.2.3 → 0.3.0

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 (116) hide show
  1. package/README.md +86 -1
  2. package/lib/cdp-server.js +47 -14
  3. package/lib/clipboard.js +22 -0
  4. package/lib/happy-dom/document-base.js +33 -0
  5. package/lib/happy-dom/parser.js +15 -6
  6. package/lib/happy-dom/selector-matcher.js +2 -2
  7. package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
  8. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  9. package/lib/headless-shell.js +40 -11
  10. package/lib/input/hit-test.js +67 -19
  11. package/lib/layout/text-layout.js +213 -91
  12. package/lib/media/byte-pipe.js +2 -2
  13. package/lib/media/position-audio.js +10 -1
  14. package/lib/media/source-snapshots.js +126 -0
  15. package/lib/media/video-keyframe.js +17 -4
  16. package/lib/media/webm-stream.js +106 -0
  17. package/lib/network/network-service.js +268 -120
  18. package/lib/paint/display-list.js +8 -28
  19. package/lib/paint/frame-cache.js +25 -0
  20. package/lib/paint/frame.js +77 -16
  21. package/lib/paint/paragraph-text.js +24 -0
  22. package/lib/paint/raster-readback.js +41 -0
  23. package/lib/paint/raw-frames.js +47 -0
  24. package/lib/paint/rounded-rect.js +82 -0
  25. package/lib/paint/shared-frame-pool.js +105 -0
  26. package/lib/render-tree/index.js +38 -6
  27. package/lib/render-tree/svg.js +15 -7
  28. package/lib/renderer/bindings.js +233 -80
  29. package/lib/renderer/broadcast-channel.js +76 -0
  30. package/lib/renderer/canvas-2d.js +257 -14
  31. package/lib/renderer/canvas-text.js +120 -0
  32. package/lib/renderer/compositor-channel.js +103 -0
  33. package/lib/renderer/compositor.js +34 -40
  34. package/lib/renderer/custom-element-reactions.js +25 -1
  35. package/lib/renderer/flags.js +1 -1
  36. package/lib/renderer/font-loading.js +227 -0
  37. package/lib/renderer/host.js +133 -98
  38. package/lib/renderer/image-resources.js +65 -0
  39. package/lib/renderer/interfaces.js +28 -0
  40. package/lib/renderer/layout-observers.js +236 -0
  41. package/lib/renderer/main.js +52 -5
  42. package/lib/renderer/media-source.js +126 -55
  43. package/lib/renderer/page-realm.js +287 -99
  44. package/lib/renderer/page-renderer.js +663 -239
  45. package/lib/renderer/prepared-script-cache.js +50 -0
  46. package/lib/renderer/rendered-text.js +108 -0
  47. package/lib/renderer/rewrite-worker.js +4 -3
  48. package/lib/renderer/script-rewrite.js +13 -2
  49. package/lib/renderer/scroll-into-view.js +28 -0
  50. package/lib/renderer/slot-assignment.js +60 -0
  51. package/lib/renderer/video-presentation.js +23 -4
  52. package/lib/renderer/workers.js +2 -2
  53. package/lib/style/computed-style.js +611 -104
  54. package/lib/style/selector-traits.js +49 -5
  55. package/package.json +1 -1
  56. package/test/broadcast-channel.test.js +26 -0
  57. package/test/canvas-2d.test.js +83 -2
  58. package/test/canvas-paint.test.js +9 -0
  59. package/test/canvas-text.test.js +41 -0
  60. package/test/cdp-stream.test.js +56 -0
  61. package/test/clipboard.test.js +35 -0
  62. package/test/computed-style-binding.test.js +151 -0
  63. package/test/computed-style.test.js +258 -1
  64. package/test/display-list.test.js +12 -0
  65. package/test/document-base.test.js +87 -0
  66. package/test/dom-platform.test.js +461 -0
  67. package/test/fetch-lifecycle.test.js +161 -0
  68. package/test/fixtures/browser-raw-demo.html +36 -0
  69. package/test/fixtures/canvas-paint.js +117 -0
  70. package/test/fixtures/canvas-text.js +42 -0
  71. package/test/fixtures/image-lifetime.js +90 -0
  72. package/test/fixtures/media-seek-snapshot.js +77 -0
  73. package/test/fixtures/mse-audio-chirp.webm +0 -0
  74. package/test/fixtures/mse-switch-large.mp4 +0 -0
  75. package/test/fixtures/mse-switch-small.mp4 +0 -0
  76. package/test/fixtures/raw-capture-benchmark.json +76 -0
  77. package/test/fixtures/renderer-memory.js +110 -0
  78. package/test/fixtures/summary-style.js +28 -0
  79. package/test/font-loading.test.js +97 -0
  80. package/test/grid.test.js +58 -0
  81. package/test/hit-test.test.js +21 -1
  82. package/test/image-lifetime.test.js +12 -0
  83. package/test/image-resources.test.js +94 -0
  84. package/test/local-media.test.js +84 -0
  85. package/test/media-seek-snapshot.test.js +11 -0
  86. package/test/media-source-config.test.js +98 -0
  87. package/test/media-source-webm-accounting.test.js +98 -0
  88. package/test/media-source-webm.test.js +108 -0
  89. package/test/navigation.test.js +1 -0
  90. package/test/network-service.test.js +388 -0
  91. package/test/page-scripts.test.js +1 -1
  92. package/test/paint-frame.test.js +103 -0
  93. package/test/paint-paragraph-cache.test.js +87 -0
  94. package/test/position-audio.test.js +24 -0
  95. package/test/positioning.test.js +22 -0
  96. package/test/prepared-script-cache.test.js +45 -0
  97. package/test/pulse-client.test.js +10 -3
  98. package/test/raster-readback.test.js +40 -0
  99. package/test/raw-capture.test.js +152 -0
  100. package/test/render-tree.test.js +28 -0
  101. package/test/renderer-memory.test.js +11 -0
  102. package/test/selector-traits.test.js +24 -0
  103. package/test/shared-frame-pool.test.js +63 -0
  104. package/test/style-sheets.test.js +51 -0
  105. package/test/svg.test.js +29 -0
  106. package/test/text-layout.test.js +106 -0
  107. package/test/video-keyframe.test.js +61 -0
  108. package/test/video-presentation.test.js +69 -0
  109. package/test/window-resize.test.js +36 -0
  110. package/tools/bench-raw-capture.js +32 -0
  111. package/tools/browser-lab.js +78 -0
  112. package/tools/browser-soak.js +75 -0
  113. package/tools/network-reference.js +49 -0
  114. package/tools/process-memory.js +38 -0
  115. package/tools/script-benchmark.js +25 -0
  116. package/ai-debug.log +0 -5537
@@ -6,11 +6,13 @@
6
6
  /** One sheet's selector traits (sibling combinators, :has(), structural pseudo-classes, invalidation features). */
7
7
  export function sheetTraits(sheet) {
8
8
  const text = String(sheet).replace(/\/\*[\s\S]*?\*\//g, "");
9
- const plain = text.replace(/\[[^\]]*\]/g, "[]").replace(/\{[^}]*\}/g, "{}");
9
+ const plain = text.replace(/\[[^\]]*\]/g, "[]").replace(/\{[^{}]*\}/g, "{}");
10
10
  const sibling = /[^\s~|^$*=\[]\s*[+~]\s*[^\s=]/.test(plain);
11
11
  const hasCompounds = new Set();
12
12
  let hasSiblings = false;
13
- const selectorsOnly = text.replace(/\{[^}]*\}/g, "{}");
13
+ // Strip leaf declaration blocks without swallowing a nested group's first
14
+ // selector. State invalidation must see selectors inside @media/@layer.
15
+ const selectorsOnly = text.replace(/\{[^{}]*\}/g, "{}");
14
16
  for (const block of hasBlocks(selectorsOnly)) {
15
17
  for (const selector of splitTopLevelText(block.slice(0, -1), ",")) {
16
18
  for (const compound of compoundsOf(selector)) {
@@ -43,6 +45,13 @@ export function sheetTraits(sheet) {
43
45
  // (".a + .b" of ".a + .b .c"): a later sibling's subtree restyles only
44
46
  // when its match of one of them changes.
45
47
  const siblingTargetCompounds = new Set();
48
+ const observedAttributes = new Set();
49
+ // Scan the original sheet so nested at-rules retain their selectors. A
50
+ // bracket in a declaration can conservatively add an unused dependency.
51
+ // Names can be escaped; namespace prefixes do not change the local name.
52
+ for (const match of text.matchAll(/\[\s*(?:(?:[\w*-]*)\|)?((?:\\[0-9a-fA-F]{1,6}\s?|\\.|[\w\u00a0-\uffff-])+)/g)) {
53
+ observedAttributes.add(cssUnescape(match[1]).toLowerCase());
54
+ }
46
55
  for (const block of selectorsOnly.matchAll(/[^{}@]*\{/g)) {
47
56
  for (const selector of splitTopLevelText(block[0].slice(0, -1), ",")) {
48
57
  const parts = complexParts(selector);
@@ -60,14 +69,15 @@ export function sheetTraits(sheet) {
60
69
  if (nested || combinator === " " || combinator === ">") {
61
70
  for (const feature of features) descendantFeatures.add(feature);
62
71
  const states = features.filter((feature) => feature.startsWith(":"));
63
- if (states.length) descendantStateCompounds.push({ states, required: positiveCompoundFeatures(compound) });
72
+ if (states.length) descendantStateCompounds.push({ states, required: positiveCompoundFeatures(compound),
73
+ alternatives: positiveSelectorAlternatives(compound) });
64
74
  }
65
75
  if (nested || combinator === "+" || combinator === "~") for (const feature of features) siblingFeatures.add(feature);
66
76
  });
67
77
  }
68
78
  }
69
79
  return {
70
- sibling, hasCompounds, hasSiblings, structuralCompounds, descendantFeatures, siblingFeatures, descendantStateCompounds, siblingTargetCompounds,
80
+ sibling, hasCompounds, hasSiblings, structuralCompounds, descendantFeatures, siblingFeatures, descendantStateCompounds, siblingTargetCompounds, observedAttributes,
71
81
  forward: /:(?:nth-child|nth-of-type|first-of-type|only-of-type)\b/.test(selectorsOnly),
72
82
  firstChild: /:(?:first-child|only-child)\b/.test(selectorsOnly),
73
83
  backward: /:(?:nth-last-child|nth-last-of-type|last-of-type|only-of-type)\b/.test(selectorsOnly),
@@ -116,7 +126,41 @@ function splitTopLevelText(text, separator) {
116
126
  return parts.filter(Boolean);
117
127
  }
118
128
 
119
- const STATE_PSEUDO = /:(?:checked|indeterminate|default|disabled|enabled|placeholder-shown|valid|invalid|in-range|out-of-range|required|optional|read-only|read-write|user-valid|user-invalid|autofill|open|popover-open|modal|target|focus|focus-visible|focus-within|hover|active|visited|link|any-link|defined|fullscreen)\b/g;
129
+ const STATE_PSEUDO = /:(?:checked|indeterminate|default|disabled|enabled|placeholder-shown|valid|invalid|in-range|out-of-range|required|optional|read-only|read-write|user-valid|user-invalid|autofill|open|popover-open|modal|target|focus|focus-visible|focus-within|hover|active|visited|link|any-link|defined|fullscreen)(?![\w-])/g;
130
+
131
+ /** Outer :is/:where groups require at least one subject alternative to fit.
132
+ * Requirements inside :not/:has cannot constrain this element positively.
133
+ * Nested functional conditions remain conservative rather than excluding
134
+ * a possible match. Complex alternatives constrain their rightmost subject.
135
+ */
136
+ function positiveSelectorAlternatives(compound) {
137
+ const groups = [];
138
+ let depth = 0;
139
+ for (let index = 0; index < compound.length; index++) {
140
+ const character = compound[index];
141
+ if (character === "\\") { index++; continue; }
142
+ if (character === "(" || character === "[") { depth++; continue; }
143
+ if (character === ")" || character === "]") { depth--; continue; }
144
+ if (depth || character !== ":") continue;
145
+ const match = /^:(?:is|where)\(/.exec(compound.slice(index));
146
+ if (!match) continue;
147
+ const start = index + match[0].length;
148
+ let end = start, nested = 1, quote = "";
149
+ for (; end < compound.length && nested; end++) {
150
+ const char = compound[end];
151
+ if (char === "\\") end++;
152
+ else if (quote) { if (char === quote) quote = ""; }
153
+ else if (char === '"' || char === "'") quote = char;
154
+ else if (char === "(") nested++;
155
+ else if (char === ")") nested--;
156
+ }
157
+ if (nested) continue;
158
+ groups.push(splitTopLevelText(compound.slice(start,end-1), ",")
159
+ .map(selector => positiveCompoundFeatures(complexParts(selector).at(-1)?.compound ?? "")));
160
+ index = end - 1;
161
+ }
162
+ return groups;
163
+ }
120
164
 
121
165
  /** CSS escapes in an identifier (".\\:popover-open", "\\31 a") resolved. */
122
166
  function cssUnescape(text) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu-browser",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Buninu Browser is a browser engine written in JavaScript and WebAssembly for Bun.",
5
5
  "keywords": [
6
6
  "buninu",
@@ -0,0 +1,26 @@
1
+ import { expect, test } from "bun:test";
2
+ import { createBroadcastChannels } from "../lib/renderer/broadcast-channel.js";
3
+
4
+ const scope = origin => createBroadcastChannels({ origin, EventTarget, DOMException,
5
+ runTask: task => task(),
6
+ realm: { messageEvent: (type, fields) => new MessageEvent(type, fields), markTrusted() {} },
7
+ });
8
+
9
+ test("BroadcastChannel separates origins, channel names and opaque document origins and disposes native channels", async () => {
10
+ const a=scope('https://a.test'), b=scope('https://b.test'), a2=scope('https://a.test'), opaque1=scope('null'), opaque2=scope('null');
11
+ const name=crypto.randomUUID(), sender=new a.BroadcastChannel(name), other=new b.BroadcastChannel(name),
12
+ peer=new a2.BroadcastChannel(name), otherName=new a.BroadcastChannel(name+'other'),
13
+ opaqueSender=new opaque1.BroadcastChannel(name), opaquePeer=new opaque2.BroadcastChannel(name);
14
+ const received=[];
15
+ other.onmessage=()=>received.push('other origin');peer.onmessage=()=>received.push('same origin');
16
+ otherName.onmessage=()=>received.push('other name');opaquePeer.onmessage=()=>received.push('opaque');
17
+ try {
18
+ sender.postMessage('message');opaqueSender.postMessage('opaque message');
19
+ for(let i=0;i<100&&!received.length;i++)await Bun.sleep(2);
20
+ await Bun.sleep(10);
21
+ expect(received).toEqual(['same origin']);
22
+ a.dispose();
23
+ expect(()=>sender.postMessage('closed')).toThrow('The channel is closed');
24
+ expect(()=>new a.BroadcastChannel('closed realm')).toThrow('The document is no longer active');
25
+ } finally {for(const item of [a,b,a2,opaque1,opaque2])item.dispose();}
26
+ });
@@ -1,10 +1,13 @@
1
1
  import { beforeAll, expect, test } from "bun:test";
2
2
  import CanvasKitInit from "../usr/lib/canvaskit/canvaskit.js";
3
- import { canvasContext, resetCanvas, setCanvasKit } from "../lib/renderer/canvas-2d.js";
3
+ import { canvasContext, resetCanvas, setCanvasKit, disposeDocumentCanvases } from "../lib/renderer/canvas-2d.js";
4
+
5
+ let CanvasKit;
4
6
 
5
7
  beforeAll(async () => {
6
8
  const wasmBinary = await Bun.file(new URL("../usr/lib/canvaskit/canvaskit.wasm", import.meta.url)).arrayBuffer();
7
- setCanvasKit(await CanvasKitInit({ wasmBinary }));
9
+ CanvasKit = await CanvasKitInit({ wasmBinary });
10
+ setCanvasKit(CanvasKit);
8
11
  });
9
12
 
10
13
  function canvas(width = 1, height = 1) {
@@ -44,6 +47,7 @@ test("a colour reads back stably, and one CanvasKit cannot parse is black", () =
44
47
  test("the same canvas keeps one context, and changing width clears it", () => {
45
48
  const node = canvas(2, 2);
46
49
  const context = canvasContext(node, "2d");
50
+ const fill = context.fillRect;
47
51
  expect(canvasContext(node, "2d")).toBe(context);
48
52
  context.fillStyle = "blue";
49
53
  context.fillRect(0, 0, 2, 2);
@@ -52,4 +56,81 @@ test("the same canvas keeps one context, and changing width clears it", () => {
52
56
  resetCanvas(node);
53
57
  expect(context.fillStyle).toBe("#000000");
54
58
  expect([...context.getImageData(0, 0, 1, 1).data]).toEqual([0, 0, 0, 0]);
59
+ expect(context.fillRect).toBe(fill);
60
+ // A method kept before a bitmap reset must draw into the new bitmap.
61
+ context.fillStyle = "red";
62
+ fill(0, 0, 1, 1);
63
+ expect([...context.getImageData(0, 0, 1, 1).data]).toEqual([255, 0, 0, 255]);
64
+ });
65
+
66
+ test("gradient and pattern facades hide native shaders while style identity and drawing survive", () => {
67
+ const node = canvas(2, 2);
68
+ const context = canvasContext(node, "2d");
69
+ const gradient = context.createLinearGradient(0, 0, 2, 0);
70
+ let offsetReads = 0, colorReads = 0;
71
+ gradient.addColorStop({ gradient, valueOf() { offsetReads++; return 0; } },
72
+ { gradient, toString() { colorReads++; return "red"; } });
73
+ expect([offsetReads, colorReads]).toEqual([1, 1]);
74
+ gradient.addColorStop(1, "red");
75
+ context.fillStyle = gradient;
76
+ context.fillRect(0, 0, 2, 2);
77
+ expect(context.fillStyle).toBe(gradient);
78
+ expect([...context.getImageData(0, 0, 1, 1).data]).toEqual([255, 0, 0, 255]);
79
+ expect(Object.keys(gradient)).toEqual(["addColorStop"]);
80
+ for (const name of ["Td", "pe", "ie", "oe"]) expect(gradient[name]).toBeUndefined();
81
+ let invoked = false;
82
+ context.fillStyle = { pe() { invoked = true; throw new Error("private shader protocol"); } };
83
+ context.fillRect(0, 0, 1, 1);
84
+ expect(invoked).toBeFalse();
85
+ expect(context.fillStyle).toBe(gradient);
86
+ context.strokeStyle = gradient;
87
+ expect(context.strokeStyle).toBe(gradient);
88
+ context.save();
89
+ context.fillStyle = "blue";
90
+ context.strokeStyle = "blue";
91
+ context.restore();
92
+ expect(context.fillStyle).toBe(gradient);
93
+ expect(context.strokeStyle).toBe(gradient);
94
+ const radial = context.createRadialGradient(0, 0, 0, 1, 1, 2);
95
+ expect(Object.keys(radial)).toEqual(["addColorStop"]);
96
+ const pattern = context.createPattern({}, "repeat");
97
+ expect(Object.keys(pattern)).toEqual(["setTransform"]);
98
+ for (const name of ["Td", "qf", "pe", "ie", "oe"]) expect(pattern[name]).toBeUndefined();
99
+ resetCanvas(node);
100
+ expect(context.fillStyle).toBe("#000000");
101
+ });
102
+
103
+ test("discarding a document frees its connected and detached canvas pixel buffers", () => {
104
+ const malloc = CanvasKit._malloc;
105
+ const free = CanvasKit._free;
106
+ const allocations = new Map();
107
+ let pixelBytes = 0;
108
+ CanvasKit._malloc = (size) => {
109
+ const pointer = malloc(size);
110
+ allocations.set(pointer, size);
111
+ pixelBytes += size;
112
+ return pointer;
113
+ };
114
+ CanvasKit._free = (pointer) => {
115
+ pixelBytes -= allocations.get(pointer) ?? 0;
116
+ allocations.delete(pointer);
117
+ return free(pointer);
118
+ };
119
+ const document = {};
120
+ try {
121
+ // Ownership follows ownerDocument, even if an element was never inserted.
122
+ const nodes = Array.from({ length: 8 }, () => ({ ownerDocument: document, width: 400, height: 300 }));
123
+ const contexts = nodes.map(node => canvasContext(node, "2d"));
124
+ expect(pixelBytes).toBe(8 * 400 * 300 * 4);
125
+ for (const context of contexts) context.fillRect(0, 0, 1, 1);
126
+ disposeDocumentCanvases(document);
127
+ expect(pixelBytes).toBe(0);
128
+ disposeDocumentCanvases(document);
129
+ expect(pixelBytes).toBe(0);
130
+ expect(() => contexts[0].fillRect(0, 0, 1, 1)).toThrow("discarded");
131
+ } finally {
132
+ disposeDocumentCanvases(document);
133
+ CanvasKit._malloc = malloc;
134
+ CanvasKit._free = free;
135
+ }
55
136
  });
@@ -0,0 +1,9 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ test("canvas bitmaps paint, update without DOM mutations, scale, reset and survive detachment", async () => {
4
+ const child = Bun.spawn([process.execPath, new URL("./fixtures/canvas-paint.js", import.meta.url).pathname],
5
+ { stdout: "pipe", stderr: "pipe" });
6
+ const [exit, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
7
+ expect(stderr).toBe("");
8
+ expect(exit).toBe(0);
9
+ }, 15_000);
@@ -0,0 +1,41 @@
1
+ import { expect, test } from "bun:test";
2
+ import { parseCanvasFont, createCanvasTextRenderer } from "../lib/renderer/canvas-text.js";
3
+ import { loadGraphics } from "../lib/renderer/page-renderer.js";
4
+
5
+ test("canvas fonts share shorthand parsing, font units, styles and quoted fallback families", () => {
6
+ expect(parseCanvasFont('italic bold 1.5em "A, B", sans-serif', 20)).toEqual({
7
+ fontSize: 30, fontFamily: ["A, B", "sans-serif"], fontWeight: 700, fontStyle: "italic", fontStretch: 100,
8
+ });
9
+ expect(parseCanvasFont("condensed 2rem monospace", 20, 15).fontSize).toBe(30);
10
+ expect(parseCanvasFont("condensed 12pt monospace").fontStretch).toBe(75);
11
+ expect(parseCanvasFont("condensed 12pt monospace").fontSize).toBe(16);
12
+ expect(parseCanvasFont("invalid")).toBeNull();
13
+ expect(parseCanvasFont("0px serif")).toBeNull();
14
+ });
15
+
16
+ test("canvas text reuses bounded native paragraphs and releases them at document teardown", async () => {
17
+ const { CanvasKit } = await loadGraphics();
18
+ let made = 0, deleted = 0;
19
+ const render = createCanvasTextRenderer(CanvasKit, () => {
20
+ made++;
21
+ return { layout() {}, getMaxIntrinsicWidth: () => 12, getLineMetrics: () => [{ ascent: 8, descent: 2 }],
22
+ getAlphabeticBaseline: () => 8, getIdeographicBaseline: () => 10, getHeight: () => 10,
23
+ delete() { deleted++; } };
24
+ });
25
+ const request = { operation: "measureText", args: ["hello"], style: parseCanvasFont("10px sans-serif"),
26
+ context: {}, settings: { textAlign: "left", textBaseline: "alphabetic", direction: "ltr" } };
27
+ expect(render(request).width).toBe(12);
28
+ render(request);
29
+ expect(made).toBe(1);
30
+ for (let i = 0; i < 200; i++) render({ ...request, args: [String(i)] });
31
+ expect(made - deleted).toBe(128);
32
+ render.clear();
33
+ expect(made).toBe(deleted);
34
+ });
35
+
36
+ test("canvas text uses real shared fonts, aligns and scales glyphs, and saves its text state", async () => {
37
+ // Page scripting runs in its own host process to keep the runner's globals.
38
+ const process = Bun.spawn([Bun.which("bun"), new URL("fixtures/canvas-text.js", import.meta.url).pathname], { stdout: "pipe", stderr: "pipe" });
39
+ const [status, stderr] = await Promise.all([process.exited, new Response(process.stderr).text()]);
40
+ expect({ status, stderr }).toEqual({ status: 0, stderr: "" });
41
+ });
@@ -0,0 +1,56 @@
1
+ import { test, expect } from 'bun:test';
2
+ import { CdpServer } from '../lib/cdp-server.js';
3
+ import { CDP } from '../tools/browser-lab.js';
4
+ const until = async predicate => { for(let i=0;i<100;i++){if(predicate())return;await Bun.sleep(5);}throw Error('condition timed out'); };
5
+ async function fixture(screenshot) {
6
+ let onFrame;
7
+ const server=CdpServer.create({screenshot,onFrame:fn=>{onFrame=fn;}}).listen(0);
8
+ const cdp=new CDP(`ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);
9
+ await cdp.ready;const {targetId}=await cdp.send('Target.createTarget',{url:'about:blank'});
10
+ const {sessionId}=await cdp.send('Target.attachToTarget',{targetId,flatten:true});cdp.sessionId=sessionId;
11
+ return {cdp,targetId,frame:()=>onFrame(),frames:()=>cdp.events.filter(e=>e.method==='Page.screencastFrame'),close:()=>{cdp.close();server.stop(true);}};
12
+ }
13
+ test('slow screencast client gets at most two unacked frames; latest updates resume after ack',async()=>{
14
+ let calls=0;const f=await fixture(async()=>String(++calls));
15
+ try{
16
+ await f.cdp.send('Page.startScreencast');await until(()=>f.frames().length===1);
17
+ f.frame();await until(()=>f.frames().length===2);
18
+ for(let i=0;i<50;i++)f.frame();await Bun.sleep(20);
19
+ expect(calls).toBe(2);
20
+ await f.cdp.send('Page.screencastFrameAck',{sessionId:f.frames()[0].params.sessionId});
21
+ await until(()=>f.frames().length===3);expect(calls).toBe(3);
22
+ await f.cdp.send('Page.screencastFrameAck',{sessionId:-1});f.frame();await Bun.sleep(10);expect(calls).toBe(3);
23
+ await f.cdp.send('Page.stopScreencast');f.frame();await Bun.sleep(10);expect(calls).toBe(3);
24
+ await f.cdp.send('Page.startScreencast');await until(()=>calls===4);
25
+ f.cdp.close();await Bun.sleep(20);f.frame();await Bun.sleep(10);expect(calls).toBe(4);
26
+ }finally{f.close();}
27
+ });
28
+ test('closing a target drops a capture already in flight',async()=>{
29
+ let resolveCapture;const f=await fixture(()=>new Promise(resolve=>{resolveCapture=resolve;}));
30
+ try{
31
+ await f.cdp.send('Page.startScreencast');await until(()=>resolveCapture);
32
+ await f.cdp.send('Target.closeTarget',{targetId:f.targetId});resolveCapture('obsolete');
33
+ await Bun.sleep(20);expect(f.frames()).toHaveLength(0);
34
+ }finally{f.close();}
35
+ });
36
+ test('clients without attached sessions receive updates and release their implicit stream on disconnect',async()=>{
37
+ let calls=0;const f=await fixture(async()=>String(++calls));
38
+ try{
39
+ f.cdp.sessionId=null;
40
+ await f.cdp.send('Page.startScreencast');await until(()=>f.frames().length===1);
41
+ f.frame();await until(()=>f.frames().length===2);
42
+ await f.cdp.send('Page.screencastFrameAck',{sessionId:f.frames()[0].params.sessionId});
43
+ f.frame();await until(()=>f.frames().length===3);
44
+ f.cdp.close();await Bun.sleep(20);f.frame();await Bun.sleep(10);expect(calls).toBe(3);
45
+ }finally{f.close();}
46
+ });
47
+ test('stopping a capture in flight drops its frame and a restarted stream gets its own options',async()=>{
48
+ const pending=[];const f=await fixture(params=>new Promise(resolve=>pending.push({params,resolve})));
49
+ try{
50
+ await f.cdp.send('Page.startScreencast',{format:'jpeg'});await until(()=>pending.length===1);
51
+ await f.cdp.send('Page.stopScreencast');await f.cdp.send('Page.startScreencast',{format:'png'});
52
+ pending[0].resolve('old');await until(()=>pending.length===2);expect(f.frames()).toHaveLength(0);
53
+ expect(pending[1].params.format).toBe('png');pending[1].resolve('new');await until(()=>f.frames().length===1);
54
+ expect(f.frames()[0].params.data).toBe('new');
55
+ }finally{f.close();}
56
+ });
@@ -0,0 +1,35 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {writeClipboard} from '../lib/clipboard.js';
3
+ import {RendererHost} from '../lib/renderer/host.js';
4
+
5
+ test('clipboard prefers xclip, preserves exact stdin, and falls back on failure',async()=>{
6
+ const calls=[];let fail=false;
7
+ const options={platform:'linux',which:name=>'/bin/'+name,spawn:(args,opts)=>{calls.push({args,opts});return {exited:Promise.resolve(fail&&args[0].endsWith('xclip')?1:0),kill(){}};}};
8
+ const text='中文\n$(do not execute) `literal`';
9
+ await writeClipboard(text,options);
10
+ expect(calls[0].args).toEqual(['/bin/xclip','-selection','clipboard']);
11
+ expect(calls[0].opts.stdin.toString()).toBe(text);expect(calls).toHaveLength(1);
12
+ fail=true;calls.length=0;await writeClipboard('',options);
13
+ expect(calls.map(c=>c.args[0])).toEqual(['/bin/xclip','/bin/wl-copy']);
14
+ await expect(writeClipboard('x',{...options,which:()=>null})).rejects.toThrow('No working');
15
+ });
16
+
17
+ test('a real button click writes through controller; page-only calls cannot overwrite clipboard',async()=>{
18
+ const copies=[];const host=new RendererHost({clipboardWrite:async text=>{copies.push(text);}});
19
+ try{
20
+ await host.start();await host.call('resize',400,200,1);
21
+ await host.call('loadDocument',{url:'https://clipboard.test/',contentType:'text/html',source:`<body style="margin:0"><button id="b" style="width:200px;height:60px">Copy</button><script>
22
+ window.copyStatus='idle';document.getElementById('b').onclick=async()=>{
23
+ try{await navigator.clipboard.writeText('npx buninu 中文');window.copyStatus='copied';}
24
+ catch(e){window.copyStatus=e.name;}
25
+ };</script>`});
26
+ await host.call('whenLoaded');
27
+ await host.call('evaluate',"document.getElementById('b').click()");await Bun.sleep(30);
28
+ expect(copies).toEqual([]);expect(await host.call('evaluate','window.copyStatus')).toBe('NotAllowedError');
29
+ await host.call('click',30,25);
30
+ for(let i=0;i<40 && !copies.length;i++)await Bun.sleep(25);
31
+ expect(copies).toEqual(['npx buninu 中文']);
32
+ for(let i=0;i<40 && await host.call('evaluate','window.copyStatus')!=='copied';i++)await Bun.sleep(25);
33
+ expect(await host.call('evaluate','window.copyStatus')).toBe('copied');
34
+ }finally{host.close();}
35
+ },20000);
@@ -0,0 +1,151 @@
1
+ import { afterAll, beforeAll, expect, test } from "bun:test";
2
+ import { RendererHost } from "../lib/renderer/host.js";
3
+
4
+ let renderer;
5
+ beforeAll(async () => {
6
+ renderer = new RendererHost({ timeout: 20_000 });
7
+ await renderer.start();
8
+ });
9
+ afterAll(() => renderer?.close());
10
+
11
+ test("computed corner radii preserve percentages, elliptical axes and logical mappings", async () => {
12
+ await renderer.call("loadDocument", {
13
+ url: "https://page.test/", contentType: "text/html", status: 200,
14
+ source: `<!doctype html><style>
15
+ #box { width:100px;height:80px;border-radius:60% 30% 15% 0 / 20% 40% 60% 0 }
16
+ #rtl { direction:rtl;border-radius:10px;border-start-start-radius:2em 50% }
17
+ </style><div id="box"></div><div id="rtl"></div>`,
18
+ });
19
+ await renderer.call("whenLoaded");
20
+ const result = JSON.parse(await renderer.call("evaluate", `JSON.stringify(['box','rtl'].map(id=>{
21
+ const s=getComputedStyle(document.getElementById(id));return [s.borderRadius,s.borderTopLeftRadius,s.borderTopRightRadius,s.borderStartStartRadius];
22
+ }))`));
23
+ expect(result).toEqual([
24
+ ["60% 30% 15% 0px / 20% 40% 60% 0px", "60% 20%", "30% 40%", "60% 20%"],
25
+ ["10px 32px 10px 10px / 10px 50% 10px 10px", "10px", "32px 50%", "32px 50%"],
26
+ ]);
27
+ });
28
+
29
+ test("computed zero gaps remain numeric for script-driven responsive toolbars", async () => {
30
+ await renderer.call("loadDocument", {
31
+ url: "https://page.test/",
32
+ source: `<!doctype html><style>
33
+ #toolbar { display:flex;gap:0;font-style:italic;font-stretch:75% }
34
+ #toolbar > div { width:80px;flex:none }
35
+ #normal { gap:normal 0px } #inherited { gap:inherit }
36
+ #reset { gap:12px;all:initial }
37
+ </style><div id="toolbar"><div></div><div></div></div>
38
+ <div id="normal"><div id="inherited"></div></div><div id="reset"></div>`,
39
+ contentType: "text/html", status: 200,
40
+ });
41
+ await renderer.call("whenLoaded");
42
+ const result = await renderer.call("evaluate", `JSON.stringify((() => {
43
+ const toolbar = document.getElementById('toolbar');
44
+ const style = getComputedStyle(toolbar);
45
+ const widths = Array.from(toolbar.children, child => child.offsetWidth + 2 * parseInt(style.columnGap, 10));
46
+ const gaps = id => { const s = getComputedStyle(document.getElementById(id)); return [s.rowGap,s.columnGap]; };
47
+ return {widths, zero:gaps('toolbar'), normal:gaps('normal'), inherited:gaps('inherited'), reset:gaps('reset'),
48
+ font:[style.fontStyle,style.fontStretch]};
49
+ })())`);
50
+ expect(JSON.parse(result)).toEqual({widths:[80,80], zero:["0px","0px"], normal:["normal","0px"],
51
+ inherited:["normal","0px"], reset:["normal","normal"], font:["italic","75%"]});
52
+ });
53
+
54
+ test("computed transform matrices retain every translation and resolve nested length math", async () => {
55
+ await renderer.call("loadDocument", {
56
+ url:"https://page.test/",contentType:"text/html",status:200,
57
+ source:`<!doctype html><style>div{width:100px;height:150px}
58
+ #popup{transform:translate(56px,780px) translate(0,-100%)}
59
+ #math{transform:scale(2) translate(calc(25% + 10px),min(20%,40px)) translate(10%,5px)}
60
+ </style><div id="popup"></div><div id="math"></div>`,
61
+ });
62
+ await renderer.call("whenLoaded");
63
+ expect(JSON.parse(await renderer.call("evaluate", `JSON.stringify(['popup','math'].map(id=>getComputedStyle(document.getElementById(id)).transform))`)))
64
+ .toEqual(["matrix(1, 0, 0, 1, 56, 630)","matrix(2, 0, 0, 2, 90, 70)"]);
65
+ });
66
+
67
+ test("focus invalidates subjects, ancestor paths, siblings and relational selectors", async () => {
68
+ await renderer.call("loadDocument", {
69
+ url:"https://page.test/",contentType:"text/html",status:200,
70
+ source:`<!doctype html><style>
71
+ input{color:black}input:focus{color:red}input:focus-visible{background:yellow}
72
+ section{color:black}section:focus-within{color:green}
73
+ :where(.group,.alternative):focus-within .nested{color:blue}
74
+ section:has(input:focus) .related{color:red}
75
+ section:focus-within + aside .following{color:purple}
76
+ </style><section class="group" id="left"><input id="a"><i class="nested"></i><i class="related"></i></section>
77
+ <aside><i class="following"></i></aside><section class="alternative" id="right"><input id="b"></section>`,
78
+ });
79
+ await renderer.call("whenLoaded");
80
+ const read = async action => JSON.parse(await renderer.call("evaluate", `JSON.stringify((()=>{
81
+ ${action};return ['#a','#b','#left','#right','.nested','.related','.following'].map(s=>{const e=document.querySelector(s);return e?getComputedStyle(e).color:null})
82
+ })())`));
83
+ const black="rgba(0, 0, 0, 1)",red="rgba(255, 0, 0, 1)",green="rgba(0, 128, 0, 1)",blue="rgba(0, 0, 255, 1)",purple="rgba(128, 0, 128, 1)";
84
+ expect(await read("document.getElementById('a').focus()"))
85
+ .toEqual([red,black,green,black,blue,red,purple]);
86
+ expect(await read("document.getElementById('b').focus()"))
87
+ .toEqual([black,red,black,green,black,black,black]);
88
+ expect(await read("document.getElementById('left').appendChild(document.getElementById('b'))"))
89
+ .toEqual([black,black,black,black,black,black,black]);
90
+ expect(await read("document.getElementById('b').focus()"))
91
+ .toEqual([black,red,green,black,blue,red,purple]);
92
+ expect(await read("document.getElementById('b').remove();document.getElementById('a').focus();document.getElementById('a').blur()"))
93
+ .toEqual([black,null,black,black,black,black,black]);
94
+ });
95
+
96
+ test("metadata mutations invalidate only selectors that observe those attributes", async () => {
97
+ await renderer.call("loadDocument", {
98
+ url:"https://page.test/",contentType:"text/html",status:200,
99
+ source:`<!doctype html><style>
100
+ section,span{color:black}[data-open] span{color:red}
101
+ section:has([aria-expanded="true"]){color:green}span[title="visible"]{color:blue}
102
+ </style><section><span>Label</span></section>`,
103
+ });
104
+ await renderer.call("whenLoaded");
105
+ expect(JSON.parse(await renderer.call("evaluate", `JSON.stringify((()=>{
106
+ const parent=document.querySelector('section'),child=document.querySelector('span'),result=[];
107
+ const read=()=>result.push([getComputedStyle(parent).color,getComputedStyle(child).color]);
108
+ for(let i=0;i<20;i++){child.setAttribute('aria-activedescendant','item'+i);child.dataset.internal=String(i);read()}
109
+ parent.dataset.open='';child.setAttribute('aria-expanded','true');read();child.title='visible';read();
110
+ return [result[0],result[19],result[20],result[21]];
111
+ })())`))).toEqual([
112
+ ["rgba(0, 0, 0, 1)","rgba(0, 0, 0, 1)"], ["rgba(0, 0, 0, 1)","rgba(0, 0, 0, 1)"],
113
+ ["rgba(0, 128, 0, 1)","rgba(255, 0, 0, 1)"], ["rgba(0, 128, 0, 1)","rgba(0, 0, 255, 1)"],
114
+ ]);
115
+ });
116
+
117
+ test("focus invalidation reaches descendants through nested stylesheet groups", async () => {
118
+ await renderer.call("loadDocument",{
119
+ url:"https://page.test/",contentType:"text/html",status:200,
120
+ source:'<!doctype html><style>body{color:black}@media(min-width:1px){@layer app{.panel:focus-within span{color:red}}}</style><div class="panel"><input><span>Label</span></div>',
121
+ });
122
+ await renderer.call("whenLoaded");
123
+ expect(JSON.parse(await renderer.call("evaluate",`JSON.stringify((()=>{
124
+ const input=document.querySelector('input'),label=document.querySelector('span');input.focus();
125
+ const focused=getComputedStyle(label).color;input.blur();return[focused,getComputedStyle(label).color];
126
+ })())`))).toEqual(["rgba(255, 0, 0, 1)","rgba(0, 0, 0, 1)"]);
127
+ });
128
+
129
+ test("native label activation restyles both radios and relational ancestors without a global reset", async () => {
130
+ await renderer.call("loadDocument", {
131
+ url:"https://page.test/",contentType:"text/html",status:200,
132
+ source:`<!doctype html><style>label{display:block}span,form{color:black}input:checked+span{color:red}
133
+ form:has(#second:checked){color:purple}</style><form>
134
+ <label><input type="radio" name="choice" checked id="first"><span id="firstText">First</span></label>
135
+ <label id="secondLabel"><input type="radio" name="choice" id="second"><span id="secondText">Second</span></label></form>`,
136
+ });
137
+ await renderer.call("whenLoaded");
138
+ const position = JSON.parse(await renderer.call("evaluate", `JSON.stringify((()=>{const r=document.getElementById('secondText').getBoundingClientRect();return [r.x+r.width/2,r.y+r.height/2]})())`));
139
+ await renderer.call("click",...position);
140
+ const result = JSON.parse(await renderer.call("evaluate", `JSON.stringify([document.getElementById('first').checked,document.getElementById('second').checked,
141
+ ...['#firstText','#secondText','form'].map(s=>getComputedStyle(document.querySelector(s)).color)])`));
142
+ expect(result).toEqual([false,true,"rgba(0, 0, 0, 1)","rgba(255, 0, 0, 1)","rgba(128, 0, 128, 1)"]);
143
+ });
144
+
145
+ test("summary UA styles follow disclosure state and principal-summary changes incrementally", async () => {
146
+ const child = Bun.spawn([process.execPath, new URL("./fixtures/summary-style.js", import.meta.url).pathname],
147
+ { stdout:"pipe", stderr:"pipe" });
148
+ const [exit,stderr] = await Promise.all([child.exited,new Response(child.stderr).text()]);
149
+ expect(stderr).toBe("");
150
+ expect(exit).toBe(0);
151
+ });