buninu-browser 0.5.1 → 0.7.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 (136) hide show
  1. package/README.md +13 -6
  2. package/index.js +10 -6
  3. package/lib/audio/pcm.js +11 -0
  4. package/lib/audio/pulse-client.js +7 -2
  5. package/lib/cdp-server.js +94 -46
  6. package/lib/happy-dom/document-base.js +29 -1
  7. package/lib/happy-dom/mutation-observer.js +157 -0
  8. package/lib/happy-dom/parser.js +2 -0
  9. package/lib/headless-shell.js +49 -9
  10. package/lib/layout/repaint.js +89 -0
  11. package/lib/media/mp4-keyframes.js +144 -0
  12. package/lib/media/video-keyframe.js +29 -1
  13. package/lib/media/webm-snapshots.js +282 -0
  14. package/lib/network/cookie-jar.js +18 -2
  15. package/lib/paint/display-list.js +4 -3
  16. package/lib/paint/frame.js +12 -5
  17. package/lib/render-tree/index.js +3 -0
  18. package/lib/renderer/animation-frames.js +76 -0
  19. package/lib/renderer/bindings.js +2 -0
  20. package/lib/renderer/compositor-channel.js +95 -10
  21. package/lib/renderer/compositor.js +54 -18
  22. package/lib/renderer/document-cookies.js +87 -0
  23. package/lib/renderer/font-loading.js +60 -16
  24. package/lib/renderer/host.js +48 -19
  25. package/lib/renderer/idle-callbacks.js +109 -0
  26. package/lib/renderer/main.js +5 -1
  27. package/lib/renderer/media-source.js +4 -0
  28. package/lib/renderer/page-realm.js +159 -49
  29. package/lib/renderer/page-renderer.js +567 -197
  30. package/lib/renderer/resource-elements.js +88 -0
  31. package/lib/renderer/script-rewrite.js +18 -1
  32. package/lib/renderer/seccomp.js +6 -4
  33. package/lib/renderer/timers.js +71 -0
  34. package/lib/renderer/video-channel.js +94 -0
  35. package/lib/renderer/video-mask.js +115 -0
  36. package/lib/renderer/video-presentation.js +304 -39
  37. package/lib/renderer/video-worker.js +46 -0
  38. package/lib/renderer/workers.js +7 -17
  39. package/lib/style/computed-style.js +257 -61
  40. package/package.json +1 -1
  41. package/test/animation-frames.test.js +67 -0
  42. package/test/canvas-style-snapshot.test.js +18 -0
  43. package/test/cdp-stream.test.js +96 -3
  44. package/test/cdp-viewers.test.js +43 -0
  45. package/test/compositor-channel-ownership.test.js +194 -0
  46. package/test/compositor.test.js +27 -1
  47. package/test/computed-style-binding.test.js +49 -0
  48. package/test/computed-style-connectivity.test.js +13 -0
  49. package/test/computed-style.test.js +86 -0
  50. package/test/cookie-sync.test.js +82 -0
  51. package/test/document-base-cache.test.js +103 -0
  52. package/test/document-cookies.test.js +117 -0
  53. package/test/duplicate-style-keys.test.js +11 -0
  54. package/test/fixtures/animation-frames.js +56 -0
  55. package/test/fixtures/canvas-style-snapshot.js +81 -0
  56. package/test/fixtures/computed-style-connectivity.js +101 -0
  57. package/test/fixtures/computed-style-lazy.js +135 -0
  58. package/test/fixtures/duplicate-style-keys.js +97 -0
  59. package/test/fixtures/fake-renderer.js +12 -0
  60. package/test/fixtures/font-definition-invalidation.js +97 -0
  61. package/test/fixtures/font-metrics-invalidation.js +87 -0
  62. package/test/fixtures/full-style-identity.js +36 -0
  63. package/test/fixtures/hidden-style-engine.js +260 -0
  64. package/test/fixtures/hidden-style-reentry.js +232 -0
  65. package/test/fixtures/hidden-style-renderer.js +203 -0
  66. package/test/fixtures/layout-repaint.js +69 -0
  67. package/test/fixtures/media-presentation-continuation.js +141 -0
  68. package/test/fixtures/media-presentation-refill.js +113 -0
  69. package/test/fixtures/media-probe.js +148 -0
  70. package/test/fixtures/media-refill-cancel.js +79 -0
  71. package/test/fixtures/media-repaint.js +48 -0
  72. package/test/fixtures/media-resource-reselection.js +94 -0
  73. package/test/fixtures/media-source-attachment.js +136 -0
  74. package/test/fixtures/media-source-no-decoder.js +17 -0
  75. package/test/fixtures/media-track-refill.js +132 -0
  76. package/test/fixtures/media-webm-seek.js +51 -0
  77. package/test/fixtures/mutation-observer.js +201 -0
  78. package/test/fixtures/observer-invalidation.js +105 -0
  79. package/test/fixtures/prepaint-scheduling.js +73 -0
  80. package/test/fixtures/resource-discovery.js +32 -0
  81. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  82. package/test/fixtures/style-propagation.js +26 -0
  83. package/test/fixtures/svg-node-reuse.js +83 -0
  84. package/test/fixtures/video-black-replay.js +140 -0
  85. package/test/fixtures/video-controls-invalidation.js +71 -0
  86. package/test/fixtures/viewport-bounds.js +148 -0
  87. package/test/font-loading.test.js +77 -0
  88. package/test/font-metrics-invalidation.test.js +60 -0
  89. package/test/full-style-identity.test.js +191 -0
  90. package/test/hidden-style-lazy.test.js +31 -0
  91. package/test/hidden-style-reentry.test.js +20 -0
  92. package/test/idle-callback.test.js +89 -0
  93. package/test/idle-scheduler.test.js +148 -0
  94. package/test/index.test.js +1 -0
  95. package/test/inline-style-cascade.test.js +63 -0
  96. package/test/layout-repaint-renderer.test.js +9 -0
  97. package/test/layout-repaint.test.js +222 -0
  98. package/test/media-presentation-continuation.test.js +14 -0
  99. package/test/media-presentation-refill.test.js +8 -0
  100. package/test/media-probe.test.js +16 -0
  101. package/test/media-repaint.test.js +8 -0
  102. package/test/media-source-attachment.test.js +34 -0
  103. package/test/media-track-refill.test.js +26 -0
  104. package/test/media-webm-seek.test.js +11 -0
  105. package/test/mp4-keyframes.test.js +36 -0
  106. package/test/mutation-observer.test.js +91 -0
  107. package/test/observer-invalidation.test.js +12 -0
  108. package/test/page-scripts.test.js +35 -0
  109. package/test/page-timers.test.js +67 -0
  110. package/test/paint-frame.test.js +18 -1
  111. package/test/pcm.test.js +63 -0
  112. package/test/prepaint-scheduling.test.js +13 -0
  113. package/test/pulse-client.test.js +165 -0
  114. package/test/raw-capture.test.js +34 -5
  115. package/test/renderer-host.test.js +51 -0
  116. package/test/resource-discovery.test.js +12 -0
  117. package/test/resource-elements.test.js +234 -0
  118. package/test/root-self-style.test.js +104 -0
  119. package/test/script-rewrite.test.js +35 -0
  120. package/test/seccomp.test.js +45 -0
  121. package/test/spare-renderer-scheduling.test.js +8 -0
  122. package/test/style-propagation.test.js +225 -0
  123. package/test/style-sheets.test.js +19 -0
  124. package/test/svg.test.js +45 -0
  125. package/test/timers.test.js +58 -0
  126. package/test/video-black-replay.test.js +34 -0
  127. package/test/video-capture.test.js +170 -0
  128. package/test/video-channel.test.js +253 -0
  129. package/test/video-continuation.test.js +57 -0
  130. package/test/video-controls-invalidation.test.js +12 -0
  131. package/test/video-keyframe.test.js +104 -4
  132. package/test/video-mask.test.js +94 -0
  133. package/test/video-presentation.test.js +239 -1
  134. package/test/video-timing-presentation.test.js +115 -0
  135. package/test/viewport-bounds.test.js +13 -0
  136. package/test/webm-snapshots.test.js +372 -0
@@ -0,0 +1,18 @@
1
+ import { expect, test } from "bun:test";
2
+ test("canvas font and inherited direction use current CSS without per-draw layout", async () => {
3
+ const child = Bun.spawn(
4
+ [
5
+ process.execPath,
6
+ new URL("./fixtures/canvas-style-snapshot.js", import.meta.url).pathname,
7
+ ],
8
+ { stdout: "pipe", stderr: "pipe" },
9
+ );
10
+ const [exit, stdout, stderr] = await Promise.all([
11
+ child.exited,
12
+ new Response(child.stdout).text(),
13
+ new Response(child.stderr).text(),
14
+ ]);
15
+ expect(stderr).toBe("");
16
+ expect(exit).toBe(0);
17
+ expect(JSON.parse(stdout).pass).toBe(true);
18
+ }, 20_000);
@@ -2,13 +2,13 @@ import { test, expect } from 'bun:test';
2
2
  import { CdpServer } from '../lib/cdp-server.js';
3
3
  import { CDP } from '../tools/browser-lab.js';
4
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) {
5
+ async function fixture(screenshot, context = {}) {
6
6
  let onFrame;
7
- const server=CdpServer.create({screenshot,onFrame:fn=>{onFrame=fn;}}).listen(0);
7
+ const server=CdpServer.create({...context,screenshot,onFrame:fn=>{onFrame=fn;}}).listen(0);
8
8
  const cdp=new CDP(`ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);
9
9
  await cdp.ready;const {targetId}=await cdp.send('Target.createTarget',{url:'about:blank'});
10
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);}};
11
+ return {cdp,targetId,frame:info=>onFrame(info),frames:()=>cdp.events.filter(e=>e.method==='Page.screencastFrame'),close:()=>{cdp.close();server.stop(true);}};
12
12
  }
13
13
  test('slow screencast client gets at most two unacked frames; latest updates resume after ack',async()=>{
14
14
  let calls=0;const f=await fixture(async()=>String(++calls));
@@ -54,3 +54,96 @@ test('stopping a capture in flight drops its frame and a restarted stream gets i
54
54
  expect(f.frames()[0].params.data).toBe('new');
55
55
  }finally{f.close();}
56
56
  });
57
+
58
+ test('zero-area screencast notifies without capture, while screenshots retain clip validation',async()=>{
59
+ const {captureViewport}=await import('../lib/paint/frame.js');
60
+ let calls=0;
61
+ const f=await fixture(options=>{
62
+ calls++;
63
+ if(options.clip?.width===0 || options.clip?.height===0)
64
+ captureViewport(null,0,0,8,6,options.clip);
65
+ return 'real-pixels';
66
+ });
67
+ const clip={x:0,y:0,width:0,height:0,scale:1};
68
+ try{
69
+ await f.cdp.send('Page.startScreencast',{format:'png',maxWidth:8,maxHeight:6,clip});
70
+ await until(()=>f.frames().length===1);f.frame();await until(()=>f.frames().length===2);
71
+ expect(calls).toBe(0);
72
+ for(const {params} of f.frames()){
73
+ const png=Buffer.from(params.data,'base64');expect(png.readUInt32BE(16)).toBe(1);expect(png.readUInt32BE(20)).toBe(1);
74
+ }
75
+ await expect(f.cdp.send('Page.captureScreenshot',{format:'png',clip})).rejects.toThrow('Invalid screenshot clip');
76
+ await f.cdp.send('Page.screencastFrameAck',{sessionId:f.frames()[0].params.sessionId});
77
+ f.frame();await until(()=>f.frames().length===3);expect(calls).toBe(1);
78
+ await f.cdp.send('Page.stopScreencast');
79
+ await f.cdp.send('Page.startScreencast',{format:'jpeg',clip:{...clip,width:8,height:6}});
80
+ await until(()=>f.frames().length===4);expect(f.frames()[3].params.data).toBe('real-pixels');expect(calls).toBe(2);
81
+ }finally{f.close();}
82
+ });
83
+
84
+ const zeroClip = {x:0,y:0,width:0,height:0,scale:1};
85
+ test('zero-area notifications reuse exact scroll snapshots and coalesce to the latest pending position',async()=>{
86
+ let queries=0,captures=0;
87
+ const f=await fixture(()=>{captures++;return 'pixels';},{scrollOffset:()=>{queries++;return {x:1,y:2};}});
88
+ const offset=i=>f.frames()[i].params.metadata;
89
+ try{
90
+ await f.cdp.send('Page.startScreencast',{clip:zeroClip});await until(()=>f.frames().length===1);
91
+ expect(queries).toBe(1);expect(offset(0)).toMatchObject({scrollOffsetX:1,scrollOffsetY:2});
92
+ f.frame({scrollOffset:{x:10.25,y:20.75}});await until(()=>f.frames().length===2);
93
+ expect(queries).toBe(1);expect(offset(1)).toMatchObject({scrollOffsetX:10.25,scrollOffsetY:20.75});
94
+ const latest={scrollOffset:{x:23.5,y:49.75}};
95
+ f.frame({scrollOffset:{x:12,y:30}});f.frame(latest);latest.scrollOffset.y=999;
96
+ await f.cdp.send('Page.screencastFrameAck',{sessionId:f.frames()[0].params.sessionId});
97
+ await until(()=>f.frames().length===3);
98
+ expect(offset(2)).toMatchObject({scrollOffsetX:23.5,scrollOffsetY:49.75});expect(queries).toBe(1);
99
+ // An unannotated/malformed update must replace, not inherit, older metadata.
100
+ f.frame({scrollOffset:{x:1,y:NaN}});
101
+ await f.cdp.send('Page.screencastFrameAck',{sessionId:f.frames()[1].params.sessionId});
102
+ await until(()=>f.frames().length===4);
103
+ expect(queries).toBe(2);expect(offset(3)).toMatchObject({scrollOffsetX:1,scrollOffsetY:2});
104
+ expect(captures).toBe(0);
105
+ }finally{f.close();}
106
+ });
107
+
108
+ test('pixel screencasts still query their capture position instead of using invalidation metadata',async()=>{
109
+ let queries=0,captures=0;
110
+ const f=await fixture(()=>String(++captures),{scrollOffset:()=>({x:0,y:++queries*100})});
111
+ try{
112
+ await f.cdp.send('Page.startScreencast',{format:'png'});await until(()=>f.frames().length===1);
113
+ f.frame({scrollOffset:{x:99,y:99}});await until(()=>f.frames().length===2);
114
+ expect(captures).toBe(2);expect(queries).toBe(2);
115
+ expect(f.frames()[1].params.metadata).toMatchObject({scrollOffsetX:0,scrollOffsetY:200});
116
+ }finally{f.close();}
117
+ });
118
+
119
+ test('screencast restart and document navigation do not reuse pending old scroll metadata',async()=>{
120
+ let queries=0;
121
+ const f=await fixture(()=>'',{scrollOffset:()=>({x:0,y:++queries})});
122
+ try{
123
+ await f.cdp.send('Page.startScreencast',{clip:zeroClip});await until(()=>f.frames().length===1);
124
+ f.frame({scrollOffset:{x:10,y:20}});await until(()=>f.frames().length===2);
125
+ f.frame({scrollOffset:{x:100,y:200}});
126
+ await f.cdp.send('Page.navigate',{url:'https://replacement.test/'});
127
+ await f.cdp.send('Page.screencastFrameAck',{sessionId:f.frames()[0].params.sessionId});
128
+ await until(()=>f.frames().length===3);
129
+ expect(f.frames()[2].params.metadata).toMatchObject({scrollOffsetX:0,scrollOffsetY:2});
130
+ f.frame({scrollOffset:{x:1000,y:2000}});
131
+ await f.cdp.send('Page.stopScreencast');
132
+ await f.cdp.send('Page.startScreencast',{clip:zeroClip});await until(()=>f.frames().length===4);
133
+ expect(f.frames()[3].params.metadata).toMatchObject({scrollOffsetX:0,scrollOffsetY:3});
134
+ expect(queries).toBe(3);
135
+ }finally{f.close();}
136
+ });
137
+
138
+ test('a scroll query crossing navigation is discarded before notifying the new document',async()=>{
139
+ const pending=[];
140
+ const f=await fixture(()=>'',{scrollOffset:()=>new Promise(resolve=>pending.push(resolve))});
141
+ try{
142
+ await f.cdp.send('Page.startScreencast',{clip:zeroClip});await until(()=>pending.length===1);
143
+ await f.cdp.send('Page.navigate',{url:'https://next.test/'});
144
+ f.frame({scrollOffset:{x:3,y:4}});
145
+ pending[0]({x:90,y:100});await until(()=>f.frames().length===1);
146
+ expect(f.frames()[0].params.metadata).toMatchObject({scrollOffsetX:3,scrollOffsetY:4});
147
+ expect(pending).toHaveLength(1);
148
+ }finally{f.close();}
149
+ });
@@ -0,0 +1,43 @@
1
+ import {test, expect} from 'bun:test';
2
+ import {CdpServer} from '../lib/cdp-server.js';
3
+ const until=async fn=>{for(let i=0;i<200;i++){if(fn())return;await Bun.sleep(5);}throw Error('viewer update timed out');};
4
+ async function connect(url){
5
+ const ws=new WebSocket(url), events=[], pending=new Map();let id=0;
6
+ ws.onmessage=({data})=>{const m=JSON.parse(data);if(m.id){const p=pending.get(m.id);pending.delete(m.id);m.error?p.reject(Error(m.error.message)):p.resolve(m.result);}else events.push(m);};
7
+ await new Promise((resolve,reject)=>{ws.onopen=resolve;ws.onerror=reject;});
8
+ return {ws,events,send(method,params={},sessionId){return new Promise((resolve,reject)=>{pending.set(++id,{resolve,reject});ws.send(JSON.stringify({id,method,params,sessionId}));});}};
9
+ }
10
+ for(const direct of [false,true])test(`independent viewer ACK, stop and disconnect (${direct?'page sockets':'sessions'})`,async()=>{
11
+ let frame;const server=CdpServer.create({screenshot:options=>options.format??'png',onFrame:fn=>frame=fn}).listen(0);
12
+ const clients=[];
13
+ try{
14
+ const root=await connect(`ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);clients.push(root);
15
+ const {targetId}=await root.send('Target.createTarget',{url:'about:blank'});
16
+ async function viewer(){const c=await connect(`ws://127.0.0.1:${server.port}/devtools/${direct?'page/'+targetId:'browser/cdp-server'}`);clients.push(c);const sid=direct?undefined:(await c.send('Target.attachToTarget',{targetId,flatten:true})).sessionId;return {c,sid,send:(m,p)=>c.send(m,p,sid),frames:()=>c.events.filter(e=>e.method==='Page.screencastFrame')};}
17
+ const a=await viewer(),b=await viewer();
18
+ await a.send('Page.startScreencast',{format:'png'});await b.send('Page.startScreencast',{format:'jpeg'});
19
+ await until(()=>a.frames().length===1&&b.frames().length===1);
20
+ expect(a.frames()[0].params.data).toBe('png');expect(b.frames()[0].params.data).toBe('jpeg');
21
+ frame();await until(()=>a.frames().length===2&&b.frames().length===2);
22
+ for(let i=0;i<5;i++)frame();
23
+ // A cannot ACK B's frames, even when both watch the same target.
24
+ await a.send('Page.screencastFrameAck',{sessionId:b.frames()[0].params.sessionId});
25
+ await Bun.sleep(20);expect(a.frames()).toHaveLength(2);expect(b.frames()).toHaveLength(2);
26
+ await b.send('Page.screencastFrameAck',{sessionId:b.frames()[0].params.sessionId});await until(()=>b.frames().length===3);
27
+ expect(a.frames()).toHaveLength(2);
28
+ await a.send('Page.stopScreencast');
29
+ await b.send('Page.screencastFrameAck',{sessionId:b.frames()[1].params.sessionId});frame();await until(()=>b.frames().length===4);
30
+ a.c.ws.close();await Bun.sleep(20);
31
+ await b.send('Page.screencastFrameAck',{sessionId:b.frames()[2].params.sessionId});frame();await until(()=>b.frames().length===5);
32
+ const replacement=await viewer();await replacement.send('Page.startScreencast');await until(()=>replacement.frames().length===1);
33
+ await b.send('Page.stopScreencast');frame();await until(()=>replacement.frames().length===2);
34
+ if(!direct){
35
+ await expect(root.send('Page.stopScreencast',{},b.sid)).rejects.toThrow('No session owned');
36
+ const {sessionId}=await replacement.c.send('Target.attachToTarget',{targetId,flatten:true});
37
+ await replacement.c.send('Page.startScreencast',{format:'jpeg'},sessionId);
38
+ await until(()=>replacement.c.events.some(e=>e.method==='Page.screencastFrame'&&e.sessionId===sessionId));
39
+ await replacement.send('Page.stopScreencast');frame();
40
+ await until(()=>replacement.c.events.filter(e=>e.method==='Page.screencastFrame'&&e.sessionId===sessionId).length===2);
41
+ }
42
+ }finally{for(const c of clients)c.ws.close();server.stop(true);}
43
+ });
@@ -0,0 +1,194 @@
1
+ import {expect, test} from 'bun:test';
2
+ import {EventEmitter} from 'node:events';
3
+ import {createServer, createConnection} from 'node:net';
4
+ import {PacketReader, PacketWriter} from '../lib/renderer/compositor-channel.js';
5
+
6
+ class DelayedSocket extends EventEmitter {
7
+ destroyed = false;
8
+ writes = [];
9
+ throwAt = -1;
10
+ write(chunk, callback) {
11
+ if (this.writes.length === this.throwAt) throw Error('write failed');
12
+ this.writes.push({chunk, callback});
13
+ return false;
14
+ }
15
+ complete(index, error) { this.writes[index].callback(error); }
16
+ destroy() { this.destroyed = true; return this; }
17
+ finishClose() { this.emit('close'); }
18
+ }
19
+
20
+ function tracked(bytes) {
21
+ const pixels = new Uint8Array(bytes);
22
+ const buffer = pixels.buffer, transfer = buffer.transfer.bind(buffer);
23
+ let releases = 0;
24
+ buffer.transfer = size => { releases++; return transfer(size); };
25
+ return {pixels, get releases() { return releases; }};
26
+ }
27
+
28
+ test('owned pixels stay borrowed until write completion and packets cannot interleave', () => {
29
+ const socket = new DelayedSocket(), writer = new PacketWriter(socket);
30
+ const owned = tracked(32); owned.pixels.fill(57);
31
+ const unowned = Uint8Array.of(1, 2, 3);
32
+ writer.send({id:1}, owned.pixels, {takeOwnership:true});
33
+ writer.send({type:'frame'});
34
+ writer.send({id:2}, unowned);
35
+ unowned.fill(99); // The default send snapshots an unowned input immediately.
36
+ expect(socket.writes).toHaveLength(1);
37
+ expect(owned.releases).toBe(0);
38
+ const wire = [Buffer.from(socket.writes[0].chunk)];
39
+ socket.complete(0);
40
+ expect(socket.writes[1].chunk).toBe(owned.pixels);
41
+ expect(owned.releases).toBe(0);
42
+ wire.push(Buffer.from(socket.writes[1].chunk));
43
+ socket.complete(1);
44
+ expect(owned.releases).toBe(1);
45
+ expect(owned.pixels.byteLength).toBe(0);
46
+ wire.push(Buffer.from(socket.writes[2].chunk));
47
+ socket.complete(2);
48
+ wire.push(Buffer.from(socket.writes[3].chunk));
49
+ socket.complete(3);
50
+ const received = [];
51
+ new PacketReader((message,pixels) => received.push([message,[...pixels]])).push(Buffer.concat(wire));
52
+ expect(received).toEqual([[{id:1},Array(32).fill(57)],[{type:'frame'},[]],[{id:2},[1,2,3]]]);
53
+ expect([...unowned]).toEqual([99,99,99]);
54
+ expect(writer.bytes).toBe(0);
55
+ writer.close(); socket.finishClose();
56
+ expect(owned.releases).toBe(1);
57
+ });
58
+
59
+ test('backpressure releases queued and rejected frames but waits for an active native write', () => {
60
+ const socket = new DelayedSocket(), writer = new PacketWriter(socket, 250);
61
+ const active = tracked(100), queued = tracked(40), rejected = tracked(180);
62
+ writer.send({id:1},active.pixels,{takeOwnership:true});
63
+ writer.send({id:2},queued.pixels,{takeOwnership:true});
64
+ expect(() => writer.send({id:3},rejected.pixels,{takeOwnership:true})).toThrow('backpressure');
65
+ expect(socket.destroyed).toBe(true);
66
+ expect([active.releases,queued.releases,rejected.releases]).toEqual([0,1,1]);
67
+ const closed = tracked(1);
68
+ expect(() => writer.send({id:4},closed.pixels,{takeOwnership:true})).toThrow('closed');
69
+ expect(closed.releases).toBe(1);
70
+ socket.finishClose();
71
+ expect(active.releases).toBe(1);
72
+ socket.complete(0); // Late write callbacks must not send or release again.
73
+ expect(socket.writes).toHaveLength(1);
74
+ expect([active.releases,queued.releases,rejected.releases]).toEqual([1,1,1]);
75
+ expect(writer.bytes).toBe(0);
76
+ });
77
+
78
+ test('synchronous write throws and asynchronous errors close partially sent packets', () => {
79
+ for (const failure of ['header-throw','body-throw','body-callback','disconnect']) {
80
+ const socket = new DelayedSocket(), writer = new PacketWriter(socket);
81
+ const active = tracked(32), queued = tracked(24);
82
+ if (failure === 'header-throw') socket.throwAt = 0;
83
+ const send = () => writer.send({id:1},active.pixels,{takeOwnership:true});
84
+ if (failure === 'header-throw') {
85
+ expect(send).toThrow('write failed');
86
+ } else {
87
+ send();
88
+ writer.send({id:2},queued.pixels,{takeOwnership:true});
89
+ if (failure === 'body-throw') socket.throwAt = 1;
90
+ socket.complete(0);
91
+ if (failure === 'body-callback') socket.complete(1, Error('connection lost'));
92
+ if (failure === 'disconnect') socket.destroy();
93
+ }
94
+ // Throw/destroy alone does not prove a pending native write has released
95
+ // its input. The close event is the portable completion boundary.
96
+ if (failure !== 'body-callback') expect(active.releases).toBe(0);
97
+ socket.finishClose();
98
+ expect(active.releases).toBe(1);
99
+ if (failure !== 'header-throw') expect(queued.releases).toBe(1);
100
+ for (let i=0; i<socket.writes.length; i++) socket.complete(i, Error('late callback'));
101
+ expect(active.releases).toBe(1);
102
+ expect(writer.bytes).toBe(0);
103
+ expect(socket.destroyed).toBe(true);
104
+ }
105
+ });
106
+
107
+ test('rejected owned serialization releases bytes without damaging an untouched stream', () => {
108
+ const socket = new DelayedSocket(), writer = new PacketWriter(socket);
109
+ const invalid = tracked(8), message = {}; message.self = message;
110
+ expect(() => writer.send(message,invalid.pixels,{takeOwnership:true})).toThrow();
111
+ expect(invalid.releases).toBe(1);
112
+ expect(socket.destroyed).toBe(false);
113
+ expect(socket.writes).toHaveLength(0);
114
+ const shared = new Uint8Array(new SharedArrayBuffer(8));
115
+ expect(() => writer.send({},shared,{takeOwnership:true})).toThrow('private ArrayBuffer');
116
+ expect(shared.byteLength).toBe(8);
117
+ writer.send({ok:true}); socket.complete(0);
118
+ writer.close(); socket.finishClose();
119
+ });
120
+
121
+ test('synchronous write callbacks drain many queued messages without recursion or double release', () => {
122
+ const socket = new DelayedSocket(), writer = new PacketWriter(socket);
123
+ const first = tracked(8);
124
+ writer.send({id:1},first.pixels,{takeOwnership:true});
125
+ for (let i=0;i<2000;i++) writer.send({id:i+2});
126
+ socket.write = (chunk,callback) => { callback(); return true; };
127
+ socket.complete(0);
128
+ expect(first.releases).toBe(1);
129
+ expect(writer.bytes).toBe(0);
130
+ writer.close(); socket.finishClose();
131
+ });
132
+
133
+ async function socketPair(receive) {
134
+ let accepted;
135
+ const ready = new Promise(resolve => { accepted = resolve; });
136
+ const server = createServer(socket => {
137
+ socket.setNoDelay(true); socket.pause();
138
+ socket.on('data',receive);
139
+ socket.on('error',()=>{});
140
+ accepted(socket);
141
+ });
142
+ await new Promise(resolve => server.listen(0,'127.0.0.1',resolve));
143
+ const client = createConnection({host:'127.0.0.1',port:server.address().port});
144
+ client.setNoDelay(true);
145
+ await new Promise((resolve,reject) => { client.once('connect',resolve);client.once('error',reject); });
146
+ const peer = await ready;
147
+ return {client,peer,server,close() {client.destroy();peer.destroy();server.close();}};
148
+ }
149
+
150
+ test('large frames survive a paused receiver with queued control and copied messages', async () => {
151
+ const received = [], expected = [], deferred = Promise.withResolvers();
152
+ const reader = new PacketReader((message,pixels) => {
153
+ received.push([message,Bun.hash(pixels)]);
154
+ pixels.buffer.transfer(0);
155
+ if (received.length === 5) deferred.resolve();
156
+ });
157
+ const pair = await socketPair(chunk => {try { reader.push(chunk); } catch(error) {deferred.reject(error);}});
158
+ const writer = new PacketWriter(pair.client);
159
+ const first = tracked(8*1024*1024), last = tracked(8*1024*1024);
160
+ const copied = new Uint8Array(8*1024*1024);
161
+ first.pixels.fill(11); copied.fill(22); last.pixels.fill(33);
162
+ try {
163
+ for (const [message,pixels,owned] of [[{id:1},first.pixels,true],[{type:'frame'},null,false],
164
+ [{id:2},copied,false],[{type:'frame',sequence:2},null,false],[{id:3},last.pixels,true]]) {
165
+ expected.push([message,Bun.hash(pixels ?? new Uint8Array())]);
166
+ writer.send(message,pixels,owned ? {takeOwnership:true} : undefined);
167
+ }
168
+ copied.fill(99);
169
+ await Bun.sleep(80);
170
+ expect(last.releases).toBe(0);
171
+ pair.peer.resume();
172
+ await deferred.promise;
173
+ for (let i=0; i<100 && last.releases===0; i++) await Bun.sleep(5);
174
+ expect(received).toEqual(expected);
175
+ expect([first.releases,last.releases]).toEqual([1,1]);
176
+ expect(copied.byteLength).toBe(8*1024*1024);
177
+ expect(writer.bytes).toBe(0);
178
+ } finally { writer.close(); pair.close(); }
179
+ },10000);
180
+
181
+ test('disconnecting a paused real socket releases in-flight and queued owned frames', async () => {
182
+ const pair = await socketPair(()=>{}), writer = new PacketWriter(pair.client);
183
+ const closed = new Promise(resolve => pair.client.once('close',resolve));
184
+ const first = tracked(8*1024*1024), second = tracked(8*1024*1024);
185
+ try {
186
+ writer.send({id:1},first.pixels,{takeOwnership:true});
187
+ writer.send({id:2},second.pixels,{takeOwnership:true});
188
+ await Bun.sleep(40);
189
+ expect(second.releases).toBe(0);
190
+ pair.peer.destroy(); await closed;
191
+ expect([first.releases,second.releases]).toEqual([1,1]);
192
+ expect(writer.bytes).toBe(0);
193
+ } finally {writer.close();pair.close();}
194
+ },10000);
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, expect, test } from "bun:test";
2
2
  import { createBrowser } from "../lib/headless-shell.js";
3
3
  import { cropViewport, encodeImage, rgbaBitmap } from "../lib/paint/frame.js";
4
4
  import { RendererHost } from "../lib/renderer/host.js";
5
+ import { NetworkService } from "../lib/network/network-service.js";
5
6
 
6
7
  const tallPage = `<!doctype html><title>tall</title><body style="margin:0">${
7
8
  Array.from({ length: 80 }, (_, index) => `<p style="margin:0;height:40px">line ${index}</p>`).join("")
@@ -27,7 +28,8 @@ test("frames: viewport crops fill uncovered areas, and Bun.Image encodes the BMP
27
28
  });
28
29
 
29
30
  test("the compositor answers screenshots and scrolling while the main thread is busy", async () => {
30
- const host = new RendererHost({ timeout: 20_000 });
31
+ const frames = [];
32
+ const host = new RendererHost({ timeout: 20_000, onFrame: frame => frames.push(frame) });
31
33
  try {
32
34
  await host.start();
33
35
  await host.call("resize", 400, 300, 1);
@@ -42,6 +44,7 @@ test("the compositor answers screenshots and scrolling while the main thread is
42
44
  await Bun.sleep(300);
43
45
  const started = performance.now();
44
46
  expect(await host.composite("scroll", 0, 120)).toEqual({ x: 0, y: 120 });
47
+ expect(frames.at(-1)?.scrollOffset).toEqual({ x: 0, y: 120 });
45
48
  const scrolled = await host.composite("screenshot");
46
49
  expect(performance.now() - started).toBeLessThan(1500);
47
50
  expect(scrolled).not.toBe(top);
@@ -56,11 +59,34 @@ test("the compositor answers screenshots and scrolling while the main thread is
56
59
  await host.call("press", "End");
57
60
  const end = await host.composite("scrollOffset");
58
61
  expect(end.y).toBe(80 * 40 - 300);
62
+ expect(frames.at(-1)?.scrollOffset).toEqual(end);
59
63
  } finally {
60
64
  host.close();
61
65
  }
62
66
  }, 30_000);
63
67
 
68
+ test("frame scroll snapshots follow the active renderer across origin replacement", async () => {
69
+ const browser = await createBrowser({ spareRenderer: false,
70
+ network: new NetworkService({fetch: async () => new Response(tallPage, {headers:{"content-type":"text/html"}})}) });
71
+ const {context} = browser, frames = [];
72
+ context.onFrame(frame => frames.push(frame));
73
+ try {
74
+ await context.resize(400, 300, 1);
75
+ await context.navigate("https://first.test/");
76
+ await context.scroll(0, 125.5);
77
+ expect(frames.at(-1)?.scrollOffset).toEqual({x:0,y:125.5});
78
+ await context.navigate("https://second.test/");
79
+ await context.screenshot();
80
+ const firstNewFrame = frames.length - 1;
81
+ expect(frames.at(-1)?.scrollOffset).toEqual({x:0,y:0});
82
+ await Bun.sleep(30);
83
+ expect(frames.slice(firstNewFrame).every(frame => frame?.scrollOffset?.y === 0)).toBe(true);
84
+ await context.scroll(0, 42.25);
85
+ expect(frames.at(-1)?.scrollOffset).toEqual({x:0,y:42.25});
86
+ expect(await context.scrollOffset()).toEqual(frames.at(-1).scrollOffset);
87
+ } finally { browser.close(); }
88
+ }, 30_000);
89
+
64
90
  let server;
65
91
  test("video continues presenting while page JavaScript blocks, then freezes on pause", async () => {
66
92
  const host = new RendererHost({ timeout: 20_000,
@@ -8,6 +8,55 @@ beforeAll(async () => {
8
8
  });
9
9
  afterAll(() => renderer?.close());
10
10
 
11
+ test("computed declarations stay live and only geometry reads flush layout", async () => {
12
+ const child = Bun.spawn([process.execPath, new URL("./fixtures/computed-style-lazy.js", import.meta.url).pathname],
13
+ { stdout:"pipe", stderr:"pipe" });
14
+ const [exit,stderr] = await Promise.all([child.exited,new Response(child.stderr).text()]);
15
+ expect(stderr).toBe("");
16
+ expect(exit).toBe(0);
17
+ });
18
+
19
+ test("stored computed declarations remain live inside the sandboxed page realm", async () => {
20
+ await renderer.call("loadDocument", { url:"https://page.test/",contentType:"text/html",status:200,
21
+ source:'<!doctype html><div id="box" style="width:100px;height:20px;color:red;--value:first"></div>' });
22
+ await renderer.call("whenLoaded");
23
+ const result = JSON.parse(await renderer.call("evaluate", `JSON.stringify((()=>{
24
+ const box=document.getElementById('box'),style=getComputedStyle(box);
25
+ const initial=[style.width,style.color,style.getPropertyValue('--value')];
26
+ box.style.width='160px';box.style.color='blue';box.style.setProperty('--value','second');
27
+ const changed=[style.width,style.color,style.getPropertyValue('--value')];
28
+ box.style.display='none';const hidden=style.display;box.style.display='block';
29
+ return [initial,changed,hidden,style.width];
30
+ })())`));
31
+ expect(result).toEqual([["100px","rgba(255, 0, 0, 1)","first"],
32
+ ["160px","rgba(0, 0, 255, 1)","second"],"none","160px"]);
33
+ });
34
+
35
+ test("computed property spellings stay consistent across declarations and live changes", async () => {
36
+ await renderer.call("loadDocument", { url:"https://page.test/",contentType:"text/html",status:200,
37
+ source:`<!doctype html><style>#one{-webkit-line-clamp:2}</style><div id="one" style="background-color:red;padding:2px 3px;--Tone:first;--tone:lower"></div>
38
+ <div id="two" style="background-color:blue;padding:7px"></div>` });
39
+ await renderer.call("whenLoaded");
40
+ const result = await renderer.call("evaluate", `(() => {
41
+ const one=document.getElementById('one'),a=getComputedStyle(one),b=getComputedStyle(document.getElementById('two'));
42
+ const names=Array.from({length:a.length},(_,i)=>a.item(i));
43
+ const read=style=>[style.backgroundColor,style.getPropertyValue('BACKGROUND-COLOR'),style.paddingLeft,
44
+ style.getPropertyValue('padding-left'),style.WebkitLineClamp,style.webkitLineClamp,style.getPropertyValue('-webkit-line-clamp')];
45
+ const initial=read(a),other=read(b);
46
+ one.style.backgroundColor='green';one.style.paddingLeft='9px';one.style.setProperty('--Tone','changed');
47
+ return {initial,other,changed:read(a),custom:[a.getPropertyValue('--Tone'),a.getPropertyValue('--tone')],
48
+ names:names.length===new Set(names).size&&names.includes('background-color')&&names.includes('-webkit-line-clamp'),
49
+ absent:['constructor','toString','unknown-property'].map(name=>a.getPropertyValue(name)),
50
+ stable:names.join('|')===Array.from({length:b.length},(_,i)=>b.item(i)).join('|')};
51
+ })()`);
52
+ expect(result).toEqual({
53
+ initial:["rgba(255, 0, 0, 1)","rgba(255, 0, 0, 1)","3px","3px","2","2","2"],
54
+ other:["rgba(0, 0, 255, 1)","rgba(0, 0, 255, 1)","7px","7px","none","none","none"],
55
+ changed:["rgba(0, 128, 0, 1)","rgba(0, 128, 0, 1)","9px","9px","2","2","2"],
56
+ custom:["changed","lower"],names:true,absent:["","",""],stable:true,
57
+ });
58
+ });
59
+
11
60
  test("computed corner radii preserve percentages, elliptical axes and logical mappings", async () => {
12
61
  await renderer.call("loadDocument", {
13
62
  url: "https://page.test/", contentType: "text/html", status: 200,
@@ -0,0 +1,13 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ for (const mode of ["host", "ses"]) test(`computed declarations follow connectivity in ${mode} mode`, async () => {
4
+ const child = Bun.spawn([process.execPath,
5
+ new URL("./fixtures/computed-style-connectivity.js", import.meta.url).pathname, mode],
6
+ { stdout: "pipe", stderr: "pipe" });
7
+ const [exit, stdout, stderr] = await Promise.all([
8
+ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
9
+ ]);
10
+ expect(stderr).toBe("");
11
+ expect(stdout).toBe("");
12
+ expect(exit).toBe(0);
13
+ }, 20_000);
@@ -67,6 +67,92 @@ test("computes the initial UA and inherited style subset", () => {
67
67
  window.happyDOM.abort();
68
68
  });
69
69
 
70
+ test("explicit inheritance preserves serialized values, factors and fallback fields", () => {
71
+ const { document, window } = parseHTMLDocument('<div id="parent"><span id="child"><i id="small"></i></span></div>');
72
+ try {
73
+ document.querySelector('#parent').setAttribute('style', `background-color: rgb(20, 40, 60); font-family: "Two Words", "Comma, Face", serif;
74
+ font-size: 20px; font-weight: 600; font-style: italic; font-stretch: 87.5%; line-height: 1.25;
75
+ list-style-type: decimal; pointer-events: none; user-select: all; text-align: right; white-space: pre-wrap;
76
+ color-scheme: dark; opacity: .6; margin: 1px 2px 3px 4px`);
77
+ const names = ['background-color', 'font-family', 'font-size', 'font-weight', 'font-style', 'font-width',
78
+ 'line-height', 'list-style-type', 'pointer-events', 'user-select', 'text-align', 'white-space',
79
+ 'color-scheme', 'opacity', 'margin'];
80
+ document.querySelector('#child').setAttribute('style', names.map(name => `${name}:inherit`).join(';'));
81
+ document.querySelector('#small').setAttribute('style', 'font-size:10px;line-height:inherit');
82
+ const engine = new StyleEngine().compute(document);
83
+ expect(engine.get(document.querySelector('#child'))).toMatchObject({
84
+ backgroundColor: 'rgba(20, 40, 60, 1)', fontFamily: ['Two Words', 'Comma, Face', 'serif'],
85
+ fontSize: 20, fontWeight: 600, fontStyle: 'italic', fontStretch: 87.5,
86
+ lineHeight: 25, lineHeightFactor: 1.25, listStyleType: 'decimal', pointerEvents: 'none',
87
+ userSelect: 'all', textAlign: 'end', whiteSpace: 'pre-wrap', colorScheme: 'dark', opacity: .6,
88
+ margin: [1, 2, 3, 4],
89
+ });
90
+ expect(engine.get(document.querySelector('#small'))).toMatchObject({ fontSize: 10, lineHeight: 12.5, lineHeightFactor: 1.25 });
91
+ } finally { window.happyDOM.abort(); }
92
+ });
93
+
94
+ test("ordinary inheritance does not serialize unrelated font families", () => {
95
+ const { document, window } = parseHTMLDocument('<div id="parent"></div><span id="child"></span>');
96
+ try {
97
+ const parent = computeElementStyle(document.querySelector('#parent'));
98
+ let serialized = 0;
99
+ const fontFamily = new Proxy(parent.fontFamily, {
100
+ get(target, key, receiver) {
101
+ if (key === 'map') serialized++;
102
+ return Reflect.get(target, key, receiver);
103
+ },
104
+ });
105
+ const inherited = { ...parent, fontFamily };
106
+ const child = document.querySelector('#child');
107
+ expect(computeElementStyle(child, inherited).fontFamily).toBe(fontFamily);
108
+ expect(serialized).toBe(0);
109
+ child.setAttribute('style', 'font-family:inherit;line-height:inherit');
110
+ expect(computeElementStyle(child, inherited).fontFamily).toEqual(parent.fontFamily);
111
+ expect(serialized).toBe(1);
112
+ } finally { window.happyDOM.abort(); }
113
+ });
114
+
115
+ test("immutable border and inset arrays are shared while descendant overrides stay isolated", () => {
116
+ const { document, window } = parseHTMLDocument('<div id="parent" style="border:3px solid red;inset:1px 2px 3px 4px"></div><span id="child"></span>');
117
+ try {
118
+ const parent = computeElementStyle(document.querySelector('#parent'));
119
+ const child = document.querySelector('#child');
120
+ const firstDefault = computeElementStyle(child), secondDefault = computeElementStyle(child);
121
+ for (const name of ['borderStyles', 'borderSpecifiedWidths', 'inset']) {
122
+ expect(firstDefault[name]).toBe(secondDefault[name]);
123
+ expect(Object.isFrozen(firstDefault[name])).toBe(true);
124
+ }
125
+ child.setAttribute('style', 'border:inherit;inset:inherit');
126
+ const inherited = computeElementStyle(child, parent);
127
+ for (const name of ['borderStyles', 'borderSpecifiedWidths', 'inset']) expect(inherited[name]).toBe(parent[name]);
128
+ child.setAttribute('style', 'border:inherit;inset:inherit;border-left-style:dashed;border-top-width:9px;right:12px');
129
+ const changed = computeElementStyle(child, inherited);
130
+ expect(changed).toMatchObject({borderStyles:['solid','solid','solid','dashed'], borderSpecifiedWidths:[9,3,3,3], inset:[1,12,3,4]});
131
+ expect(parent).toMatchObject({borderStyles:['solid','solid','solid','solid'], borderSpecifiedWidths:[3,3,3,3], inset:[1,2,3,4]});
132
+ for (const name of ['borderStyles', 'borderSpecifiedWidths', 'inset']) {
133
+ expect(changed[name]).not.toBe(parent[name]);
134
+ expect(Object.isFrozen(changed[name])).toBe(true);
135
+ }
136
+ } finally { window.happyDOM.abort(); }
137
+ });
138
+
139
+ test("inheriting mutable border and inset arrays copies without freezing caller-owned arrays", () => {
140
+ const { document, window } = parseHTMLDocument('<div id="parent"></div><span id="child" style="border:inherit;inset:inherit"></span>');
141
+ try {
142
+ const parent = {...computeElementStyle(document.querySelector('#parent')),
143
+ borderStyles:['solid','dotted','dashed','double'], borderSpecifiedWidths:[1,2,3,4], inset:[5,6,7,8]};
144
+ const child = computeElementStyle(document.querySelector('#child'), parent);
145
+ for (const name of ['borderStyles', 'borderSpecifiedWidths', 'inset']) {
146
+ expect(child[name]).toEqual(parent[name]);
147
+ expect(child[name]).not.toBe(parent[name]);
148
+ expect(Object.isFrozen(child[name])).toBe(true);
149
+ expect(Object.isFrozen(parent[name])).toBe(false);
150
+ }
151
+ parent.borderStyles[0]='hidden';parent.borderSpecifiedWidths[0]=99;parent.inset[0]=99;
152
+ expect(child).toMatchObject({borderStyles:['solid','dotted','dashed','double'], borderSpecifiedWidths:[1,2,3,4], inset:[5,6,7,8]});
153
+ } finally { window.happyDOM.abort(); }
154
+ });
155
+
70
156
  test(":empty includes text nodes, but ignores comments and empty text nodes", () => {
71
157
  const { document, window } = parseHTMLDocument('<span id="text">0</span><span id="space"> </span><span id="empty"><!--comment--></span>');
72
158
  const styles = new StyleEngine().compute(document, ['span:empty { display:none }']);