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
package/test/svg.test.js CHANGED
@@ -39,6 +39,35 @@ test("parses transform lists into one matrix", () => {
39
39
  expect([a, b, c, d].map((value) => Math.round(value))).toEqual([0, 1, -1, 0]);
40
40
  });
41
41
 
42
+ test("SVG paint follows the CSS cascade, inherited HTML fill and descendant currentColor", () => {
43
+ const { document, window } = parseHTMLDocument(`<main style="fill:green;color:blue"><svg viewBox="0 0 24 24" style="opacity:.5">
44
+ <path id="inherited" d="M0 0h3v3z"/>
45
+ <path id="overridden" fill="red" stroke-width="1" d="M4 0h3v3z"/>
46
+ <g style="color:red;fill:currentColor;opacity:.5"><path d="M8 0h3v3z"/></g>
47
+ <path id="inline" fill="red" style="fill:yellow" d="M12 0h3v3z"/>
48
+ </svg></main>`);
49
+ const styles = new StyleEngine().compute(document, ["#overridden {fill:blue;stroke:currentColor;stroke-width:2;fill-opacity:50%;fill-rule:evenodd}"]);
50
+ const tree = new RenderTreeBuilder().build(document, styles);
51
+ const svg = [...tree.nodesById.values()].find(node => node.tagName === "SVG").svg;
52
+ expect(svg.shapes.map(shape => shape.fill)).toEqual([
53
+ "rgba(0, 128, 0, 1)", "rgba(0, 0, 255, 1)", "rgba(255, 0, 0, 1)", "rgba(255, 255, 0, 1)",
54
+ ]);
55
+ expect(svg.shapes[1]).toMatchObject({ stroke: "rgba(0, 0, 255, 1)", strokeWidth: 2, fillOpacity: .5, fillRule: "evenodd" });
56
+ expect(svg.shapes[0].fillOpacity).toBe(1); // Root opacity is applied by its CSS box, once.
57
+ expect(svg.shapes[2].fillOpacity).toBe(.5);
58
+ window.happyDOM.abort();
59
+ });
60
+
61
+ test("SVG CSS display removes a subtree while visible descendants can override visibility", () => {
62
+ const { document, window } = parseHTMLDocument(`<svg><g class="hidden"><path d="M0 0h3v3z"/><path class="visible" d="M4 0h3v3z"/></g>
63
+ <g class="none"><path class="visible" d="M8 0h3v3z"/></g></svg>`);
64
+ const styles = new StyleEngine().compute(document, [".hidden {visibility:hidden}.visible{visibility:visible}.none{display:none}"]);
65
+ const svg = extractSvg(document.querySelector("svg"), "black", styles);
66
+ expect(svg.shapes).toHaveLength(1);
67
+ expect(svg.shapes[0].d).toBe("M4 0h3v3z");
68
+ window.happyDOM.abort();
69
+ });
70
+
42
71
  test("inline SVG is a replaced box with margins and vertical-align in the line", () => {
43
72
  const { document, window } = parseHTMLDocument(`<body style="margin:0"><p style="margin:0">
44
73
  <svg viewBox="0 0 16 16" width="16" height="16" style="margin-right:8px;vertical-align:text-bottom"><path d="M0 0h16v16z"/></svg>Readme</p></body>`);
@@ -8,6 +8,32 @@ function boxFor(tree, layout, tagName) {
8
8
  return layout.boxes.find((box) => tree.nodesById.get(box.nodeId)?.tagName === tagName);
9
9
  }
10
10
 
11
+ test("display contents removes the wrapper box while preserving inherited text styles", () => {
12
+ const { document, window } = parseHTMLDocument(`<body style="margin:0;font-size:10px;line-height:10px">
13
+ before <div id="wrapper" style="display:contents;padding:50px;border:10px solid;position:absolute;color:red"><span>inside</span></div> after
14
+ </body>`);
15
+ const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
16
+ const layout = layoutText(tree, { x:0, y:0, width:200, measureText:text=>text.length });
17
+ expect(layout.boxes.some(box => tree.nodesById.get(box.nodeId)?.domNode?.id === "wrapper")).toBe(false);
18
+ expect(layout.fragments.length).toBe(1);
19
+ expect(layout.fragments[0].text.trim()).toBe("before inside after");
20
+ expect([...tree.nodesById.values()].find(node => node.type === "text" && node.text === "inside").style.color).toBe("rgba(255, 0, 0, 1)");
21
+ window.happyDOM.abort();
22
+ });
23
+
24
+ test("display contents promotes nested children into flex and grid item placement", () => {
25
+ for (const display of ["flex", "grid"]) {
26
+ const { document, window } = parseHTMLDocument(`<body style="margin:0"><main style="display:${display};grid-template-columns:20px 20px 20px;gap:5px">
27
+ <div style="display:contents"><i style="width:20px;height:10px"></i><span style="display:contents"><i style="width:20px;height:10px"></i></span></div>
28
+ <i style="width:20px;height:10px"></i></main></body>`);
29
+ const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
30
+ const layout = layoutText(tree, { x:0, y:0, width:200 });
31
+ expect(layout.boxes.filter(box => tree.nodesById.get(box.nodeId)?.tagName === "I").map(box => [box.x,box.y,box.width,box.height]))
32
+ .toEqual([[0,0,20,10],[25,0,20,10],[50,0,20,10]]);
33
+ window.happyDOM.abort();
34
+ }
35
+ });
36
+
11
37
  test("percentage and calc padding reserve an aspect-ratio box and reflow on resize", () => {
12
38
  const { document, window } = parseHTMLDocument(`<body style="margin:0">
13
39
  <div id="ratio" style="position:relative;padding-bottom:calc(100% * 9 / 16)">
@@ -339,6 +365,86 @@ function layoutFixture(html, css = "", width = 400) {
339
365
  return { ...parsed, tree, layout, box };
340
366
  }
341
367
 
368
+ test("column flex distributes growth, scaled shrink and constrained free space", () => {
369
+ for (const [children, expected] of [
370
+ ['<i style="flex:1;min-height:0"></i><b style="flex:2;min-height:0"></b>', [100,200]],
371
+ ['<i style="height:200px;min-height:0"></i><b style="height:400px;min-height:0"></b>', [100,200]],
372
+ ['<i style="flex:1;max-height:70px"></i><b style="flex:1"></b>', [70,230]],
373
+ ['<i style="flex:.25"></i><b style="flex:.25"></b>', [75,75]],
374
+ ]) {
375
+ const {window,box} = layoutFixture(`<main>${children}</main>`, 'main{display:flex;flex-direction:column;height:300px}');
376
+ expect([box('i').height,box('b').height]).toEqual(expected);
377
+ expect(box('b').y).toBe(expected[0]);
378
+ window.happyDOM.abort();
379
+ }
380
+ });
381
+
382
+ test("column flex automatic minimum protects content, while scrollable items can shrink", () => {
383
+ for (const [overflow,height] of [["visible",100],["auto",80],["hidden",80]]) {
384
+ const {window,box} = layoutFixture('<main><section><i></i></section></main>',
385
+ `main{display:flex;flex-direction:column;height:80px}section{overflow:${overflow}}i{display:block;height:100px}`);
386
+ expect(box('section').height).toBe(height);
387
+ window.happyDOM.abort();
388
+ }
389
+ });
390
+
391
+ test("an omitted flex basis uses content in an auto-height column capped by max-height", () => {
392
+ for (const [flex,expected] of [["1",250],["1 1",250],["1 1 0",0],["1 1 0%",250]]) {
393
+ const {window,box} = layoutFixture('<main><header></header><section><i></i></section></main>',
394
+ `main{display:flex;flex-direction:column;max-height:300px}header{height:50px;flex:none}
395
+ section{flex:${flex};min-height:0;overflow-y:auto}i{display:block;height:800px}`);
396
+ expect(box('section').height).toBe(expected);
397
+ expect(box('main').height).toBe(expected+50);
398
+ window.happyDOM.abort();
399
+ }
400
+ });
401
+
402
+ test("column auto margins absorb free space before justify-content", () => {
403
+ const {window,box} = layoutFixture('<main><i></i><b></b></main>',
404
+ 'main{display:flex;flex-direction:column;height:300px;justify-content:space-between}i{height:40px;margin-top:auto}b{height:60px;margin-bottom:auto}');
405
+ expect([box('i').y,box('b').y]).toEqual([100,140]);
406
+ window.happyDOM.abort();
407
+ });
408
+
409
+ test("stretched and inset-sized flex columns center within their final used height", () => {
410
+ for (const css of [
411
+ 'main{display:flex;height:300px}section{display:flex;flex-direction:column;justify-content:center}',
412
+ 'main{position:relative;height:300px}section{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center}',
413
+ ]) {
414
+ const {window,box} = layoutFixture('<main><section><i></i></section></main>',css+'i{display:block;height:60px;width:30px}');
415
+ expect(box('section').height).toBe(300);
416
+ expect(box('i').y).toBe(120);
417
+ window.happyDOM.abort();
418
+ }
419
+ });
420
+
421
+ test("nested column flex growth fills the viewport-sized modal wrapper", () => {
422
+ const {window,box} = layoutFixture('<main><section><article><i></i></article></section></main>',
423
+ 'main{display:flex;flex-direction:column;height:800px}section{display:flex;flex-direction:column;flex-grow:1;justify-content:center}article{display:flex;justify-content:center}i{height:300px;width:200px}');
424
+ expect(box('section').height).toBe(800);
425
+ expect(box('i')).toMatchObject({x:100,y:250,width:200,height:300});
426
+ window.happyDOM.abort();
427
+ });
428
+
429
+ test("percentage HTML image dimensions size flex items within their container", () => {
430
+ const {window,box,layout} = layoutFixture('<main><img width="100%" height="100%"></main>',
431
+ 'main{display:flex;width:84px;height:84px}');
432
+ expect(box('img')).toMatchObject({width:84,height:84});
433
+ const image=layout.fragments.flatMap(fragment=>fragment.runs).find(run=>run.type==='image');
434
+ expect(image).toMatchObject({width:84,height:84});
435
+ window.happyDOM.abort();
436
+ });
437
+
438
+ test("author auto sizing overrides HTML dimension hints and uses the image's intrinsic size", () => {
439
+ const {document,window}=parseHTMLDocument('<body style="margin:0"><img width="30" height="20" style="width:auto;height:auto"></body>');
440
+ const element=document.querySelector('img'),resources=new Map([[element,{width:120,height:60}]]);
441
+ const styles=new StyleEngine().compute(document);
442
+ const tree=new RenderTreeBuilder().build(document,styles,resources);
443
+ const layout=layoutText(tree,{width:400,lineHeight:20,measureText:text=>text.length*10,fontMetrics:()=>({ascent:16,height:20})});
444
+ expect(layout.fragments.flatMap(fragment=>fragment.runs).find(run=>run.type==='image')).toMatchObject({width:120,height:60});
445
+ window.happyDOM.abort();
446
+ });
447
+
342
448
  test("nests block boxes and includes ancestor padding and borders", () => {
343
449
  const { window, layout, box } = layoutFixture(
344
450
  `<section><div>text</div></section>`,
@@ -0,0 +1,61 @@
1
+ import { expect, test } from 'bun:test';
2
+ import wasmpeg from '../vendor/wasmpeg/src/js/index.js';
3
+ import { seekVideoKeyframe } from '../lib/media/video-keyframe.js';
4
+
5
+ test('seeking within a GOP longer than ten seconds preserves the decoded frame timestamp', async () => {
6
+ await wasmpeg.load();
7
+ const bytes = new Uint8Array(await Bun.file(new URL('./fixtures/long-video.mp4', import.meta.url)).arrayBuffer());
8
+ const decoder = await wasmpeg.decode(bytes);
9
+ try {
10
+ // This thirty-second fixture has a different picture at every frame,
11
+ // but only its first frame is a keyframe.
12
+ const firstHash = Bun.hash(decoder.nextFrame(32, 18));
13
+ for (const target of [15, 20, 25, 29]) {
14
+ const timestamp = seekVideoKeyframe(decoder, target);
15
+ expect(Bun.hash(decoder.nextFrame(32, 18))).toBe(firstHash);
16
+ expect(timestamp).toBe(0);
17
+ }
18
+ } finally { decoder.close(); }
19
+ });
20
+
21
+ test('a later MP4 initialization keeps absolute seek times, including seeks back across its first GOP', async () => {
22
+ await wasmpeg.load();
23
+ const bytes = new Uint8Array(await Bun.file(new URL('./fixtures/mse-switch-large.mp4', import.meta.url)).arrayBuffer());
24
+ const view = new DataView(bytes.buffer);
25
+ const visit = (from, to, callback) => {
26
+ for (let offset = from; offset + 8 <= to;) {
27
+ const size = view.getUint32(offset);
28
+ if (size < 8 || offset + size > to) break;
29
+ const type = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8));
30
+ callback(type, offset + 8);
31
+ if (['moov', 'trak', 'mdia', 'moof', 'traf'].includes(type)) visit(offset + 8, offset + size, callback);
32
+ offset += size;
33
+ }
34
+ };
35
+ let timescale;
36
+ visit(0, bytes.length, (type, data) => {
37
+ if (type === 'mdhd') timescale = view.getUint32(data + (bytes[data] === 1 ? 20 : 12));
38
+ });
39
+ let fragments = 0;
40
+ visit(0, bytes.length, (type, data) => {
41
+ if (type !== 'tfdt') return;
42
+ fragments++;
43
+ if (bytes[data] === 1) view.setBigUint64(data + 4, view.getBigUint64(data + 4) + BigInt(6 * timescale));
44
+ else view.setUint32(data + 4, view.getUint32(data + 4) + 6 * timescale);
45
+ });
46
+ expect(fragments).toBeGreaterThan(0);
47
+ const decoder = await wasmpeg.decode(bytes, { format: 'mp4' });
48
+ try {
49
+ const hashes = [];
50
+ for (let pixels; (pixels = decoder.nextFrame(32, 18));) hashes.push(Bun.hash(pixels));
51
+ expect(hashes).toHaveLength(60);
52
+ // The fixture's keyframes are 2s apart. After moving tfdt by six seconds,
53
+ // seek(8s) must return frame 20, not frame 0 or an offset-relative frame.
54
+ for (const [target, frame, expectedTime] of [[6, 0, 6], [7.9, 0, 6], [8, 20, 8], [11, 40, 10], [6.2, 0, 6], [5, 0, 6]]) {
55
+ const timestamp = seekVideoKeyframe(decoder, target, 6);
56
+ expect(Bun.hash(decoder.nextFrame(32, 18))).toBe(hashes[frame]);
57
+ expect(timestamp).toBeGreaterThanOrEqual(expectedTime);
58
+ expect(timestamp).toBeLessThan(expectedTime + 1 / decoder.fps);
59
+ }
60
+ } finally { decoder.close(); }
61
+ });
@@ -54,6 +54,75 @@ test("reopening at a keyframe does not show the prefix before the resume positio
54
54
  } finally { video.stop(); }
55
55
  });
56
56
 
57
+ test("a presentation keeps absolute epoch times and ends before replaced frames", async () => {
58
+ let at = 0;
59
+ const seeks = [], reports = [];
60
+ const video = new VideoPresentation({ decode: async () => ({ width: 2, height: 2, fps: 10,
61
+ seek(ms) { seeks.push(ms); at = 0; },
62
+ nextFrame(w, h) { return new Uint8Array(w * h * 4).fill(++at); }, close() {} }) },
63
+ () => {}, report => reports.push(report));
64
+ const tick = seconds => {
65
+ clearTimeout(video.timer);
66
+ video.timing(9, seconds - 12);
67
+ video.step();
68
+ };
69
+ try {
70
+ video.timing(9, 0.2);
71
+ await video.open({ id: 3, time: 12.2, startTime: 12, endTime: 12.5,
72
+ hasAudio: true, audioId: 9, audioStart: 12 });
73
+ expect(Math.min(...seeks)).toBe(12000);
74
+ expect(video.frame.time).toBeCloseTo(12.2);
75
+ expect(reports.some(report => report.end !== undefined)).toBe(false);
76
+ tick(12.49);
77
+ expect(video.frame.time).toBeCloseTo(12.4);
78
+ expect(reports.some(report => report.end !== undefined)).toBe(false);
79
+ tick(12.5);
80
+ tick(13);
81
+ expect(reports.filter(report => report.end !== undefined)).toEqual([{ id: 3, end: 12.5 }]);
82
+ expect(reports.filter(report => report.time !== undefined).every(report => report.time >= 12.2 && report.time < 12.5)).toBe(true);
83
+ } finally { video.stop(); }
84
+ });
85
+
86
+ test("a short epoch snapshot reports its actual EOF so playback can request more bytes", async () => {
87
+ let at = 0;
88
+ const reports = [];
89
+ const video = new VideoPresentation({ decode: async () => ({ width: 2, height: 2, fps: 10,
90
+ nextFrame(w, h) { return ++at <= 2 ? new Uint8Array(w * h * 4).fill(at) : null; }, close() {} }) },
91
+ () => {}, report => reports.push(report));
92
+ try {
93
+ video.timing(10, 0.25);
94
+ await video.open({ id: 4, time: 40, startTime: 40, endTime: 45,
95
+ hasAudio: true, audioId: 10, audioStart: 40 });
96
+ expect(video.frame.time).toBeCloseTo(40.1);
97
+ expect(reports.find(report => report.end !== undefined)?.end).toBeCloseTo(40.2);
98
+ } finally { video.stop(); }
99
+ });
100
+
101
+ test("a later epoch discards queued old frames at its boundary without accepting stale bindings", async () => {
102
+ const reports = [];
103
+ const video = new VideoPresentation({ decode: async () => ({ width: 2, height: 2, fps: 10,
104
+ nextFrame(w, h) { return new Uint8Array(w * h * 4); }, close() {} }) },
105
+ () => {}, report => reports.push(report));
106
+ try {
107
+ video.timing(11, 0);
108
+ await video.open({ id: 5, time: 10, startTime: 10,
109
+ hasAudio: true, audioId: 11, audioStart: 10 });
110
+ expect(video.queue.some(frame => frame.time > 10.3)).toBe(true);
111
+ video.bind(4, 11, 10, 10, 10.1);
112
+ expect(video.config.endTime).toBeUndefined();
113
+ video.bind(5, undefined, undefined, undefined, 10.3);
114
+ expect(video.config.audioId).toBe(11);
115
+ expect(video.config.audioStart).toBe(10);
116
+ expect(video.config.time).toBe(10);
117
+ clearTimeout(video.timer);
118
+ video.timing(11, 0.3);
119
+ video.step();
120
+ expect(video.frame.time).toBeCloseTo(10.2);
121
+ expect(reports.find(report => report.end !== undefined)).toEqual({ id: 5, end: 10.3 });
122
+ expect(video.queue).toHaveLength(0);
123
+ } finally { video.stop(); }
124
+ });
125
+
57
126
  test("video layer preserves opaque controls, translucent overlays and clipped pixels", () => {
58
127
  const base = new Uint8Array([0,0,0,255, 255,255,255,255, 0,0,128,255, 10,20,30,255]);
59
128
  const mask = new Uint8Array([255,255,255,0, 0,0,0,0, 127,127,127,0, 0,0,0,0]);
@@ -0,0 +1,36 @@
1
+ import {test, expect} from 'bun:test';
2
+ import {RendererHost} from '../lib/renderer/host.js';
3
+
4
+ test('viewport resize notifies window listeners with fresh geometry and repaints their changes',async()=>{
5
+ const host=new RendererHost({timeout:20000});
6
+ try {
7
+ await host.start();
8
+ await host.call('resize',320,240,1);
9
+ await host.call('loadDocument',{url:'https://resize.test/',contentType:'text/html',
10
+ source:'<body style="margin:0"><div id="box" style="width:100%;height:10px"></div><canvas id="canvas"></canvas></body>'});
11
+ await host.call('whenLoaded');
12
+ await host.call('evaluate',`window.events=[];window.propertyEvents=0;
13
+ window.onresize=()=>window.propertyEvents++;
14
+ window.addEventListener('resize',e=>{
15
+ const c=document.getElementById('canvas');
16
+ c.width=innerWidth;c.height=innerHeight;
17
+ events.push([innerWidth,innerHeight,devicePixelRatio,
18
+ document.getElementById('box').getBoundingClientRect().width,
19
+ e.target===window,e.bubbles,e.cancelable]);
20
+ document.body.style.backgroundColor='rgb(17,34,51)';
21
+ });`);
22
+ await host.call('resize',400,300,1.5);
23
+ expect(await host.call('evaluate','JSON.stringify(events)')).toBe('[[400,300,1.5,400,true,false,false]]');
24
+ expect(await host.call('evaluate','propertyEvents')).toBe(1);
25
+ expect(await host.call('evaluate',`[document.getElementById('canvas').width,document.getElementById('canvas').height].join(',')`)).toBe('400,300');
26
+ const png=Buffer.from(await host.composite('screenshot',{format:'png'}),'base64');
27
+ expect([png.readUInt32BE(16),png.readUInt32BE(20)]).toEqual([600,450]);
28
+ expect(await host.call('evaluate',"getComputedStyle(document.body).backgroundColor")).toBe('rgba(17, 34, 51, 1)');
29
+ await host.call('resize',400,300,1.5);
30
+ await host.call('resize',400,300,2);
31
+ expect(await host.call('evaluate','events.length')).toBe(1);
32
+ await host.call('resize',400,200,2);
33
+ expect(await host.call('evaluate','JSON.stringify(events[1])')).toBe('[400,200,2,400,true,false,false]');
34
+ expect(await host.call('evaluate','propertyEvents')).toBe(2);
35
+ }finally{host.close();}
36
+ },30000);
@@ -0,0 +1,32 @@
1
+ // Run with Bun on the host to measure capture delivery, not terminal FPS.
2
+ // Uses a local synthetic page, the normal renderer sandbox and changing crops.
3
+ import {RendererHost} from '../lib/renderer/host.js';
4
+ import {statSync,unlinkSync} from 'node:fs';
5
+ const count=Number(process.argv.find(a=>a.startsWith('--samples='))?.split('=')[1] ?? 30);
6
+ if(!Number.isInteger(count)||count<1||count>1000)throw Error('Invalid sample count');
7
+ const host=new RendererHost();
8
+ const reports=[];
9
+ try{
10
+ await host.start();await host.call('resize',1280,690,1.5);
11
+ await host.call('loadDocument',{url:'https://benchmark.test/',contentType:'text/html',source:'<body style="margin:0">'+Array.from({length:80},(_,i)=>`<div style="height:44px;background:rgb(${i*17%256},${i*37%256},${i*71%256})">Frame ${i} 中文 ABCDEFG 0123456789</div>`).join('')});
12
+ await host.call('whenLoaded');await Bun.sleep(500);
13
+ const formats=process.argv.find(a=>a.startsWith('--formats='))?.slice('--formats='.length).split(',') ?? ['png','jpeg','rgba','bgra','bgra','rgba','jpeg','png'];
14
+ for(const format of formats){
15
+ if(!['png','jpeg','rgba','bgra'].includes(format))throw Error('Invalid format');
16
+ const samples=[];let bytes=0;
17
+ for(let i=0;i<count+3;i++){
18
+ await host.composite('scroll',0,i%2?17:-17);
19
+ const start=performance.now();
20
+ const data=await host.composite('screenshot',{format,quality:85,optimizeForSpeed:true});
21
+ const ms=performance.now()-start;
22
+ if(format==='rgba'||format==='bgra'){
23
+ const f=JSON.parse(data);bytes=statSync(f.path).size;unlinkSync(f.path);
24
+ }else bytes=Buffer.byteLength(data,'base64');
25
+ if(i>=3)samples.push(ms);
26
+ }
27
+ samples.sort((a,b)=>a-b);
28
+ const report={format,count,meanMs:samples.reduce((a,b)=>a+b,0)/count,medianMs:samples[Math.floor(count/2)],bytes};
29
+ reports.push(report);console.log(JSON.stringify(report));
30
+ }
31
+ console.log(JSON.stringify({sandbox:host.sandbox,capture:[1920,1035],reports}));
32
+ }finally{host.close();}
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env bun
2
+ // Development-only CDP comparison runner. No package dependencies or page patches.
3
+ import { mkdirSync } from 'node:fs';
4
+ import { resolve, join } from 'node:path';
5
+
6
+ export class CDP {
7
+ constructor(url) {
8
+ this.ws = new WebSocket(url); this.next = 1; this.pending = new Map(); this.events = [];
9
+ this.ready = new Promise((yes, no) => { this.ws.addEventListener('open', yes, {once:true}); this.ws.addEventListener('error', no, {once:true}); });
10
+ this.ws.addEventListener('message', ({data}) => {
11
+ const m = JSON.parse(data);
12
+ if (m.id) { const p = this.pending.get(m.id); if (!p) return; this.pending.delete(m.id); clearTimeout(p.timer); m.error ? p.no(Error(JSON.stringify(m.error))) : p.yes(m.result); }
13
+ else { this.events.push({time:performance.now(), ...m}); if(this.events.length>3000)this.events.shift(); }
14
+ });
15
+ this.ws.addEventListener('close', () => { for(const p of this.pending.values()){clearTimeout(p.timer);p.no(Error('CDP closed'));} this.pending.clear(); });
16
+ }
17
+ async send(method, params={}, timeout=30000) {
18
+ await this.ready;
19
+ return new Promise((yes,no) => { const id=this.next++; const timer=setTimeout(()=>{this.pending.delete(id);no(Error(`${method} timed out`));},timeout);this.pending.set(id,{yes,no,timer});this.ws.send(JSON.stringify({id,method,params,...(this.sessionId?{sessionId:this.sessionId}:{})})); });
20
+ }
21
+ async evaluate(expression, timeout=10000) { const r=await this.send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true},timeout);if(r.exceptionDetails)throw Error(r.exceptionDetails.text+' '+(r.exceptionDetails.exception?.description||''));return r.result?.value; }
22
+ close(){this.ws.close();}
23
+ }
24
+ export async function launch(engine, dir, extra=[]) {
25
+ mkdirSync(dir,{recursive:true});
26
+ const log=join(dir,engine+'.log');
27
+ const cmd=engine==='chromium' ? ['/usr/local/bin/chromium-headless-shell','--no-sandbox','--disable-dev-shm-usage','--remote-debugging-port=0','--window-size=1280,800',...extra,'about:blank'] : [process.execPath,resolve('buninu-browser.js'),'--remote-debugging-port=0',...extra];
28
+ const proc=Bun.spawn(cmd,{stdout:Bun.file(join(dir,engine+'.stdout')),stderr:'pipe',env:{...process.env,BUNINU_LOG:join(dir,'buninu-diagnostic.log')}});
29
+ let stderr='';const writer=Bun.file(log).writer();
30
+ (async()=>{for await(const chunk of proc.stderr){writer.write(chunk);stderr+=Buffer.from(chunk).toString();if(stderr.length>200000)stderr=stderr.slice(-100000);}await writer.end();})();
31
+ let cdp;
32
+ try {
33
+ let port;for(let i=0;i<200;i++){const m=stderr.match(/DevTools listening on ws:\/\/[^:]+:(\d+)/);if(m){port=Number(m[1]);break;}if(proc.exitCode!==null)throw Error(`engine exited ${proc.exitCode}: ${stderr.slice(-1000)}`);await Bun.sleep(50);}
34
+ if(!port){proc.kill();throw Error('CDP endpoint missing');}
35
+ const version=await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
36
+ cdp=new CDP(version.webSocketDebuggerUrl);await cdp.ready;
37
+ const {targetId}=await cdp.send('Target.createTarget',{url:'about:blank'});
38
+ const {sessionId}=await cdp.send('Target.attachToTarget',{targetId,flatten:true});
39
+ cdp.sessionId=sessionId;
40
+ await cdp.send('Page.enable');await cdp.send('Runtime.enable');
41
+ await cdp.send('Emulation.setDeviceMetricsOverride',{width:1280,height:800,deviceScaleFactor:1,mobile:false});
42
+ return {cdp,proc,stop:async()=>{cdp.close();proc.kill();await proc.exited;}};
43
+ } catch(error) { cdp?.close();proc.kill();await proc.exited;throw error; }
44
+ }
45
+ const describe = `(()=>({url:location.href,title:document.title,text:document.body?.innerText?.slice(0,12000),nodes:document.querySelectorAll('*').length,readyState:document.readyState,controls:[...document.querySelectorAll('a,button,input,select,summary,[role="button"]')].map(e=>{const r=e.getBoundingClientRect();return {tag:e.tagName,text:(e.innerText||e.getAttribute('aria-label')||e.getAttribute('placeholder')||'').trim().slice(0,100),href:e.getAttribute('href'),id:e.id,x:r.x,y:r.y,width:r.width,height:r.height};}).filter(e=>e.width>0&&e.height>0&&e.x<1280&&e.y<800&&e.x+e.width>0&&e.y+e.height>0).slice(0,100)}))()`;
46
+ export async function snapshot(cdp,dir,name) {
47
+ const start=performance.now(); const {data}=await cdp.send('Page.captureScreenshot',{format:'png'});const captureMs=performance.now()-start;
48
+ await Bun.write(join(dir,name+'.png'),Buffer.from(data,'base64'));
49
+ let dom;try{dom=await cdp.evaluate(describe);}catch(error){dom={error:error.message};}
50
+ await Bun.write(join(dir,name+'.json'),JSON.stringify({...dom,captureMs},null,2));return {captureMs,...dom};
51
+ }
52
+ export async function runCase({engine,url,dir,actions=[],settle=1500}) {
53
+ const started=performance.now();let browser;const report={engine,url,actions:[]};
54
+ try {
55
+ browser=await launch(engine,dir);const {cdp}=browser;report.startupMs=performance.now()-started;
56
+ const nav=performance.now();await cdp.send('Page.navigate',{url},45000);report.navigateReplyMs=performance.now()-nav;
57
+ const deadline=performance.now()+30000;
58
+ while(performance.now()<deadline&&!cdp.events.some(e=>e.method==='Page.loadEventFired'&&e.time>=nav))await Bun.sleep(100);
59
+ const loaded=cdp.events.find(e=>e.method==='Page.loadEventFired'&&e.time>=nav);report.loadMs=loaded?loaded.time-nav:null;
60
+ await Bun.sleep(settle);report.initial=await snapshot(cdp,dir,'initial');
61
+ for(let i=0;i<actions.length;i++){
62
+ const action=actions[i],at=performance.now();let error=null;
63
+ try {
64
+ if(action.click||action.clickText){const point=await cdp.evaluate(`(()=>{const candidates=[...document.querySelectorAll(${JSON.stringify(action.click||'button,a,summary,[role="button"],[role="menuitem"]')})];const e=candidates.find(e=>{const r=e.getBoundingClientRect(),s=getComputedStyle(e);return r.width>0&&r.height>0&&s.visibility!=='hidden'&&(!${JSON.stringify(action.clickText||'')}||(e.innerText||e.getAttribute('aria-label')||'').trim()===${JSON.stringify(action.clickText||'')});});if(!e)return null;e.scrollIntoView({block:'nearest',inline:'nearest'});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};})()`);if(!point)throw Error('visible selector not found');for(const type of ['mousePressed','mouseReleased'])await cdp.send('Input.dispatchMouseEvent',{type,...point,button:'left',clickCount:1});}
65
+ else if(action.point){for(const type of ['mousePressed','mouseReleased'])await cdp.send('Input.dispatchMouseEvent',{type,x:action.point[0],y:action.point[1],button:'left',clickCount:1});}
66
+ else if(action.type)await cdp.send('Input.insertText',{text:action.type});
67
+ else if(action.key){for(const type of ['keyDown','keyUp'])await cdp.send('Input.dispatchKeyEvent',{type,key:action.key,code:action.key,windowsVirtualKeyCode:action.key==='Enter'?13:action.key==='Escape'?27:0});}
68
+ else if(action.scroll)await cdp.send('Input.dispatchMouseEvent',{type:'mouseWheel',x:640,y:500,deltaY:action.scroll,deltaX:0});
69
+ else if(action.evaluate)report.lastEvaluation=await cdp.evaluate(action.evaluate);
70
+ }catch(e){error=e.message;}
71
+ await Bun.sleep(action.wait??600);
72
+ const state=await snapshot(cdp,dir,`action-${i+1}`);report.actions.push({action,error,elapsedMs:performance.now()-at,state});
73
+ }
74
+ report.errors=cdp.events.filter(e=>e.method==='Runtime.exceptionThrown').slice(-20);
75
+ }catch(error){report.error=error.stack;}finally{await browser?.stop();report.totalMs=performance.now()-started;await Bun.write(join(dir,'report.json'),JSON.stringify(report,null,2));}
76
+ return report;
77
+ }
78
+ if(import.meta.main){const [engine,url,dir,...rest]=process.argv.slice(2);if(!engine||!url||!dir)throw Error('usage: bun tools/browser-lab.js ENGINE URL OUTPUT_DIR [ACTIONS_JSON]');const result=await runCase({engine,url,dir:resolve(dir),actions:rest[0]?JSON.parse(rest[0]):[]});console.log(JSON.stringify({engine,url,error:result.error,startupMs:result.startupMs,loadMs:result.loadMs,captureMs:result.initial?.captureMs,title:result.initial?.title,actions:result.actions.map(a=>({action:a.action,error:a.error,url:a.state?.url}))}));}
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bun
2
+ // End-to-end bounded DOM/canvas/image churn, navigation and CDP capture soak.
3
+ // Usage: bun tools/browser-soak.js buninu /tmp/browser-soak 900
4
+ import { join, resolve } from 'node:path';
5
+ import { launch } from './browser-lab.js';
6
+ import { encodeImage } from '../lib/paint/frame.js';
7
+ import { processMemory } from './process-memory.js';
8
+
9
+ const [engine = 'buninu', directory = '/tmp/browser-soak', seconds = '600'] = process.argv.slice(2);
10
+ const duration = Math.max(1, Number(seconds)) * 1000;
11
+ const dir = resolve(directory);
12
+ const rgba = new Uint8Array(32 * 32 * 4);
13
+ for (let i = 0; i < rgba.length; i += 4) rgba.set([30, 120, 210, 255], i);
14
+ const png = await encodeImage(rgba, 32, 32);
15
+ let requests = 0;
16
+ const server = Bun.serve({ port: 0, fetch(request) {
17
+ requests++;
18
+ const url = new URL(request.url);
19
+ if (url.pathname === '/pixel.png') return new Response(png, { headers: { 'content-type': 'image/png', 'cache-control': 'max-age=3600' } });
20
+ return new Response(`<!doctype html><meta charset="utf-8"><title>Browser soak ${url.search}</title>
21
+ <style>body{margin:0;font:16px sans-serif;background:#fafafa}header{position:sticky;top:0;background:#135;color:white;padding:12px;z-index:2}main{padding:12px}.card{display:flex;gap:12px;align-items:center;padding:8px;border-bottom:1px solid #ddd}canvas{display:block}button{font:inherit}</style>
22
+ <header>Navigation ${url.search} · <span id="counter">0</span> <button id="toggle">Toggle</button></header>
23
+ <main><canvas id="paint" width="800" height="120"></canvas><section id="cards">${Array.from({length:160}, (_,i)=>`<div class="card"><img src="/pixel.png" width="32" height="32"><span>Row ${i}: Browser rendering, memory and input exercise 中文 🙂</span></div>`).join('')}</section></main>
24
+ <script>
25
+ let tick=0; const paint=document.getElementById('paint'); const context=paint.getContext('2d');
26
+ const counter=document.getElementById('counter'); const cards=document.getElementById('cards');
27
+ document.getElementById('toggle').onclick=()=>document.body.style.background=tick%2?'#def':'#fed';
28
+ setInterval(()=>{tick++;counter.textContent=String(tick);context.fillStyle=tick%2?'#69c':'#c96';context.fillRect((tick*11)%760,20,40,80);
29
+ if(tick%10===0){const card=cards.firstElementChild;card.querySelector('span').textContent='Recycled row '+tick;cards.appendChild(card);}
30
+ if(tick%20===0){for(let i=0;i<8;i++){const c=document.createElement('canvas');c.width=800;c.height=600;c.getContext('2d').fillRect(0,0,800,600);}}
31
+ },100);
32
+ </script>`, { headers: { 'content-type': 'text/html' } });
33
+ } });
34
+
35
+ let browser;
36
+ const samples = [];
37
+ const errors = [];
38
+ const started = performance.now();
39
+ let navigations = 0;
40
+ try {
41
+ browser = await launch(engine, dir);
42
+ const writer = Bun.file(join(dir, 'samples.jsonl')).writer();
43
+ const { cdp } = browser;
44
+ let nextNavigation = 0;
45
+ while (performance.now() - started < duration) {
46
+ const elapsed = performance.now() - started;
47
+ if (elapsed >= nextNavigation) {
48
+ const host = Math.floor(navigations / 4) % 2 ? 'localhost' : '127.0.0.1';
49
+ await cdp.send('Page.navigate', { url: `http://${host}:${server.port}/page?n=${navigations++}` });
50
+ nextNavigation = elapsed + 15000;
51
+ await Bun.sleep(300);
52
+ }
53
+ try {
54
+ await cdp.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: 600, y: 500, deltaY: samples.length % 20 < 10 ? 180 : -180, deltaX: 0 });
55
+ const before = performance.now();
56
+ const { data } = await cdp.send('Page.captureScreenshot', { format: 'png', optimizeForSpeed: true });
57
+ const captureMs = performance.now() - before;
58
+ const counter = await cdp.evaluate(`document.getElementById('counter')?.textContent`);
59
+ const processes = await processMemory(browser.proc.pid);
60
+ const sample = { elapsedMs: Math.round(elapsed), navigation: navigations, counter, captureMs, encodedLength: data.length, rssMiB: processes ? processes.reduce((sum,p)=>sum+p.rssKiB,0)/1024 : null, processes };
61
+ samples.push(sample);writer.write(JSON.stringify(sample)+'\n');writer.flush();
62
+ if (samples.length % 30 === 0) await Bun.write(join(dir, 'latest.png'), Buffer.from(data, 'base64'));
63
+ if (samples.length % 60 === 0) console.log(JSON.stringify(sample));
64
+ } catch (error) { errors.push({ elapsedMs: Math.round(elapsed), error: error.message }); }
65
+ await Bun.sleep(1000);
66
+ }
67
+ await writer.end();
68
+ } finally {
69
+ await browser?.stop();server.stop(true);
70
+ const mean = values => values.length ? values.reduce((a,b)=>a+b,0)/values.length : null;
71
+ const report = { engine, elapsedMs: performance.now()-started, navigations, requests, samples: samples.length, errors,
72
+ peakRssMiB: Math.max(0,...samples.map(s=>s.rssMiB)), firstMinuteRssMiB: mean(samples.filter(s=>s.elapsedMs>=60000&&s.elapsedMs<120000).map(s=>s.rssMiB)),
73
+ lastMinuteRssMiB: mean(samples.filter(s=>s.elapsedMs>duration-60000).map(s=>s.rssMiB)), captureMeanMs: mean(samples.map(s=>s.captureMs)) };
74
+ await Bun.write(join(dir, 'report.json'), JSON.stringify(report,null,2));console.log(JSON.stringify(report));
75
+ }
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env bun
2
+ // Exercise redirects through real HTTP servers, using Chromium as a reference.
3
+ import { launch } from "./browser-lab.js";
4
+ import { resolve } from "node:path";
5
+
6
+ const [engine = "buninu", output = "/tmp/buninu-network-reference"] = process.argv.slice(2);
7
+ const calls = [];
8
+ let source, target;
9
+ const handler = async request => {
10
+ const url = new URL(request.url);
11
+ calls.push({ method: request.method, path: url.pathname, host: url.host, origin: request.headers.get("origin"),
12
+ cookie: request.headers.get("cookie"), authorization: request.headers.get("authorization"),
13
+ preflight: request.headers.get("access-control-request-headers") });
14
+ const cors = { "access-control-allow-origin": request.headers.get("origin") ?? source,
15
+ "access-control-allow-credentials": "true", "access-control-allow-methods": "PUT", "access-control-allow-headers": "x-custom" };
16
+ if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
17
+ if (url.pathname === "/") return new Response("<!doctype html><title>Network reference</title><p>Redirect fixture</p>", { headers: { "content-type": "text/html" } });
18
+ if (url.pathname.startsWith("/redirect/")) return new Response(null, { status: 307, headers: { location: target + url.pathname.slice(9) } });
19
+ if (url.pathname === "/chain") return new Response(null, { status: 302, headers: { ...cors, location: source + "/finish" } });
20
+ return new Response("visible body", { headers: { ...(url.pathname === "/denied" ? {} : cors),
21
+ "x-private": "hidden", "x-public": "visible", "access-control-expose-headers": "x-public" } });
22
+ };
23
+ const first = Bun.serve({ port: 0, fetch: handler });
24
+ const second = Bun.serve({ port: 0, fetch: handler });
25
+ source = `http://localhost:${first.port}`; target = `http://localhost:${second.port}`;
26
+ let browser;
27
+ const report = { engine, cases: [] };
28
+ try {
29
+ browser = await launch(engine, resolve(output));
30
+ await browser.cdp.send("Page.navigate", { url: source + "/" });
31
+ await Bun.sleep(300);
32
+ await browser.cdp.evaluate('document.cookie="private=fixture; SameSite=Lax"; undefined;');
33
+ for (const [name, url, init] of [
34
+ ["denied", source + "/redirect/denied", {}],
35
+ ["allowed", source + "/redirect/allowed", {}],
36
+ ["same-origin", source + "/redirect/allowed", { mode: "same-origin" }],
37
+ ["opaque", source + "/redirect/allowed", { mode: "no-cors" }],
38
+ ["preflight", source + "/redirect/allowed", { method: "PUT", headers: { authorization: "Bearer fixture", "x-custom": "yes" }, body: "body" }],
39
+ ["chain", target + "/chain", {}],
40
+ ]) {
41
+ calls.length = 0;
42
+ const result = await browser.cdp.evaluate(`(async()=>{try {const r=await fetch(${JSON.stringify(url)},${JSON.stringify(init)});
43
+ return {type:r.type,status:r.status,body:await r.text(),private:r.headers.get('x-private'),public:r.headers.get('x-public')};
44
+ } catch(e) {return {error:e.name};}})()`);
45
+ report.cases.push({ name, result, calls: calls.slice() });
46
+ }
47
+ await Bun.write(resolve(output, "network.json"), JSON.stringify(report, null, 2));
48
+ console.log(JSON.stringify(report));
49
+ } finally { await browser?.stop(); first.stop(true); second.stop(true); }
@@ -0,0 +1,38 @@
1
+ import { readdir } from 'node:fs/promises';
2
+
3
+ // Android kernels may omit /proc/PID/task/PID/children. PPid in status works
4
+ // there too; return null if this platform does not expose process memory.
5
+ export async function processMemory(rootPid) {
6
+ let entries;
7
+ try { entries = (await readdir('/proc')).filter(name => /^\d+$/.test(name)); }
8
+ catch { return null; }
9
+ const records = (await Promise.all(entries.map(async name => {
10
+ try {
11
+ const status = await Bun.file(`/proc/${name}/status`).text();
12
+ return { pid: Number(name), parent: Number(status.match(/^PPid:\s+(\d+)/m)?.[1]), rssKiB: Number(status.match(/^VmRSS:\s+(\d+)/m)?.[1] ?? 0) };
13
+ } catch { return null; }
14
+ }))).filter(Boolean);
15
+ const children = new Map();
16
+ for (const record of records) {
17
+ if (!children.has(record.parent)) children.set(record.parent, []);
18
+ children.get(record.parent).push(record);
19
+ }
20
+ const root = records.find(record => record.pid === rootPid);
21
+ if (!root) return null;
22
+ const found = [root];
23
+ for (let index = 0; index < found.length; index++) found.push(...(children.get(found[index].pid) ?? []));
24
+ return found;
25
+ }
26
+
27
+ if (import.meta.main) {
28
+ const [pid, output, seconds = '600'] = process.argv.slice(2);
29
+ const writer = Bun.file(output).writer();
30
+ const start = Date.now();
31
+ while (Date.now() - start < Number(seconds) * 1000) {
32
+ const processes = await processMemory(Number(pid));
33
+ if (!processes) break;
34
+ writer.write(JSON.stringify({elapsedMs:Date.now()-start,rssMiB:processes.reduce((sum,p)=>sum+p.rssKiB,0)/1024,processes})+'\n');
35
+ writer.flush();await Bun.sleep(1000);
36
+ }
37
+ await writer.end();
38
+ }