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
@@ -0,0 +1,87 @@
1
+ import { expect, test } from "bun:test";
2
+ import { createPageRenderer, loadGraphics } from "../lib/renderer/page-renderer.js";
3
+
4
+ test("scroll paints reuse shaped text, evict bounded resources, and release paragraphs on navigation/disposal", async () => {
5
+ const { CanvasKit } = await loadGraphics();
6
+ const original = CanvasKit.ParagraphBuilder.MakeFromFontCollection;
7
+ let built = 0;
8
+ let alive = 0;
9
+ CanvasKit.ParagraphBuilder.MakeFromFontCollection = (...args) => {
10
+ const builder = original(...args);
11
+ const build = builder.build.bind(builder);
12
+ builder.build = () => {
13
+ const paragraph = build();
14
+ const dispose = paragraph.delete.bind(paragraph);
15
+ built++;
16
+ alive++;
17
+ paragraph.delete = () => { alive--; dispose(); };
18
+ return paragraph;
19
+ };
20
+ return builder;
21
+ };
22
+ let renderer;
23
+ try {
24
+ renderer = await createPageRenderer({ fetchResource: async url => ({ ok: false, status: 404, url, body: new Uint8Array() }) });
25
+ renderer.resize(400, 200, 1);
26
+ await renderer.loadDocument({ url: "https://paint.test/", status: 200, contentType: "text/html",
27
+ source: `<!doctype html><style>body { margin:0 } p { margin:0; height:30px }</style>${
28
+ Array.from({ length: 400 }, (_, i) => `<p>Article number ${i}: shared text</p>`).join("")}` });
29
+ await renderer.whenLoaded();
30
+ const first = await renderer.screenshot();
31
+ const before = built;
32
+ renderer.scroll(0, 1);
33
+ await renderer.screenshot();
34
+ // No layout invalidation and the same seven visible paragraph items.
35
+ expect(built - before).toBeLessThanOrEqual(1);
36
+ renderer.scroll(0, -1);
37
+ expect(await renderer.screenshot()).toBe(first);
38
+ // A fresh layout/display list still uses identical text shaping inputs.
39
+ const beforeLayout = built;
40
+ renderer.resize(400, 201, 1);
41
+ await renderer.screenshot();
42
+ expect(built - beforeLayout).toBeLessThanOrEqual(1);
43
+ renderer.resize(400, 200, 1);
44
+ expect(await renderer.screenshot()).toBe(first);
45
+
46
+ for (let i = 0; i < 40; i++) renderer.scroll(0, 250);
47
+ expect(alive).toBeGreaterThan(0);
48
+ expect(alive).toBeLessThanOrEqual(256);
49
+ renderer.resize(400, 200, 2);
50
+ const zoomed = await renderer.screenshot();
51
+ expect((await new Bun.Image(Buffer.from(zoomed, "base64")).metadata()).width).toBe(800);
52
+ await renderer.loadDocument({ url: "https://paint.test/empty", status: 200, contentType: "text/html", source: "<!doctype html><body></body>" });
53
+ await renderer.whenLoaded();
54
+ expect(alive).toBe(0);
55
+ } finally {
56
+ renderer?.dispose();
57
+ CanvasKit.ParagraphBuilder.MakeFromFontCollection = original;
58
+ }
59
+ expect(alive).toBe(0);
60
+ });
61
+
62
+ test("screenshot encoding options are honored and separated in the frame cache", async () => {
63
+ const { CanvasKit: kit } = await loadGraphics();
64
+ const renderer = await createPageRenderer({});
65
+ const images = [];
66
+ try {
67
+ renderer.resize(400, 200, 1);
68
+ await renderer.loadDocument({ url: "https://capture.test/", contentType: "text/html",
69
+ source: "<body style='margin:0;color:#256;background:#def'><h1>Frame format cache</h1><p>Lossless PNG and JPEG capture</p>" });
70
+ await renderer.whenLoaded();
71
+ const png = await renderer.screenshot();
72
+ const jpeg = await renderer.screenshot({ format: "jpeg", quality: 85 });
73
+ expect((await new Bun.Image(Buffer.from(jpeg, "base64")).metadata()).format).toBe("jpeg");
74
+ const fast = await renderer.screenshot({ optimizeForSpeed: true });
75
+ const pixelBytes = value => {
76
+ const image = kit.MakeImageFromEncoded(Buffer.from(value, "base64"));
77
+ images.push(image);
78
+ return image.readPixels(0, 0, { width: 400, height: 200, colorType: kit.ColorType.RGBA_8888,
79
+ alphaType: kit.AlphaType.Unpremul, colorSpace: kit.ColorSpace.SRGB });
80
+ };
81
+ expect(pixelBytes(fast)).toEqual(pixelBytes(png));
82
+ expect(await renderer.screenshot()).toBe(png);
83
+ } finally {
84
+ for (const image of images) image.delete();
85
+ renderer.dispose();
86
+ }
87
+ });
@@ -65,3 +65,27 @@ test("an ended prefix or failed reopen never claims to have reached the target",
65
65
  const sound = track(pipe, 2);
66
66
  expect(await advanceAudio(() => sound, 8, { needsMore: () => false, reopen: async () => false })).toBe(false);
67
67
  });
68
+
69
+ test("a seek before the first audio epoch cannot keep reopening an unreachable prefix", async () => {
70
+ let sound = { position: 6, decoder: { sampleRate: 1, channels: 1 } }, reopened = 0;
71
+ expect(await advanceAudio(() => sound, 2, {
72
+ needsMore: () => true,
73
+ reopen: async () => { reopened++; sound = { ...sound, position: 6 }; return true; },
74
+ })).toBe(false);
75
+ expect(reopened).toBe(1);
76
+ });
77
+
78
+ test("audio epoch EOF resumes at the next absolute sample cursor", async () => {
79
+ const pipe = createBytePipe(); pipe.finish();
80
+ let sound = { position: 6, pending: null, endTime: 6, snapshotId: 1, openedGeneration: 1, pipe,
81
+ decoder: { sampleRate: 1, channels: 1, nextSamples: () => null } };
82
+ expect(await advanceAudio(() => sound, 7, {
83
+ needsMore: () => true,
84
+ reopen: async () => {
85
+ sound = { ...sound, position: 6, endTime: Infinity, snapshotId: 2, pending: new Float32Array([6, 7]) };
86
+ return true;
87
+ },
88
+ })).toBe(true);
89
+ expect(sound.position).toBe(7);
90
+ expect([...sound.pending]).toEqual([7]);
91
+ });
@@ -107,6 +107,28 @@ test("relative offsets and translate move a box and its content without affectin
107
107
  expect(box(".centered")).toMatchObject({ x: -20, y: 40 + 10 });
108
108
  });
109
109
 
110
+ test("transform lists compose pixel and percentage translations in order", () => {
111
+ const { box } = render(
112
+ `<div class="popup"></div><div class="scaled"></div><div class="reversed"></div><div class="math"></div>`,
113
+ `.popup,.scaled,.reversed,.math { position:absolute;left:0;top:0;width:100px;height:153px }
114
+ .popup { transform:translate(56px,780px) translate(0,-100%) }
115
+ .scaled { transform:scale(2,3) translate(10px,20%) translateX(5px) }
116
+ .reversed { transform:translate(10px,20%) scale(2,3) translateX(5px) }
117
+ .math { transform:translate(calc(25% + 10px),min(20%,40px)) translate(10%,5px) }`,
118
+ );
119
+ expect(box(".popup")).toEqual({x:56,y:627,width:100,height:153});
120
+ expect(box(".scaled")).toMatchObject({x:30,y:91.8});
121
+ expect(box(".reversed")).toMatchObject({x:20,y:30.6});
122
+ expect(box(".math")).toMatchObject({x:45,y:35.6});
123
+ });
124
+
125
+ test("individual translate precedes the transform list and preserves normal flow", () => {
126
+ const {box} = render('<div class="translated"></div><div class="next"></div>',
127
+ '.translated{width:100px;height:80px;translate:10px -50%;transform:scale(2) translate(5px,10px)}.next{height:20px}');
128
+ expect(box('.translated')).toEqual({x:20,y:-20,width:100,height:80});
129
+ expect(box('.next').y).toBe(80);
130
+ });
131
+
110
132
  test("an absolutely positioned ::after arrow is centred on its host's edge", () => {
111
133
  // The buninu.org step arrow: top:50%; right:-.75rem; translate(50%,-50%).
112
134
  const { box } = render(
@@ -0,0 +1,45 @@
1
+ import { expect, test } from "bun:test";
2
+ import { PreparedScriptCache, prepareClassicScript } from "../lib/renderer/prepared-script-cache.js";
3
+ import { escapeSesHtmlComments, rewriteClassicScriptSource } from "../lib/renderer/script-rewrite.js";
4
+
5
+ test("cached preparation preserves source semantics and does not retain evaluated globals", () => {
6
+ const source = "var result = supplied + 1; function read() { return result; }";
7
+ const prepared = prepareClassicScript(source);
8
+ const original = rewriteClassicScriptSource(source);
9
+ expect(prepared).toEqual({ code: escapeSesHtmlComments(original.code), parsed: true, strict: false });
10
+ expect(prepareClassicScript(source)).toBe(prepared);
11
+ const run = supplied => {
12
+ const page = { supplied, $buninu$ref: name => { throw new ReferenceError(name); } };
13
+ page.globalThis = page;
14
+ Function("page", "source", "with(page) { eval(source); }")(page, prepared.code);
15
+ return page;
16
+ };
17
+ const first = run(2), second = run(8);
18
+ expect(first.read()).toBe(3);
19
+ expect(second.read()).toBe(9);
20
+ expect(first.read).not.toBe(second.read);
21
+ expect(Object.isFrozen(prepared)).toBeTrue();
22
+ });
23
+
24
+ test("preparation retains a bounded working set and never keeps oversized sources", () => {
25
+ const calls = [];
26
+ const cache = new PreparedScriptCache({ maxBytes: 40, maxEntries: 2, prepare(source) {
27
+ calls.push(source); return { code: source, parsed: true, strict: false };
28
+ } });
29
+ cache.prepare("first"); cache.prepare("next"); cache.prepare("first"); cache.prepare("third");
30
+ expect(calls).toEqual(["first", "next", "third"]);
31
+ cache.prepare("next");
32
+ expect(calls).toEqual(["first", "next", "third", "next"]);
33
+ const oversized = "a".repeat(11);
34
+ cache.prepare(oversized); cache.prepare(oversized);
35
+ expect(calls.filter(source => source === oversized)).toHaveLength(2);
36
+ expect(cache.prepare("next").code).toBe("next");
37
+ expect(calls.filter(source => source === "next")).toHaveLength(2);
38
+ });
39
+
40
+ test("different strictness and invalid sources never share a prepared result", () => {
41
+ const cache = new PreparedScriptCache();
42
+ expect(cache.prepare("'use strict'; var value = 1;").strict).toBeTrue();
43
+ expect(cache.prepare("var value = 1;").strict).toBeFalse();
44
+ expect(cache.prepare("var = ;").parsed).toBeFalse();
45
+ });
@@ -135,6 +135,13 @@ test("measured device progress governs the media clock and bounded audio queue t
135
135
  const { createServer } = await import("node:net");
136
136
  let received = 0, played = 0, reports = 0;
137
137
  const times = [], errors = [], requests = [];
138
+ // The suite also runs CPU-heavy renderers in parallel. Observe protocol
139
+ // progress instead of assuming a polling timer ran within 120 ms.
140
+ const waitFor = async predicate => {
141
+ const deadline = Date.now() + 2000;
142
+ while (!predicate() && Date.now() < deadline) await Bun.sleep(10);
143
+ expect(predicate()).toBe(true);
144
+ };
138
145
  const scalar64 = (tag, value) => {
139
146
  const bytes = Buffer.alloc(9);
140
147
  bytes[0] = tag;
@@ -178,13 +185,13 @@ test("measured device progress governs the media clock and bounded audio queue t
178
185
  await Bun.sleep(80);
179
186
  expect(received).toBe(0); // a short prefix does not start/stop playback
180
187
  stream.write(new Uint8Array(1900));
181
- await Bun.sleep(180);
188
+ await waitFor(() => reports > 0 && received === 500 && times.length > 0);
182
189
  expect(reports).toBeGreaterThan(0);
183
190
  expect(received).toBe(500); // at most the 0.5 s queue budget, not 2 s
184
191
  expect(stream.queued).toBe(1500); // survives a renderer busy spell in the controller
185
192
  expect(times.at(-1)).toBe(0); // server/device hasn't played anything
186
193
  played = 100;
187
- await Bun.sleep(120);
194
+ await waitFor(() => times.at(-1) === 0.1 && received === 600);
188
195
  expect(times.at(-1)).toBeCloseTo(0.1, 5);
189
196
  expect(received).toBe(600);
190
197
  // A long network/producer pause is NOT permission to catch up by queuing seconds.
@@ -192,7 +199,7 @@ test("measured device progress governs the media clock and bounded audio queue t
192
199
  expect(times.at(-1)).toBeCloseTo(0.1, 5);
193
200
  expect(received).toBe(600);
194
201
  played = 600;
195
- await Bun.sleep(120);
202
+ await waitFor(() => times.at(-1) === 0.6 && received > 1000);
196
203
  expect(times.at(-1)).toBeCloseTo(0.6, 5);
197
204
  expect(received).toBeGreaterThan(1000);
198
205
  expect(received).toBeLessThanOrEqual(1100);
@@ -0,0 +1,40 @@
1
+ import { expect, test } from "bun:test";
2
+ import { loadGraphics } from "../lib/renderer/page-renderer.js";
3
+ import { RasterReadback } from "../lib/paint/raster-readback.js";
4
+
5
+ test("raster readback reuses native scratch while published shared frames stay immutable", async () => {
6
+ const { CanvasKit: kit } = await loadGraphics();
7
+ const malloc = kit._malloc, free = kit._free;
8
+ const pointers = new Set();
9
+ let allocations = 0, releases = 0;
10
+ kit._malloc = size => { const pointer = malloc(size); pointers.add(pointer); allocations++; return pointer; };
11
+ kit._free = pointer => { if (pointers.delete(pointer)) releases++; free(pointer); };
12
+ const surface = kit.MakeSurface(4, 4);
13
+ const capture = new RasterReadback(kit, { shared: true });
14
+ const read = (size = 4) => {
15
+ const image = surface.makeImageSnapshot();
16
+ try { return capture.read(image, size, size); } finally { image.delete(); }
17
+ };
18
+ try {
19
+ surface.getCanvas().clear(kit.Color(255, 0, 0, 0.5));
20
+ const first = read();
21
+ const afterFirst = allocations;
22
+ expect(first.buffer).toBeInstanceOf(SharedArrayBuffer);
23
+ expect([...first.subarray(0, 4)]).toEqual([255, 0, 0, 128]);
24
+ surface.getCanvas().clear(kit.Color(0, 0, 255, 1));
25
+ const second = read();
26
+ expect(allocations).toBe(afterFirst);
27
+ expect([...second.subarray(0, 4)]).toEqual([0, 0, 255, 255]);
28
+ expect([...first.subarray(0, 4)]).toEqual([255, 0, 0, 128]);
29
+ expect(read(2)).toHaveLength(16);
30
+ expect(allocations).toBe(afterFirst);
31
+ // Structured cloning shares pixels without another allocation or copy.
32
+ const clone = structuredClone(first);
33
+ expect(clone.buffer).toBeInstanceOf(SharedArrayBuffer);
34
+ expect([...clone]).toEqual([...first]);
35
+ } finally {
36
+ capture.dispose(); capture.dispose(); surface.dispose();
37
+ kit._malloc = malloc; kit._free = free;
38
+ }
39
+ expect(releases).toBe(allocations);
40
+ });
@@ -0,0 +1,152 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {readFileSync,existsSync,unlinkSync} from 'node:fs';
3
+ import {dirname} from 'node:path';
4
+ import {RendererHost} from '../lib/renderer/host.js';
5
+ import {PacketReader,encodePacket,listenCompositor,connectCompositor} from '../lib/renderer/compositor-channel.js';
6
+ import {RawFrames} from '../lib/paint/raw-frames.js';
7
+ import CanvasKitInit from '../usr/lib/canvaskit/canvaskit.js';
8
+ const kit=CanvasKitInit({wasmBinary:readFileSync(new URL('../usr/lib/canvaskit/canvaskit.wasm',import.meta.url))});
9
+
10
+ test('binary channel handles fragmented packets, rejects oversized input and authenticates peers',async()=>{
11
+ const results=[];const reader=new PacketReader((message,pixels)=>results.push([message,[...pixels]]));
12
+ const bytes=Buffer.concat([encodePacket({id:1},Uint8Array.of(1,2,3)),encodePacket({id:2})]);
13
+ for(let i=0;i<bytes.length;i++)reader.push(bytes.subarray(i,i+1));
14
+ expect(results).toEqual([[{id:1},[1,2,3]],[{id:2},[]]]);
15
+ const header=Buffer.alloc(8);header.writeUInt32LE(0xffffffff,0);
16
+ expect(()=>new PacketReader(()=>{}).push(header)).toThrow('size');
17
+ let reply;const got=new Promise(resolve=>reply=resolve);
18
+ const server=await listenCompositor((message,pixels)=>{server.send({id:message.id,length:pixels.length});},()=>{});
19
+ let peer;
20
+ try{
21
+ await expect(connectCompositor({...server.endpoint,token:'wrong'},()=>{},()=>{})).rejects.toThrow();
22
+ peer=await connectCompositor(server.endpoint,message=>reply(message),()=>{});
23
+ peer.send({id:8},new Uint8Array(1024));expect(await got).toEqual({id:8,length:1024});
24
+ }finally{peer?.close();server.close();}
25
+ });
26
+
27
+ test('raw file owner rejects malformed input and bounds unconsumed frames',()=>{
28
+ const store=new RawFrames();let path;
29
+ try{
30
+ expect(()=>store.write(new Uint8Array(3),1,1,'rgba')).toThrow('Invalid');
31
+ for(let i=0;i<16;i++)path=JSON.parse(store.write(new Uint8Array(4),1,1,'rgba')).path;
32
+ expect(()=>store.write(new Uint8Array(4),1,1,'rgba')).toThrow('stalled');
33
+ }finally{store.close();}
34
+ expect(existsSync(dirname(path))).toBe(false);
35
+ });
36
+
37
+ for(const compositor of [true,false])test(`raw screenshots match PNG and stay fresh after consumer deletion (compositor=${compositor})`,async()=>{
38
+ const host=new RendererHost({compositor});let directory;
39
+ try{
40
+ await host.start();await host.call('resize',32,24,1);
41
+ await host.call('loadDocument',{url:'https://page.test/',source:'<body style="margin:0;background:rgb(211,73,19)">',contentType:'text/html'});
42
+ await host.call('whenLoaded');
43
+ const png=await host.composite('screenshot',{format:'png'});
44
+ const CK=await kit;const image=CK.MakeImageFromEncoded(Buffer.from(png,'base64'));
45
+ const reference=image.readPixels(0,0,{width:32,height:24,colorType:CK.ColorType.RGBA_8888,alphaType:CK.AlphaType.Unpremul,colorSpace:CK.ColorSpace.SRGB});image.delete();
46
+ for(const format of ['rgba','bgra']){
47
+ const frame=JSON.parse(await host.composite('screenshot',{format}));directory=dirname(frame.path);
48
+ const pixels=readFileSync(frame.path);expect(pixels.length).toBe(32*24*4);
49
+ if(format==='bgra')for(let i=0;i<pixels.length;i+=4){const r=pixels[i];pixels[i]=pixels[i+2];pixels[i+2]=r;}
50
+ expect([...pixels]).toEqual([...reference]);unlinkSync(frame.path);
51
+ const next=JSON.parse(await host.composite('screenshot',{format}));expect(next.path).not.toBe(frame.path);expect(existsSync(next.path)).toBe(true);unlinkSync(next.path);
52
+ }
53
+ if(compositor){
54
+ const busy=host.call('evaluate',"(()=>{const end=Date.now()+1800;while(Date.now()<end);return 1})()");await Bun.sleep(150);
55
+ const start=performance.now();const raw=JSON.parse(await host.composite('screenshot',{format:'bgra'}));
56
+ expect(performance.now()-start).toBeLessThan(1400);unlinkSync(raw.path);await busy;
57
+ }
58
+ }finally{host.close();}
59
+ expect(existsSync(directory)).toBe(false);
60
+ },20000);
61
+
62
+ test('normal CDP routes raw requests to the controller without changing standard screenshots',async()=>{
63
+ const {createBrowser}=await import('../lib/headless-shell.js');
64
+ const {CdpServer}=await import('../lib/cdp-server.js');
65
+ const {CDP}=await import('../tools/browser-lab.js');
66
+ const browser=await createBrowser({spareRenderer:false});
67
+ const server=CdpServer.create(browser.context).listen(0,'127.0.0.1');
68
+ const cdp=new CDP(`ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);
69
+ let directory;
70
+ try{
71
+ await cdp.ready;const {targetId}=await cdp.send('Target.createTarget',{url:'about:blank'});
72
+ const {sessionId}=await cdp.send('Target.attachToTarget',{targetId,flatten:true});cdp.sessionId=sessionId;
73
+ await cdp.send('Emulation.setDeviceMetricsOverride',{width:16,height:9,deviceScaleFactor:1.75,mobile:false});
74
+ const metrics=await cdp.send('Page.getLayoutMetrics');
75
+ expect(metrics.cssVisualViewport).toMatchObject({pageX:0,pageY:0});
76
+ const {data}=await cdp.send('Page.captureScreenshot',{format:'bgra',clip:{x:0,y:0,width:16,height:15/1.75,scale:1}});
77
+ const frame=JSON.parse(data);directory=dirname(frame.path);
78
+ expect(frame).toMatchObject({version:1,transport:'file',format:'bgra',width:28,height:15});expect(existsSync(frame.path)).toBe(true);
79
+ cdp.events.length=0;
80
+ await cdp.send('Page.startScreencast',{format:'png',maxWidth:4,maxHeight:2,clip:{x:0,y:0,width:4,height:2,scale:1},everyNthFrame:1});
81
+ let event;for(let i=0;i<100;i++){event=cdp.events.find(e=>e.method==='Page.screencastFrame');if(event)break;await Bun.sleep(20);}
82
+ expect(event).toBeDefined();
83
+ const crop=Buffer.from(event.params.data,'base64');
84
+ // Native 1.75 density is preserved, rather than rescaling to maxWidth/maxHeight.
85
+ expect([crop.readUInt32BE(16),crop.readUInt32BE(20)]).toEqual([7,4]);
86
+ await cdp.send('Page.screencastFrameAck',{sessionId:event.params.sessionId});
87
+ await cdp.send('Page.stopScreencast');
88
+ const png=await cdp.send('Page.captureScreenshot',{format:'png'});
89
+ expect([...Buffer.from(png.data,'base64').subarray(0,4)]).toEqual([137,80,78,71]);
90
+ }finally{cdp.close();server.stop(true);browser.close();}
91
+ expect(existsSync(directory)).toBe(false);
92
+ },20000);
93
+
94
+ test('closing during listener startup rejects promptly and a new start remains usable',async()=>{
95
+ const host=new RendererHost();
96
+ const pending=host.start();host.close();
97
+ await expect(pending).rejects.toThrow('closed');
98
+ try{await host.start();expect(host.pid).toBeGreaterThan(0);}finally{host.close();}
99
+ },15000);
100
+
101
+ for(const compositor of [true,false])test(`fractional CDP clips preserve exact physical dimensions and raw pixels (compositor=${compositor})`,async()=>{
102
+ const host=new RendererHost({compositor});
103
+ try {
104
+ await host.start();await host.call('resize',16,9,1.75);
105
+ await host.call('loadDocument',{url:'https://clip.test/',contentType:'text/html',source:'<body style="margin:0;background:red"><div style="height:3px;background:blue"></div><div style="height:100px"></div>'});
106
+ await host.call('whenLoaded');
107
+ const full=JSON.parse(await host.composite('screenshot',{format:'rgba'}));
108
+ expect([full.width,full.height]).toEqual([28,16]);
109
+ const clip={x:0,y:0,width:16,height:15/1.75,scale:1};
110
+ const cropped=JSON.parse(await host.composite('screenshot',{format:'rgba',clip}));
111
+ expect([cropped.width,cropped.height]).toEqual([28,15]);
112
+ expect(readFileSync(cropped.path)).toEqual(readFileSync(full.path).subarray(0,28*15*4));
113
+ const CK=await kit;const png=CK.MakeImageFromEncoded(Buffer.from(await host.composite('screenshot',{format:'png',clip}),'base64'));
114
+ expect([png.width(),png.height()]).toEqual([28,15]);png.delete();
115
+ await host.composite('scroll',0,4);
116
+ const offset=await host.composite('scrollOffset');
117
+ const scrolled=JSON.parse(await host.composite('screenshot',{format:'bgra',clip:{...clip,y:offset.y}}));
118
+ expect([...readFileSync(scrolled.path).subarray(0,4)]).toEqual([0,0,255,255]);
119
+ await expect(host.composite('screenshot',{format:'rgba',clip:{...clip,width:Infinity}})).rejects.toThrow();
120
+ }finally{host.close();}
121
+ },20000);
122
+
123
+ test('scale-one full-width fractional clip borrows contiguous raster bytes',async()=>{
124
+ const {captureViewport}=await import('../lib/paint/frame.js');
125
+ const tile={x:0,y:0,scale:1.75,pixelWidth:28,pixelHeight:16,pixels:new Uint8Array(28*16*4)};
126
+ const frame=captureViewport(tile,0,0,16,9,{x:0,y:0,width:16,height:15/1.75,scale:1});
127
+ expect(frame.pixels.buffer).toBe(tile.pixels.buffer);
128
+ expect(frame.pixels.byteLength).toBe(28*15*4);
129
+ });
130
+
131
+ test('in-flight raw capture follows an origin renderer swap without exposing compositor closed',async()=>{
132
+ const {createBrowser}=await import('../lib/headless-shell.js');
133
+ const server=Bun.serve({port:0,fetch:()=>new Response('<body style="margin:0;background:rgb(211,73,19)">',{headers:{'content-type':'text/html'}})});
134
+ const browser=await createBrowser({spareRenderer:false});
135
+ const original=RendererHost.prototype.composite;let rejectCapture,armed=true;
136
+ const reached=Promise.withResolvers();
137
+ RendererHost.prototype.composite=function(method,...params){
138
+ if(armed&&method==='screenshot'){
139
+ armed=false;reached.resolve();return new Promise((_,reject)=>rejectCapture=reject);
140
+ }
141
+ return original.call(this,method,...params);
142
+ };
143
+ try{
144
+ const pending=browser.context.screenshot({format:'bgra'});
145
+ await reached.promise;
146
+ await browser.context.navigate(`http://127.0.0.1:${server.port}/`);
147
+ rejectCapture(Error('compositor closed'));
148
+ const frame=JSON.parse(await pending);const bytes=readFileSync(frame.path);
149
+ expect(frame.format).toBe('bgra');expect([...bytes.subarray(0,4)]).toEqual([19,73,211,255]);
150
+ unlinkSync(frame.path);
151
+ }finally{RendererHost.prototype.composite=original;browser.close();server.stop(true);}
152
+ },20000);
@@ -40,6 +40,34 @@ test("keeps node IDs stable across generations of the same DOM", () => {
40
40
  window.happyDOM.abort();
41
41
  });
42
42
 
43
+ test("closed details show only their first summary and update when opened", async () => {
44
+ const { StyleEngine } = await import("../lib/style/computed-style.js");
45
+ const { document, window } = parseHTMLDocument(`<details><p>Before summary</p><summary>Menu</summary><summary>Extra summary</summary><p>Contents</p></details>`);
46
+ const builder = new RenderTreeBuilder();
47
+ const styles = new StyleEngine();
48
+ const first = builder.build(document, styles.compute(document));
49
+ expect(renderTreeText(first)).toBe("▶ Menu");
50
+ document.querySelector("details").setAttribute("open", "");
51
+ const second = builder.build(document, styles.compute(document));
52
+ expect(renderTreeText(second)).toContain("▼ Menu");
53
+ expect(renderTreeText(second)).toContain("Extra summary");
54
+ expect(renderTreeText(second)).toContain("Contents");
55
+ expect(renderTreeText(second)).toContain("Before summary");
56
+ window.happyDOM.abort();
57
+ });
58
+
59
+ test("details without summary generate the default label and author CSS can override hidden", async () => {
60
+ const { StyleEngine } = await import("../lib/style/computed-style.js");
61
+ const { document, window } = parseHTMLDocument(`<details style="padding:20px;border:3px solid;color:red">Contents</details><p hidden style="display:block">Visible</p>`);
62
+ const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
63
+ expect(renderTreeText(tree)).toBe("▶ DetailsVisible");
64
+ const summary = [...tree.nodesById.values()].find(node => node.tagName === "SUMMARY");
65
+ expect(summary.style.padding).toEqual([0, 0, 0, 0]);
66
+ expect(summary.style.borderWidths).toEqual([0, 0, 0, 0]);
67
+ expect(summary.style.color).toBe("rgba(255, 0, 0, 1)");
68
+ window.happyDOM.abort();
69
+ });
70
+
43
71
  test("includes visible elements appended beside body under html", async () => {
44
72
  const { StyleEngine } = await import("../lib/style/computed-style.js");
45
73
  const { document, window } = parseHTMLDocument(`<html><head></head><body><p>body</p></body></html>`);
@@ -0,0 +1,11 @@
1
+ import { expect, test } from 'bun:test';
2
+
3
+ test('CanvasKit renderer lifetimes release native surfaces and failed video readbacks', async () => {
4
+ const child = Bun.spawn([process.execPath, new URL('./fixtures/renderer-memory.js', import.meta.url).pathname],
5
+ { stdout: 'pipe', stderr: 'pipe' });
6
+ const [code, stdout, stderr] = await Promise.all([
7
+ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
8
+ ]);
9
+ expect({ code, stderr }).toEqual({ code: 0, stderr: '' });
10
+ expect(stdout).toContain('renderer memory lifecycle passed');
11
+ }, 30_000);
@@ -17,3 +17,27 @@ test("finds :has() compounds and sibling-target compounds in a sheet", () => {
17
17
  expect(traits.descendantFeatures.has(".esc:a")).toBe(true);
18
18
  expect(traits.sibling && traits.firstChild && traits.lastChild).toBe(true);
19
19
  });
20
+
21
+ test("focus pseudo-class features retain their complete names", () => {
22
+ const traits = sheetTraits('.a:focus .x{}.b:focus-visible .x{}.c:focus-within .x{}');
23
+ expect(traits.descendantStateCompounds.map(entry=>entry.states)).toEqual([[":focus"],[":focus-visible"],[":focus-within"]]);
24
+ });
25
+
26
+ test("nested rule groups retain their first selector's invalidation dependencies", () => {
27
+ const traits=sheetTraits('@media(min-width:1px){@layer app{.panel:focus-within .label{color:red}.panel:has([data-open]){color:blue}}}');
28
+ expect(traits.descendantStateCompounds.map(entry=>entry.states)).toEqual([[":focus-within"]]);
29
+ expect([...traits.hasCompounds]).toEqual(['.panel:has([data-open])']);
30
+ expect(traits.descendantFeatures.has('.panel')).toBe(true);
31
+ });
32
+
33
+ test("functional alternatives constrain state invalidation without assuming negated requirements", () => {
34
+ const traits = sheetTraits(':is(.a,.b):focus-within .x{}:where(.outer > .target,button):focus .x{}:not(:is(.a,.b)):focus .x{}');
35
+ expect(traits.descendantStateCompounds.map(entry=>entry.alternatives))
36
+ .toEqual([[[[".a"],[".b"]]],[[[".target"],["t:button"]]],[]]);
37
+ });
38
+
39
+ test("observed attributes include nested, subject and escaped selectors", () => {
40
+ const traits = sheetTraits(String.raw`@media (min-width:1px){[data-open] .x:has([aria-expanded="true"]){} }
41
+ :is([title],[data-foo\:bar]){}.x[DATA-STATE]{}[ns|data-mode]{}[|data-empty]{}[data\2d escaped]{}`);
42
+ expect([...traits.observedAttributes].sort()).toEqual(["aria-expanded","data-empty","data-escaped","data-foo:bar","data-mode","data-open","data-state","title"]);
43
+ });
@@ -0,0 +1,63 @@
1
+ import { expect, test } from "bun:test";
2
+ import { SharedFramePool, FrameLeaseTracker } from "../lib/paint/shared-frame-pool.js";
3
+
4
+ test("pixel reuse waits for both current renderer ownership and every compositor lease", () => {
5
+ const pool = new SharedFramePool();
6
+ const first = pool.acquire(16); first.fill(7); pool.publish(1, first);
7
+ pool.publish(2, first); // Scroll publication with the same current pixels.
8
+ pool.release(1);
9
+ const second = pool.acquire(16); second.fill(9); pool.publish(3, second);
10
+ const third = pool.acquire(16);
11
+ expect(third.buffer).not.toBe(first.buffer);
12
+ expect([...first]).toEqual(Array(16).fill(7));
13
+ pool.release(2);
14
+ expect(pool.acquire(16).buffer).toBe(first.buffer);
15
+ pool.release(3);
16
+ expect(pool.acquire(16).buffer).not.toBe(second.buffer); // Still renderer's current tile.
17
+ pool.clear();
18
+ });
19
+
20
+ test("an old frame stays immutable while its asynchronous screenshot encoder is reading it", async () => {
21
+ const pool = new SharedFramePool();
22
+ const released = [];
23
+ const tracker = new FrameLeaseTracker(serial => { released.push(serial); pool.release(serial); });
24
+ const first = pool.acquire(8); first.fill(1); pool.publish(1, first); tracker.replace(1, first);
25
+ let finish;
26
+ const captured = tracker.capture(async pixels => { await new Promise(resolve => finish = resolve); return [...pixels]; });
27
+ const second = pool.acquire(8); second.fill(2); pool.publish(2, second); tracker.replace(2, second);
28
+ expect(released).toEqual([]);
29
+ const third = pool.acquire(8); third.fill(3); pool.publish(3, third); tracker.replace(3, third);
30
+ expect(released).toEqual([2]);
31
+ expect(pool.acquire(8).buffer).toBe(second.buffer);
32
+ finish();
33
+ expect(await captured).toEqual(Array(8).fill(1));
34
+ expect(released).toEqual([2, 1]);
35
+ expect(pool.acquire(8).buffer).toBe(first.buffer);
36
+ pool.clear();
37
+ });
38
+
39
+ test("idle pixel cache stays bounded and failed captures release their retired frames", async () => {
40
+ const pool = new SharedFramePool({ maxBuffers: 1, maxBytes: 8 });
41
+ const frames = Array.from({ length: 4 }, (_, i) => {
42
+ const pixels = pool.acquire(8); pool.publish(i, pixels); return pixels;
43
+ });
44
+ pool.release(0); pool.release(1); pool.release(2);
45
+ expect(pool.acquire(8).buffer).toBe(frames[2].buffer);
46
+ expect(frames.some(frame => pool.acquire(8).buffer === frame.buffer)).toBe(false);
47
+ const released = [];
48
+ const tracker = new FrameLeaseTracker(serial => released.push(serial));
49
+ tracker.replace(10, "old");
50
+ const pending = tracker.capture(() => { throw new Error("encoder failed"); });
51
+ tracker.replace(11, "new");
52
+ await expect(pending).rejects.toThrow("encoder failed");
53
+ expect(released).toEqual([10]);
54
+ });
55
+
56
+ test("a resize replaces idle warm-up buffers with reusable current-size buffers", () => {
57
+ const pool = new SharedFramePool({ maxBuffers: 2 });
58
+ const small = pool.acquire(4); pool.publish(1, small);
59
+ const medium = pool.acquire(8); pool.publish(2, medium); pool.release(1);
60
+ const large = pool.acquire(32); pool.publish(3, large); pool.release(2);
61
+ const next = pool.acquire(32); pool.publish(4, next); pool.release(3);
62
+ expect(pool.acquire(32).buffer).toBe(large.buffer);
63
+ });
@@ -0,0 +1,51 @@
1
+ import { afterAll, beforeAll, expect, test } from "bun:test";
2
+ import { RendererHost } from "../lib/renderer/host.js";
3
+
4
+ let renderer;
5
+ const requests = [];
6
+ beforeAll(async () => {
7
+ renderer = new RendererHost({ timeout: 20_000, fetch: async url => {
8
+ requests.push(String(url));
9
+ return new Response("#target { color: blue }", { headers: { "content-type": "text/css" } });
10
+ }});
11
+ await renderer.start();
12
+ });
13
+ afterAll(() => renderer?.close());
14
+ const read = (code = "") => renderer.call("evaluate", `(()=>{${code};return getComputedStyle(document.getElementById('target')).color})()`);
15
+ async function load(source) {
16
+ await renderer.call("loadDocument", { url: "https://style.test/", contentType: "text/html", status: 200, source });
17
+ await renderer.call("whenLoaded");
18
+ }
19
+
20
+ test("inline sheet media and type are respected after mutation and viewport changes", async () => {
21
+ await renderer.call("resize", 800, 600, 1);
22
+ await load(`<!doctype html><style>#target{color:red}</style>
23
+ <style media="print">#target{color:purple}</style>
24
+ <style id="responsive" media="screen and (max-width:500px)">#target{color:green}</style>
25
+ <style id="typed" type="text/plain">#target{color:blue}</style><div id="target">Text</div>`);
26
+ expect(await read()).toBe("rgba(255, 0, 0, 1)");
27
+ await renderer.call("resize", 400, 600, 1);
28
+ expect(await read()).toBe("rgba(0, 128, 0, 1)");
29
+ expect(await read("document.getElementById('responsive').setAttribute('media','print')")).toBe("rgba(255, 0, 0, 1)");
30
+ expect(await read("document.getElementById('typed').setAttribute('type','text/css')")).toBe("rgba(0, 0, 255, 1)");
31
+ });
32
+
33
+ test("linked sheet media can change without refetching and disabled sheets fetch on enable", async () => {
34
+ requests.length = 0;
35
+ await load(`<!doctype html><style>#target{color:red}</style>
36
+ <link id="linked" rel="stylesheet" href="/linked.css" media="print"><div id="target">Text</div>`);
37
+ expect(await read()).toBe("rgba(255, 0, 0, 1)");
38
+ expect(requests).toEqual(["https://style.test/linked.css"]);
39
+ expect(await read("document.getElementById('linked').setAttribute('media','screen')")).toBe("rgba(0, 0, 255, 1)");
40
+ expect(requests.length).toBe(1);
41
+ expect(await read("document.getElementById('linked').setAttribute('disabled','')")).toBe("rgba(255, 0, 0, 1)");
42
+ await load(`<!doctype html><style>#target{color:red}</style>
43
+ <link id="linked" rel="stylesheet" disabled href="/disabled.css"><div id="target">Text</div>`);
44
+ expect(await read()).toBe("rgba(255, 0, 0, 1)");
45
+ expect(requests.length).toBe(1);
46
+ await read("document.getElementById('linked').removeAttribute('disabled')");
47
+ const deadline = Date.now() + 3000;
48
+ while (await read() !== "rgba(0, 0, 255, 1)" && Date.now() < deadline) await Bun.sleep(10);
49
+ expect(await read()).toBe("rgba(0, 0, 255, 1)");
50
+ expect(requests).toEqual(["https://style.test/linked.css", "https://style.test/disabled.css"]);
51
+ });