buninu-browser 0.7.0 → 0.9.1

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 (142) hide show
  1. package/AGENTS.md +5 -0
  2. package/README.md +90 -1
  3. package/buninu-browser.js +1 -1
  4. package/frontends/wayland/README.md +35 -2
  5. package/frontends/wayland/client.js +8 -6
  6. package/frontends/wayland/clipboard.js +1 -1
  7. package/frontends/wayland/index.js +1 -1
  8. package/frontends/wayland/input.js +1 -1
  9. package/frontends/wayland/resource-io.js +6 -0
  10. package/frontends/wayland/transport-posix.js +120 -0
  11. package/frontends/wayland/transport-win32.js +9 -0
  12. package/frontends/wayland/transport.js +3 -120
  13. package/frontends/wayland/win32-resources.js +80 -0
  14. package/frontends/wayland/win32-wire.js +62 -0
  15. package/lib/audio/biquad.js +25 -0
  16. package/lib/audio/piano.js +6 -3
  17. package/lib/audio/playback-buffer.js +5 -5
  18. package/lib/audio/pulse-client.js +24 -5
  19. package/lib/audio/web-audio.js +570 -0
  20. package/lib/browser-dialog.js +126 -0
  21. package/lib/cdp-server.js +43 -11
  22. package/lib/file-picker.js +48 -0
  23. package/lib/headless-shell.js +34 -12
  24. package/lib/layout/text-layout.js +112 -19
  25. package/lib/media/mp4-frame-times.js +93 -0
  26. package/lib/paint/direct-canvas.ts +39 -0
  27. package/lib/paint/display-list.js +37 -12
  28. package/lib/paint/raster-readback.js +16 -7
  29. package/lib/renderer/animation-frames.js +10 -5
  30. package/lib/renderer/bindings.js +69 -16
  31. package/lib/renderer/canvas-2d.js +265 -15
  32. package/lib/renderer/canvas-round-rect.js +36 -0
  33. package/lib/renderer/css-animations.js +173 -0
  34. package/lib/renderer/dialog-client.js +30 -0
  35. package/lib/renderer/dialog-worker.js +35 -0
  36. package/lib/renderer/eval-environment.js +28 -0
  37. package/lib/renderer/file-input.js +49 -0
  38. package/lib/renderer/host.js +33 -4
  39. package/lib/renderer/html-drag.js +77 -0
  40. package/lib/renderer/image-loading.js +45 -2
  41. package/lib/renderer/main.js +4 -0
  42. package/lib/renderer/module-transform.js +5 -3
  43. package/lib/renderer/page-builtins.js +59 -0
  44. package/lib/renderer/page-realm.js +177 -40
  45. package/lib/renderer/page-renderer.js +397 -49
  46. package/lib/renderer/piano-input.js +3 -1
  47. package/lib/renderer/raster-surface.js +18 -0
  48. package/lib/renderer/script-rewrite.js +165 -9
  49. package/lib/renderer/video-presentation.js +5 -4
  50. package/lib/renderer/webgl/clip.js +24 -0
  51. package/lib/renderer/webgl/context.js +158 -0
  52. package/lib/renderer/webgl/cpu-fragment.js +211 -0
  53. package/lib/renderer/webgl/draw-cache.js +22 -0
  54. package/lib/renderer/webgl/pipeline.js +70 -0
  55. package/lib/renderer/webgl/shaders.js +58 -0
  56. package/lib/renderer/webgl/textures.js +43 -0
  57. package/lib/renderer/window-named-properties.js +46 -0
  58. package/lib/screenshot-cli.js +4 -7
  59. package/lib/style/animation-values.js +29 -0
  60. package/lib/style/computed-style.js +109 -13
  61. package/lib/style/transform.js +44 -0
  62. package/lib/window-size.js +10 -0
  63. package/package.json +1 -1
  64. package/patches/happy-dom/0007-scope-content-models-to-html-namespace.patch +29 -0
  65. package/patches/happy-dom/README.md +16 -0
  66. package/test/animation-frames.test.js +15 -0
  67. package/test/canvas-2d.test.js +252 -4
  68. package/test/canvas-frame-pacing.test.js +7 -0
  69. package/test/cdp-viewers.test.js +36 -0
  70. package/test/classic-games-patches/0001-fix-game-initialization.patch +177 -0
  71. package/test/classic-games-patches/README.md +156 -0
  72. package/test/classic-games-scenarios.js +133 -0
  73. package/test/clipboard.test.js +16 -0
  74. package/test/computed-style.test.js +18 -0
  75. package/test/css-animation-lifecycle.test.js +28 -0
  76. package/test/css-animations.test.js +83 -0
  77. package/test/direct-canvas.test.js +39 -0
  78. package/test/dynamic-inline-handlers.test.js +25 -0
  79. package/test/eval-declarations.test.js +22 -0
  80. package/test/file-picker.test.js +34 -0
  81. package/test/fixtures/canvas-damage.js +23 -0
  82. package/test/fixtures/canvas-frame-pacing.js +35 -0
  83. package/test/fixtures/direct-canvas.js +96 -0
  84. package/test/fixtures/javascript-dialogs.html +8 -0
  85. package/test/fixtures/media-audio-lifetime.js +65 -0
  86. package/test/fixtures/renderer-memory.js +7 -5
  87. package/test/fixtures/video-black-replay.js +8 -6
  88. package/test/fixtures/video-slow-end.js +39 -0
  89. package/test/fixtures/web-audio-renderer.js +45 -0
  90. package/test/flex.test.js +26 -0
  91. package/test/grid.test.js +13 -0
  92. package/test/headless-shell.test.js +9 -0
  93. package/test/headless-window-size.test.js +28 -0
  94. package/test/html-rcdata.test.js +35 -0
  95. package/test/image-decode.test.js +18 -0
  96. package/test/javascript-dialog.test.js +83 -0
  97. package/test/manual-breakout-paddle.js +42 -0
  98. package/test/manual-breakout-static.js +35 -0
  99. package/test/manual-classic-games.js +41 -0
  100. package/test/manual-clumsy-bird-three-pipes.js +81 -0
  101. package/test/manual-clumsy-bird.js +52 -0
  102. package/test/manual-hextris.js +30 -0
  103. package/test/manual-numbered-music.js +24 -0
  104. package/test/manual-t-rex-runner.js +49 -0
  105. package/test/manual-threejs-scene.js +64 -0
  106. package/test/manual-webgl-benchmark.js +27 -0
  107. package/test/manual-webgl-cpu.html +26 -0
  108. package/test/media-audio-lifetime.test.js +8 -0
  109. package/test/mp4-frame-times.test.js +33 -0
  110. package/test/page-scripts.test.js +96 -1
  111. package/test/piano-input.test.js +15 -2
  112. package/test/piano.test.js +11 -2
  113. package/test/playback-buffer.test.js +11 -0
  114. package/test/pointer-drag.test.js +33 -0
  115. package/test/positioning.test.js +82 -0
  116. package/test/pulse-client.test.js +51 -2
  117. package/test/raster-readback.test.js +14 -0
  118. package/test/script-rewrite.test.js +61 -3
  119. package/test/video-slow-end.test.js +6 -0
  120. package/test/viewport-keyboard-overflow.test.js +25 -0
  121. package/test/wayland-win32-resources.test.js +47 -0
  122. package/test/wayland-win32-wire.test.js +59 -0
  123. package/test/web-audio-renderer.test.js +8 -0
  124. package/test/web-audio.test.js +264 -0
  125. package/test/webgl-clip.test.js +9 -0
  126. package/test/webgl-cpu-fragment.test.js +43 -0
  127. package/test/window-named-properties.test.js +41 -0
  128. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfig.ts +2 -2
  129. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfigContentModelEnum.ts +1 -0
  130. package/vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts +23 -3
  131. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +1 -1
  132. package/vendor/land-renderer/LICENSE +21 -0
  133. package/vendor/land-renderer/README.md +14 -0
  134. package/vendor/land-renderer/direct-compositor.ts +443 -0
  135. package/vendor/land-renderer/frame.js +88 -0
  136. package/vendor/land-renderer/png-fast.js +85 -0
  137. package/vendor/land-renderer/raw-frames.js +66 -0
  138. package/vendor/land-renderer/render-types.ts +7 -0
  139. package/vendor/land-renderer/renderer.ts +134 -0
  140. package/vendor/land-renderer/snapshot.js +37 -0
  141. package/vendor/land-renderer/viewport.js +18 -0
  142. package/vendor/ses/dist/ses.mjs +1 -1
@@ -0,0 +1,65 @@
1
+ // Isolate host scripting globals and model the decoder's bounded session pool.
2
+ import {strict as assert} from 'node:assert';
3
+ import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
4
+
5
+ let active=0,opened=0,closed=0;
6
+ const outputs=[],errors=[];
7
+ const renderer=await createPageRenderer({
8
+ fetchResource:async url=>({ok:true,contentType:'audio/mpeg',body:new Uint8Array([url.includes('background')?1:2])}),
9
+ reportError:error=>errors.push(String(error)),
10
+ videoDecoder:{
11
+ probe:async bytes=>({duration:bytes[0]===1?100:0.1,audio:{sampleRate:1000}}),
12
+ async decodeAudio(bytes){
13
+ if(active>=8)throw Error('audio decoder session pool exhausted');
14
+ active++;opened++;
15
+ let remaining=bytes[0]===1?100000:100,disposed=false;
16
+ return {sampleRate:1000,channels:1,
17
+ nextSamples(){assert.equal(disposed,false);if(!remaining)return null;const size=Math.min(100,remaining);remaining-=size;return new Float32Array(size).fill(.25);},
18
+ close(){assert.equal(disposed,false,'each decoder closes once');disposed=true;active--;closed++;},
19
+ };
20
+ },
21
+ },
22
+ audio:{
23
+ open(options,listener){outputs.push({listener,samples:0});return outputs.length;},
24
+ write(id,bytes){outputs[id-1].samples+=bytes.length/2;},
25
+ finish(id){queueMicrotask(()=>outputs[id-1].listener({event:'ended'}));},
26
+ close(id){return Promise.resolve(outputs[id-1].samples*2);},
27
+ },
28
+ });
29
+ const evaluate=source=>renderer.evaluate(source);
30
+ const waitFor=async predicate=>{for(let i=0;i<100;i++){if(await predicate())return;await Bun.sleep(5);}assert.fail('audio did not finish');};
31
+ async function finishSound(){
32
+ const output=outputs.at(-1);
33
+ output.listener({event:'request',bytes:1000});
34
+ await waitFor(()=>evaluate('effect.ended'));
35
+ assert.equal(output.samples,100);
36
+ assert.equal(await evaluate('effect.currentTime'),.1);
37
+ assert.equal(await evaluate('effect.error'),null);
38
+ assert.equal(await evaluate('background.paused'),false);
39
+ assert.equal(active,1,'only the playing background decoder remains after each sound ends');
40
+ }
41
+ try{
42
+ renderer.setScripting('host');
43
+ await renderer.loadDocument({url:'https://audio-lifetime.test/',contentType:'text/html',source:'<body></body>'});
44
+ await evaluate(`globalThis.background=new Audio('background.mp3');background.play()`);
45
+ assert.equal(active,1);
46
+ // Games can retain dozens of short-lived Audio objects without asking them
47
+ // to release a decoder explicitly. Keeping those DOM objects is harmless.
48
+ await evaluate('globalThis.saved=[]');
49
+ for(let i=0;i<12;i++){
50
+ await evaluate(`globalThis.effect=new Audio('effect.mp3');saved.push(effect);effect.play()`);
51
+ assert.equal(active,2);
52
+ await finishSound();
53
+ }
54
+ // Released decoders must be reconstructed for replay and for a seek.
55
+ await evaluate('effect.play()');await finishSound();
56
+ await evaluate('effect.currentTime=0');
57
+ await waitFor(()=>evaluate('effect.readyState>=2'));
58
+ await evaluate('effect.play()');await finishSound();
59
+ assert.equal(opened,15);
60
+ assert.equal(closed,14);
61
+ assert.deepEqual(errors,[]);
62
+ process.stdout.write('audio decoder lifetime integration passed\n');
63
+ }finally{renderer.dispose();}
64
+ assert.equal(active,0,'navigation/disposal releases the background too');
65
+ assert.equal(closed,opened);
@@ -8,18 +8,17 @@ import { createPageRenderer, loadGraphics } from '../../lib/renderer/page-render
8
8
  const { CanvasKit: kit } = await loadGraphics();
9
9
  lockdown({ errorTaming: 'safe', overrideTaming: 'severe', consoleTaming: 'unsafe',
10
10
  localeTaming: 'unsafe', stackFiltering: 'concise', reporting: 'none' });
11
- const malloc = kit._malloc, free = kit._free, makeSurface = kit.MakeSurface;
11
+ const malloc = kit._malloc, free = kit._free, makeSurface = kit.MakeSurface, makeRasterDirectSurface = kit.MakeRasterDirectSurface;
12
12
  const allocations = new Map(), surfaces = new Set(), images = new Set();
13
13
  const errors = [];
14
14
  let reads = 0, failAt = Infinity;
15
15
  kit._malloc = size => { const pointer = malloc(size); allocations.set(pointer, size); return pointer; };
16
16
  kit._free = pointer => { allocations.delete(pointer); return free(pointer); };
17
- kit.MakeSurface = (...args) => {
18
- const surface = makeSurface(...args);
17
+ const trackSurface = surface => {
19
18
  if (!surface) return surface;
20
19
  surfaces.add(surface);
21
- const dispose = surface.dispose.bind(surface), snapshot = surface.makeImageSnapshot.bind(surface);
22
- surface.dispose = () => { surfaces.delete(surface); return dispose(); };
20
+ const releaseSurface = surface.delete.bind(surface), snapshot = surface.makeImageSnapshot.bind(surface);
21
+ surface.delete = () => { surfaces.delete(surface); return releaseSurface(); };
23
22
  surface.makeImageSnapshot = (...args) => {
24
23
  const image = snapshot(...args), release = image.delete.bind(image), read = image.readPixels.bind(image);
25
24
  images.add(image);
@@ -32,6 +31,8 @@ kit.MakeSurface = (...args) => {
32
31
  };
33
32
  return surface;
34
33
  };
34
+ kit.MakeSurface = (...args) => trackSurface(makeSurface(...args));
35
+ kit.MakeRasterDirectSurface = (...args) => trackSurface(makeRasterDirectSurface(...args));
35
36
  const liveBytes = () => [...allocations.values()].reduce((sum, size) => sum + size, 0);
36
37
  let warmBytes;
37
38
  try {
@@ -107,4 +108,5 @@ try {
107
108
  kit._malloc = malloc;
108
109
  kit._free = free;
109
110
  kit.MakeSurface = makeSurface;
111
+ kit.MakeRasterDirectSurface = makeRasterDirectSurface;
110
112
  }
@@ -7,9 +7,9 @@ import {captureViewport} from '../../lib/paint/frame.js';
7
7
  const {CanvasKit:kit} = await loadGraphics();
8
8
  lockdown({errorTaming:'safe',overrideTaming:'severe',consoleTaming:'unsafe',localeTaming:'unsafe',stackFiltering:'concise',reporting:'none'});
9
9
  let readbacks = 0, checks = 0;
10
- const makeSurface = kit.MakeSurface;
11
- kit.MakeSurface = (...args) => {
12
- const surface = makeSurface(...args), snapshot = surface.makeImageSnapshot.bind(surface);
10
+ const makeSurface = kit.MakeSurface, makeRasterDirectSurface=kit.MakeRasterDirectSurface;
11
+ const trackSurface = surface => {
12
+ const snapshot = surface.makeImageSnapshot.bind(surface);
13
13
  surface.makeImageSnapshot = () => {
14
14
  const image = snapshot(), read = image.readPixels.bind(image);
15
15
  image.readPixels = (...args) => { readbacks++; return read(...args); };
@@ -17,6 +17,8 @@ kit.MakeSurface = (...args) => {
17
17
  };
18
18
  return surface;
19
19
  };
20
+ kit.MakeSurface=(...args)=>trackSurface(makeSurface(...args));
21
+ kit.MakeRasterDirectSurface=(...args)=>trackSurface(makeRasterDirectSurface(...args));
20
22
  const source = `<style>body{margin:0;background:#182331;color:white;height:500px}
21
23
  #wrap{position:absolute;left:12px;top:14px;width:160px;height:90px;overflow:hidden;border-radius:13px}
22
24
  video{display:block;width:160px;height:90px}#caption{position:absolute;left:5px;top:55px;background:#6548;font:12px sans-serif}
@@ -116,9 +118,9 @@ for (const alpha of [255,127]) {
116
118
  await repaint('incomplete-shared-frame',"document.getElementById('marker').style.background='lime'");
117
119
  seed(candidate,alpha,4);
118
120
  // Controls alone currently need a separate layout invalidation to refresh
119
- // their display-list bit, so force geometry here to exercise the fallback.
120
- await repaint('controls',"document.getElementById('v').controls=true;document.getElementById('marker').style.width='26px'",false);
121
- assert.equal(candidate.tile.videoLayer,null);
121
+ // their display-list bit, so force geometry here to exercise the layer.
122
+ await repaint('controls',"document.getElementById('v').controls=true;document.getElementById('marker').style.width='26px'");
123
+ assert.ok(candidate.tile.videoLayer,'controls retain independent video presentation');
122
124
  await mutate("document.getElementById('v').controls=false;document.getElementById('marker').style.width='27px'");
123
125
  for (const client of clients) await layer(client);seed(candidate,alpha);
124
126
  await repaint('controls-restored',"document.getElementById('marker').style.background='cyan'");
@@ -0,0 +1,39 @@
1
+ import {strict as assert} from 'node:assert';
2
+ import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
3
+ let decoded=0;const errors=[];
4
+ const renderer=await createPageRenderer({
5
+ fetchResource:async()=>({ok:true,contentType:'video/mp4',body:new Uint8Array([1])}),
6
+ reportError:e=>errors.push(String(e)),
7
+ videoDecoder:{
8
+ probe:async()=>({duration:.12,video:{width:2,height:2,fpsNum:100,fpsDen:1}}),
9
+ decode:async()=>{let index=0;return {width:2,height:2,fps:100,
10
+ seek(){index=0},close(){},nextFrame(width=2,height=2){
11
+ if(index===12)return null;
12
+ const end=performance.now()+25;while(performance.now()<end){}
13
+ index++;decoded=index;const pixels=new Uint8Array(width*height*4);
14
+ for(let i=0;i<pixels.length;i+=4){pixels[i]=index*20;pixels[i+3]=255}return pixels;
15
+ }}},
16
+ },
17
+ });
18
+ try{
19
+ renderer.setScripting('host');
20
+ await renderer.loadDocument({url:'https://slow-video.test/',contentType:'text/html',source:'<video id="v" src="slow.mp4" controls></video>'});
21
+ await renderer.evaluate(`globalThis.v=document.querySelector("video");
22
+ globalThis.positions=[];v.addEventListener('timeupdate',()=>{
23
+ if(v.ended)return;
24
+ const c=document.createElement('canvas');c.width=c.height=2;
25
+ const x=c.getContext('2d');x.drawImage(v,0,0,2,2);
26
+ positions.push([v.currentTime,x.getImageData(0,0,1,1).data[0]]);
27
+ });v.play()`);
28
+ for(let i=0;i<100&&!await renderer.evaluate('v.ended');i++)await Bun.sleep(10);
29
+ assert.equal(await renderer.evaluate('v.ended'),true);
30
+ assert.equal(decoded,12,'ended must wait for all frames of a slow silent decoder');
31
+ const last=await renderer.evaluate(`(()=>{const c=document.createElement('canvas');c.width=c.height=2;const x=c.getContext('2d');x.drawImage(v,0,0,2,2);return x.getImageData(0,0,1,1).data[0]})()`);
32
+ assert.equal(last,240,'the actual final frame must be retained at ended');
33
+ const positions=await renderer.evaluate('positions');
34
+ assert.ok(positions.length>0,'playback must report progress');
35
+ for(const [time,red] of positions)assert.ok(time<=(red/20-1)/100+1e-6,
36
+ `progress ${time} must not lead displayed frame ${red}`);
37
+ assert.deepEqual(errors,[]);
38
+ process.stdout.write('slow video end integration passed\n');
39
+ }finally{renderer.dispose()}
@@ -0,0 +1,45 @@
1
+ import assert from 'node:assert/strict';
2
+ import {createPageRenderer,loadGraphics} from '../../lib/renderer/page-renderer.js';
3
+
4
+ const mode=process.argv[2]??'ses';
5
+ if(mode==='ses'){
6
+ await import('../../vendor/ses/dist/ses.mjs');await loadGraphics();
7
+ lockdown({errorTaming:'safe',overrideTaming:'severe',consoleTaming:'unsafe',localeTaming:'unsafe',stackFiltering:'concise',reporting:'none'});
8
+ }
9
+ const outputs=[],closed=[],errors=[];
10
+ const renderer=await createPageRenderer({reportError:error=>errors.push(String(error)),audio:{
11
+ open(options,listener){outputs.push({options,listener,pcm:[]});return outputs.length;},
12
+ write(id,bytes){outputs[id-1].pcm.push(new Int16Array(bytes.buffer,bytes.byteOffset,bytes.byteLength/2).slice());},
13
+ close(id){closed.push(id);return Promise.resolve();},
14
+ }});
15
+ try{
16
+ renderer.setScripting(mode);
17
+ await renderer.loadDocument({url:'https://web-audio.test/',contentType:'text/html',source:'<body>Web Audio</body>'});
18
+ const result=await renderer.evaluate(`(()=>{
19
+ globalThis.c=new AudioContext({sampleRate:8000});globalThis.other=new webkitAudioContext({sampleRate:8000});
20
+ globalThis.osc=c.createOscillator();globalThis.gain=c.createGain();
21
+ osc.frequency.setValueAtTime(500,c.currentTime);gain.gain.value=.25;
22
+ osc.connect(gain).connect(c.destination);osc.start();globalThis.ends=0;osc.onended=()=>ends++;
23
+ return {context:c instanceof BaseAudioContext,node:osc instanceof AudioScheduledSourceNode,eventTarget:osc instanceof EventTarget,
24
+ param:osc.frequency instanceof AudioParam,alias:AudioContext===webkitAudioContext,name:AudioContext.name,
25
+ native:AudioContext.prototype.createOscillator.toString().includes('[native code]'),
26
+ output:osc.context===c&&gain.context===c,state:c.state};
27
+ })()`);
28
+ assert.deepEqual(result,{context:true,node:true,eventTarget:true,param:true,alias:true,name:'AudioContext',native:mode==='ses',output:true,state:'running'});
29
+ assert.equal(outputs.length,2);
30
+ assert.deepEqual(outputs[0].options,{rate:8000,channels:2,name:'Web Audio'});
31
+ outputs[0].listener({event:'request',bytes:512});
32
+ assert.ok(Math.abs(outputs[0].pcm[0][8]-8192)<=1,'real page graph produces the expected sine samples');
33
+ assert.equal(await renderer.evaluate('c.currentTime'),.016);
34
+ await renderer.evaluate('c.suspend()');assert.deepEqual(closed,[1]);
35
+ await renderer.evaluate('c.resume()');assert.equal(outputs.length,3);
36
+ await renderer.evaluate('osc.stop()');outputs[2].listener({event:'request',bytes:512});
37
+ await Bun.sleep(30);assert.equal(await renderer.evaluate('ends'),1);
38
+ assert.ok(outputs[2].pcm[0].every(x=>x===0));
39
+ await renderer.loadDocument({url:'https://web-audio.test/next',contentType:'text/html',source:'<p>next</p>'});
40
+ assert.deepEqual(closed.sort(),[1,2,3],'navigation closes every document-owned context');
41
+ const count=outputs[2].pcm.length;outputs[2].listener({event:'request',bytes:512});
42
+ assert.equal(outputs[2].pcm.length,count,'stale requests cannot revive an old graph');
43
+ assert.deepEqual(errors,[]);
44
+ process.stdout.write(`${mode} Web Audio page PCM and disposal passed\n`);
45
+ }finally{renderer.dispose();}
package/test/flex.test.js CHANGED
@@ -159,3 +159,29 @@ test("justify-content distributes a column container's extra height (min-height
159
159
  done();
160
160
  }
161
161
  });
162
+
163
+ test('reverse flex directions reflect items without reversing descendants',()=>{
164
+ for(const direction of ['row-reverse','column-reverse']){
165
+ const column=direction.startsWith('column');
166
+ const f=flex('<div id="root"><div id="a"><div id="nested"></div></div><div id="b"></div></div>',
167
+ `#root{display:flex;flex-flow:${direction} nowrap;width:100px;height:100px;gap:5px} #a,#b{width:20px;height:20px;flex-shrink:0} #nested{width:5px;height:5px}`);
168
+ try{
169
+ const a=f.rect('#a'),b=f.rect('#b'),n=f.rect('#nested');
170
+ expect(column?a.top:a.left).toBe(80);
171
+ expect(column?b.top:b.left).toBe(55);
172
+ expect(n.top).toBe(a.top);expect(n.left).toBe(a.left);
173
+ }finally{f.done()}
174
+ }
175
+ });
176
+
177
+ test('column-reverse resolves intrinsic height, spacing and physical margins',()=>{
178
+ const f=flex('<div id="root"><div id="a"></div><div id="b"></div></div>',
179
+ '#root{display:flex;flex-direction:column-reverse;width:30px;gap:5px} #a{height:10px;margin-top:3px;margin-bottom:7px} #b{height:20px}');
180
+ try{expect(f.rect('#root').height).toBe(45);expect(f.rect('#a').top).toBe(28);expect(f.rect('#b').top).toBe(0)}finally{f.done()}
181
+ });
182
+
183
+ test('column-reverse preserves the sign of child transform translations',()=>{
184
+ const f=flex('<div id="root"><div id="a"></div><div id="b"></div></div>',
185
+ '#root{display:flex;flex-direction:column-reverse;width:40px;height:100px} #a,#b{height:20px} #a{transform:translateY(-30px)}');
186
+ try{expect(f.rect('#a').top).toBe(50);expect(f.rect('#b').top).toBe(60)}finally{f.done()}
187
+ });
package/test/grid.test.js CHANGED
@@ -309,3 +309,16 @@ test("an item stretches to its area by default and justify-items can center it",
309
309
  expect(render(grid("grid-template-columns: 8px; justify-items: center", "<i>ab</i>")).item(0))
310
310
  .toEqual({ left: 3, top: 0, width: 2, height: 1 });
311
311
  });
312
+
313
+ test('fractional rows fill a definite aspect-ratio grid even when cells are empty',()=>{
314
+ const {items}=render(grid('width:100px;aspect-ratio:1;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:8px', '<i></i><i></i><i></i><i></i>'),100);
315
+ expect(items()).toEqual([
316
+ {left:0,top:0,width:46,height:46},{left:54,top:0,width:46,height:46},
317
+ {left:0,top:54,width:46,height:46},{left:54,top:54,width:46,height:46},
318
+ ]);
319
+ });
320
+
321
+ test('fractional row sizing respects fixed tracks and intrinsic minimum heights',()=>{
322
+ const {items}=render(grid('width:40px;height:100px;grid-template-columns:40px;grid-template-rows:20px 1fr 2fr;row-gap:10px','<i></i><i></i><i></i>'),40);
323
+ expect(items().map(item=>item.height)).toEqual([20,20,40]);
324
+ });
@@ -224,6 +224,15 @@ test("top-level shell exposes the parsed document through Bun.WebView", async ()
224
224
  await view.cdp('Input.dispatchKeyEvent', {type,key:' ',code:'Space',windowsVirtualKeyCode:32,text:type==='char'?' ':undefined});
225
225
  }
226
226
  }
227
+ await view.evaluate(`window.heldKey=false;window.heldEvents=[];
228
+ document.addEventListener('keydown',e=>{if(e.code==='KeyW'){heldKey=true;heldEvents.push('down');e.preventDefault()}});
229
+ document.addEventListener('keyup',e=>{if(e.code==='KeyW'){heldKey=false;heldEvents.push('up')}});`);
230
+ await view.cdp('Input.dispatchKeyEvent',{type:'keyDown',key:'w',code:'KeyW',windowsVirtualKeyCode:87});
231
+ expect(await view.evaluate('heldKey')).toBe(true);
232
+ expect(await view.evaluate('new Promise(resolve=>requestAnimationFrame(()=>resolve(heldKey)))')).toBe(true);
233
+ await view.cdp('Input.dispatchKeyEvent',{type:'keyUp',key:'w',code:'KeyW',windowsVirtualKeyCode:87});
234
+ expect(await view.evaluate('heldKey')).toBe(false);
235
+ expect(await view.evaluate('heldEvents')).toEqual(['down','up']);
227
236
  expect(await view.evaluate('spaceKeys')).toEqual(Array.from({length:6},()=>[32,32]));
228
237
  expect(await view.evaluate('scrollY')).toBe(0);
229
238
  await view.cdp("Input.dispatchMouseEvent", {
@@ -0,0 +1,28 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {parseWindowSize} from '../lib/window-size.js';
3
+ import {CDP} from '../tools/browser-lab.js';
4
+ test('window size defaults and validation',()=>{
5
+ expect(parseWindowSize([])).toEqual({width:800,height:600});
6
+ expect(parseWindowSize(['--window-size=1920,1080'])).toEqual({width:1920,height:1080});
7
+ expect(parseWindowSize(['--window-size=640x480'])).toEqual({width:640,height:480});
8
+ for(const size of ['', '0,600','-1,600','800,','800,600,1','1.5,600','9007199254740992,600'])
9
+ expect(()=>parseWindowSize(['--window-size='+size])).toThrow('positive integers');
10
+ });
11
+ test('persistent headless CLI size reaches renderer, CDP targets and captures; CDP can override it',async()=>{
12
+ const child=Bun.spawn([process.execPath,'index.js','--headless','--window-size=960,540','--remote-debugging-port=0'],{cwd:new URL('..',import.meta.url).pathname,stdout:'ignore',stderr:'pipe'});
13
+ let client;const reader=child.stderr.getReader();
14
+ const deadline=setTimeout(()=>child.kill(),20000);let log='';
15
+ try{
16
+ let match;
17
+ while(!(match=log.match(/DevTools listening on (ws:\/\/\S+)/))){const {value,done}=await reader.read();if(done)throw Error(log);log+=new TextDecoder().decode(value);}
18
+ client=new CDP(match[1]);await client.ready;
19
+ const {targetId}=await client.send('Target.createTarget',{url:'about:blank'});
20
+ const {sessionId}=await client.send('Target.attachToTarget',{targetId,flatten:true});client.sessionId=sessionId;
21
+ const target=await client.send('Target.getTargetInfo',{targetId});expect(target.targetInfo.viewport).toMatchObject({width:960,height:540});
22
+ const metrics=await client.send('Page.getLayoutMetrics');expect(metrics.cssVisualViewport).toMatchObject({clientWidth:960,clientHeight:540});
23
+ const capture=await client.send('Page.captureScreenshot',{format:'png'});const png=Buffer.from(capture.data,'base64');
24
+ expect([png.readUInt32BE(16),png.readUInt32BE(20)]).toEqual([960,540]);
25
+ await client.send('Emulation.setDeviceMetricsOverride',{width:640,height:360,deviceScaleFactor:1,mobile:false});
26
+ const resized=await client.send('Page.getLayoutMetrics');expect(resized.cssVisualViewport).toMatchObject({clientWidth:640,clientHeight:360});
27
+ }finally{clearTimeout(deadline);client?.close();reader.releaseLock();child.kill();await child.exited;}
28
+ },25000);
@@ -0,0 +1,35 @@
1
+ import {test, expect} from 'bun:test';
2
+ import {parseHTMLDocument} from '../lib/happy-dom/parser.js';
3
+
4
+ test('textarea preserves embedded markup as text and decodes character references', () => {
5
+ const {document} = parseHTMLDocument('<textarea>\nconst html = `<div>&amp;<br></div>`;<!-- literal -->\n</TEXTAREA><p>after</p>');
6
+ const area = document.querySelector('textarea');
7
+ expect(area.value).toBe('const html = `<div>&<br></div>`;<!-- literal -->\n');
8
+ expect(area.children.length).toBe(0);
9
+ expect(document.querySelector('p').textContent).toBe('after');
10
+ });
11
+
12
+ test('RCDATA ends only at matching end tag, retaining all content through EOF', () => {
13
+ const {document} = parseHTMLDocument('<textarea>a</textareax><b>c &lt;d\0');
14
+ expect(document.querySelector('textarea').value).toBe('a</textareax><b>c <d\uFFFD');
15
+ const other = parseHTMLDocument('<title>A <b>B</b> &amp; C</title><textarea>&#10;hello</textarea>').document;
16
+ expect(other.title).toBe('A <b>B</b> & C');
17
+ expect(other.querySelector('textarea').value).toBe('hello');
18
+ });
19
+
20
+ test('textarea parsing also works inside HTML fragments without changing script raw text', () => {
21
+ const {document} = parseHTMLDocument('');
22
+ document.body.innerHTML='<textarea><div>&amp;</div></textarea><script>"<div>&amp;</div>"</script>';
23
+ expect(document.querySelector('textarea').value).toBe('<div>&</div>');
24
+ expect(document.querySelector('script').textContent).toBe('"<div>&amp;</div>"');
25
+ });
26
+
27
+ test('SVG title remains foreign content and does not swallow following SVG or HTML siblings',()=>{
28
+ const {document,window}=parseHTMLDocument('<div><svg><title>Share &amp; score</title><g><text><tspan>label</tspan></text></g></svg><button id="after">play</button></div>');
29
+ try{
30
+ expect(document.querySelector('svg title').textContent).toBe('Share & score');
31
+ expect(document.querySelector('svg g tspan').textContent).toBe('label');
32
+ expect(document.getElementById('after').textContent).toBe('play');
33
+ expect(document.querySelector('svg title').namespaceURI).toBe('http://www.w3.org/2000/svg');
34
+ }finally{window.happyDOM.close();}
35
+ });
@@ -0,0 +1,18 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {createBrowser} from '../lib/headless-shell.js';
3
+ import {CdpServer} from '../lib/cdp-server.js';
4
+ import {CDP} from '../tools/browser-lab.js';
5
+ const png=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a9ZkAAAAASUVORK5CYII=','base64');
6
+ test('image decode waits for real pixels, handles detached images and rejects broken or replaced requests',async()=>{
7
+ const server=Bun.serve({hostname:'127.0.0.1',port:0,async fetch(req){const path=new URL(req.url).pathname;if(path==='/'){return new Response('<img id="initial" src="/ok.png">',{headers:{'Content-Type':'text/html'}});}if(path==='/slow.png')await Bun.sleep(80);return path==='/bad'?new Response('broken',{status:404}):new Response(png,{headers:{'Content-Type':'image/png'}});}});
8
+ const browser=await createBrowser({spareRenderer:false}),cdpServer=CdpServer.create(browser.context).listen(0),c=new CDP(`ws://127.0.0.1:${cdpServer.port}/devtools/browser/cdp-server`);
9
+ try{
10
+ await c.send('Page.navigate',{url:server.url.href});
11
+ expect(await c.evaluate('(async()=>{await initial.decode();const c=document.createElement("canvas").getContext("2d");c.drawImage(initial,0,0);return [initial.complete,initial.naturalWidth,initial.naturalHeight,c.getImageData(0,0,1,1).data[3]]})()')).toEqual([true,1,1,255]);
12
+ expect(await c.evaluate('(async()=>{const im=new Image();const p=im.decode();im.src="/slow.png";await p;return [im.complete,im.naturalWidth]})()')).toEqual([true,1]);
13
+ expect(await c.evaluate('(async()=>{const im=new Image();im.src="/slow.png";await Promise.all([im.decode(),im.decode()]);let sync=true;const p=im.decode().then(()=>sync);sync=false;return await p})()')).toBe(false);
14
+ expect(await c.evaluate(`(async()=>{const im=new Image();im.src='/ok.png';await im.decode();const before=[im.width,im.height];im.width=7;im.height=9;const explicit=[im.width,im.height];im.removeAttribute('width');im.removeAttribute('height');im.style.cssText='width:30px;height:20px;padding:2px;border:3px solid black;transform:scale(2)';document.body.appendChild(im);const displayed=[im.width,im.height];im.style.display='none';return [before,explicit,displayed,[im.width,im.height]]})()`)).toEqual([[1,1],[7,9],[30,20],[1,1]]);
15
+ for(const src of['','/bad','http://['])expect(await c.evaluate(`(async()=>{const im=new Image();im.src=${JSON.stringify(src)};try{await im.decode();return 'resolved'}catch(e){return e.name}})()`)).toBe('EncodingError');
16
+ expect(await c.evaluate('(async()=>{const im=new Image();im.src="/slow.png";const p=im.decode().then(()=>"resolved",e=>e.name);await Promise.resolve();im.removeAttribute("src");return await p})()')).toBe('EncodingError');
17
+ }finally{c.close();cdpServer.stop(true);browser.close();server.stop(true);}
18
+ },30000);
@@ -0,0 +1,83 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {createBrowser} from '../lib/headless-shell.js';
3
+ import {CdpServer} from '../lib/cdp-server.js';
4
+ import {CDP} from '../tools/browser-lab.js';
5
+ const until=async fn=>{for(let i=0;i<500;i++){if(fn())return;await Bun.sleep(10);}throw Error('dialog did not open');};
6
+ test('real renderer dialogs block script until CDP responds and preserve synchronous return values',async()=>{
7
+ let copied="";
8
+ const browser=await createBrowser({spareRenderer:false,clipboard:{read:async()=>copied,write:async text=>{copied=text;}}});const server=CdpServer.create(browser.context).listen(0);
9
+ const cdp=new CDP(`ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);
10
+ try{
11
+ await cdp.ready;const {targetId}=await cdp.send('Target.createTarget',{url:'about:blank'});
12
+ cdp.sessionId=(await cdp.send('Target.attachToTarget',{targetId,flatten:true})).sessionId;
13
+ await cdp.send('Page.enable');
14
+ await cdp.send('Page.navigate',{url:'data:text/html,<title>Dialog test</title><p>hello</p>'});
15
+ let count=0;
16
+ async function answer(expression,type,message,accept,promptText=''){
17
+ let done=false;
18
+ const result=cdp.send('Runtime.evaluate',{expression,returnByValue:true}).then(value=>{done=true;return value;});
19
+ await until(()=>cdp.events.filter(e=>e.method==='Page.javascriptDialogOpening').length>count);
20
+ const event=cdp.events.filter(e=>e.method==='Page.javascriptDialogOpening')[count++];
21
+ expect(event.params).toMatchObject({type,message});expect(done).toBe(false);
22
+ await cdp.send('Page.handleJavaScriptDialog',{accept,promptText});
23
+ return (await result).result.value;
24
+ }
25
+ expect(await answer('alert(1+2); 42','alert','3',true)).toBe(42);
26
+ expect(await answer('confirm("yes?")','confirm','yes?',true)).toBe(true);
27
+ expect(await answer('confirm("no?")','confirm','no?',false)).toBe(false);
28
+ expect(await answer('prompt("name", "default")','prompt','name',true,' 中文 😀 ')).toBe(' 中文 😀 ');
29
+ expect(await answer('prompt("empty")','prompt','empty',true,'')).toBe('');
30
+ expect(await answer('prompt("cancel")','prompt','cancel',false)).toBe(null);
31
+ const nav=cdp.send('Page.navigate',{url:'javascript:alert(1+2)'});
32
+ await until(()=>cdp.events.filter(e=>e.method==='Page.javascriptDialogOpening').length>count);
33
+ await cdp.send('Page.handleJavaScriptDialog',{accept:true});await nav;
34
+ // Browser UI works through ordinary input, including long selectable messages.
35
+ const ui=cdp.send('Runtime.evaluate',{expression:'alert("long message\\n".repeat(200)); 99',returnByValue:true});
36
+ await until(()=>Boolean(browser.context.dialogState));
37
+ const shot=await cdp.send('Page.captureScreenshot',{format:'png'});
38
+ await Bun.write('/tmp/browser-alert.png',Buffer.from(shot.data,'base64'));
39
+ const evaluate=async expression=>(await cdp.send('Runtime.evaluate',{expression,returnByValue:true})).result.value;
40
+ expect(await evaluate('document.getElementById("message").scrollHeight > document.getElementById("message").clientHeight')).toBe(true);
41
+ await cdp.send('Input.dispatchMouseEvent',{type:'mouseWheel',x:300,y:150,deltaX:0,deltaY:250});
42
+ expect(await evaluate('document.getElementById("message").scrollTop')).toBeGreaterThan(0);
43
+ await evaluate('const r=document.createRange();r.selectNodeContents(document.getElementById("message"));window.getSelection().removeAllRanges();window.getSelection().addRange(r);');
44
+ expect(await evaluate('window.getSelection().toString().length')).toBeGreaterThan(1000);
45
+ await cdp.send('Input.dispatchKeyEvent',{type:'keyDown',key:'c',code:'KeyC',modifiers:2});
46
+ await cdp.send('Input.dispatchKeyEvent',{type:'keyUp',key:'c',code:'KeyC',modifiers:2});
47
+ expect(copied.length).toBeGreaterThan(1000);
48
+ await cdp.send('Emulation.setDeviceMetricsOverride',{width:320,height:240,deviceScaleFactor:1});
49
+ const small=await cdp.send('Page.captureScreenshot',{format:'png'});const pixels=Buffer.from(small.data,'base64');
50
+ expect([pixels.readUInt32BE(16),pixels.readUInt32BE(20)]).toEqual([320,240]);
51
+ await Bun.write('/tmp/browser-alert-small.png',pixels);
52
+ await cdp.send('Input.dispatchKeyEvent',{type:'keyDown',key:'Enter',code:'Enter'});
53
+ await cdp.send('Input.dispatchKeyEvent',{type:'keyUp',key:'Enter',code:'Enter'});
54
+ expect((await ui).result.value).toBe(99);
55
+ const input=cdp.send('Runtime.evaluate',{expression:'prompt("Your name", "default")',returnByValue:true});
56
+ await until(()=>Boolean(browser.context.dialogState));
57
+ await cdp.send('Input.insertText',{text:'Alice'});
58
+ await cdp.send('Input.dispatchKeyEvent',{type:'keyDown',key:'Enter',code:'Enter'});
59
+ expect((await input).result.value).toBe('Alice');
60
+ await cdp.send('Input.dispatchKeyEvent',{type:'keyUp',key:'Enter',code:'Enter'});
61
+ await cdp.send('Page.navigate',{url:'data:text/html,'+encodeURIComponent(`<button id="ask" onclick="window.answer=confirm('From a page click')">Ask</button>`)});
62
+ await cdp.send('Input.dispatchMouseEvent',{type:'mousePressed',button:'left',x:20,y:18});
63
+ const clicked=cdp.send('Input.dispatchMouseEvent',{type:'mouseReleased',button:'left',x:20,y:18});
64
+ await until(()=>Boolean(browser.context.dialogState));
65
+ await cdp.send('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape'});
66
+ await clicked;
67
+ expect((await cdp.send('Runtime.evaluate',{expression:'window.answer',returnByValue:true})).result.value).toBe(false);
68
+ // Inline scripts can pause document loading, without blocking the modal UI.
69
+ const loading=cdp.send('Page.navigate',{url:'data:text/html,'+encodeURIComponent('<script>alert("During load");window.loadedAfterDialog=true;</script>')});
70
+ await until(()=>browser.context.dialogState?.message==='During load');
71
+ await cdp.send('Page.captureScreenshot',{format:'png'});
72
+ await cdp.send('Page.handleJavaScriptDialog',{accept:true});
73
+ await loading;
74
+ expect(await evaluate('window.loadedAfterDialog')).toBe(true);
75
+ // Reload cancels the old dialog before waiting on its blocked renderer.
76
+ await cdp.send('Page.navigate',{url:'about:blank'});
77
+ const pending=cdp.send('Runtime.evaluate',{expression:'confirm("Reload me")',returnByValue:true});
78
+ await until(()=>Boolean(browser.context.dialogState));
79
+ await cdp.send('Page.reload');
80
+ expect((await pending).result.value).toBe(false);
81
+ expect(browser.context.dialogState).toBe(null);
82
+ }finally{cdp.close();server.stop(true);browser.close();}
83
+ },30000);
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env bun
2
+ // Controlled RAF timestamps exercise the external game, never browser production code.
3
+ // bun test/manual-breakout-paddle.js ../Classic-Games [buninu|chromium]
4
+ import {resolve,sep} from 'node:path';
5
+ import {launch} from '../tools/browser-lab.js';
6
+ const root=resolve(process.argv[2]??'../Classic-Games'),engine=process.argv[3]??'buninu';
7
+ const server=Bun.serve({hostname:'127.0.0.1',port:0,fetch(request){
8
+ const path=resolve(root,'.'+new URL(request.url).pathname);
9
+ return path.startsWith(root+sep)?new Response(Bun.file(path)):new Response('Forbidden',{status:403});
10
+ }});
11
+ let app;
12
+ try{
13
+ app=await launch(engine,`/tmp/breakout-paddle-${engine}`);const c=app.cdp;
14
+ await c.send('Page.addScriptToEvaluateOnNewDocument',{source:`(()=>{
15
+ let next=0;const frames=new Map();
16
+ window.requestAnimationFrame=callback=>{frames.set(++next,callback);return next};
17
+ window.cancelAnimationFrame=id=>frames.delete(id);
18
+ window.__step=time=>{const batch=[...frames.values()];frames.clear();for(const callback of batch)callback(time)};
19
+ const p=CanvasRenderingContext2D.prototype,original=p.roundRect;
20
+ p.roundRect=function(...args){if(args[2]===80&&args[3]===12)window.__paddle=args[0];return original.apply(this,args)};
21
+ })()`});
22
+ const results=[];
23
+ for(const fps of [15,60,120]){
24
+ await c.send('Page.navigate',{url:`http://127.0.0.1:${server.port}/games/breakout/index.html?fps=${fps}`});await Bun.sleep(300);
25
+ const point=await c.evaluate(`(()=>{const r=document.querySelector('#playBtn').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`);
26
+ for(const type of ['mousePressed','mouseReleased'])await c.send('Input.dispatchMouseEvent',{type,...point,button:'left',clickCount:1});
27
+ await c.evaluate('__step(0)');
28
+ const initial=await c.evaluate('__paddle');
29
+ const target=await c.evaluate(`(()=>{const r=document.querySelector('canvas').getBoundingClientRect();return {x:r.x+r.width*.85,y:r.y+r.height*.8}})()`);
30
+ await c.send('Input.dispatchMouseEvent',{type:'mouseMoved',...target});
31
+ const count=Math.round(fps*.4);
32
+ const actual=await c.evaluate(`(()=>{for(let i=1;i<=${count};i++)__step(i*1000/${fps});return __paddle})()`);
33
+ const expected=initial+480*.4;
34
+ if(Math.abs(actual-expected)>.01)throw Error(`${fps} FPS paddle ${actual}, expected ${expected}`);
35
+ const left=await c.evaluate(`(()=>{const r=document.querySelector('canvas').getBoundingClientRect();return {x:r.x+r.width*.15,y:r.y+r.height*.8}})()`);
36
+ await c.send('Input.dispatchMouseEvent',{type:'mouseMoved',...left});
37
+ const before=actual;await c.evaluate('__step(1400)');const after=await c.evaluate('__paddle');
38
+ if(Math.abs((before-after)-48)>.01)throw Error('Resume jump does not honor 100 ms catch-up cap');
39
+ results.push({fps,elapsedMs:400,initial,actual,expected,resumeMovement:before-after});
40
+ }
41
+ console.log(JSON.stringify({engine,passed:true,results}));
42
+ }finally{try{await app?.stop()}finally{server.stop(true)}}
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bun
2
+ // Compare the external upstream game and its static-layer patch using real Canvas pixels.
3
+ // bun test/manual-breakout-static.js ../Classic-Games [buninu|chromium]
4
+ import {resolve} from 'node:path';
5
+ import {launch} from '../tools/browser-lab.js';
6
+ const root=resolve(process.argv[2]??'../Classic-Games'),engine=process.argv[3]??'buninu';
7
+ const result=Bun.spawnSync(['git','-C',root,'show','HEAD:games/breakout/index.html']);
8
+ if(result.exitCode)throw Error('Cannot read upstream Breakout');
9
+ const original=result.stdout.toString(),cached=await Bun.file(resolve(root,'games/breakout/index.html')).text();
10
+ const server=Bun.serve({hostname:'127.0.0.1',port:0,fetch(request){return new Response(new URL(request.url).pathname==='/original'?original:cached,{headers:{'content-type':'text/html'}})}});
11
+ let app;
12
+ try{
13
+ app=await launch(engine,`/tmp/breakout-static-${engine}`);const c=app.cdp;
14
+ await c.send('Page.addScriptToEvaluateOnNewDocument',{source:`(()=>{Math.random=()=>.5;let next=0;const frames=new Map();window.requestAnimationFrame=f=>{frames.set(++next,f);return next};window.cancelAnimationFrame=id=>frames.delete(id);window.__step=t=>{const batch=[...frames.values()];frames.clear();for(const f of batch)f(t)};window.__brickPaints=0;const p=CanvasRenderingContext2D.prototype,o=p.roundRect;p.roundRect=function(...a){if(a[2]===52&&a[3]===18)__brickPaints++;return o.apply(this,a)}})()`});
15
+ const snapshots=[];
16
+ for(const name of ['original','cached']){
17
+ await c.send('Page.navigate',{url:`http://127.0.0.1:${server.port}/${name}`});await Bun.sleep(250);
18
+ const point=await c.evaluate(`(()=>{const r=document.getElementById('playBtn').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`);
19
+ for(const type of ['mousePressed','mouseReleased'])await c.send('Input.dispatchMouseEvent',{type,...point,button:'left',clickCount:1});
20
+ await c.evaluate('__step(0)');
21
+ const capture=()=>c.evaluate(`document.querySelector('canvas').toDataURL()`);
22
+ const initial=await capture(),paintStart=await c.evaluate('__brickPaints');
23
+ await c.evaluate('__step(16.6666666667)');const idlePaints=await c.evaluate(`__brickPaints-${paintStart}`);
24
+ for(const type of ['keyDown','keyUp'])await c.send('Input.dispatchKeyEvent',{type,key:' ',code:'Space',windowsVirtualKeyCode:32});
25
+ await c.evaluate('for(let i=2;i<=110;i++)__step(i*1000/60)');
26
+ const afterHit=await capture(),score=await c.evaluate('document.getElementById("score-val").textContent');
27
+ snapshots.push({name,initial,afterHit,idlePaints,score});
28
+ }
29
+ const [a,b]=snapshots;
30
+ if(a.initial!==b.initial)throw Error('Static cache changes the initial Canvas image');
31
+ if(a.afterHit!==b.afterHit)throw Error('Static cache changes Canvas pixels after a brick hit');
32
+ if(!/\d/.test(b.score)||parseInt(b.score)<=0)throw Error('No brick was destroyed');
33
+ if(a.idlePaints!==60||b.idlePaints!==0)throw Error('Static bricks were not reused');
34
+ console.log(JSON.stringify({engine,passed:true,identicalInitialPixels:true,identicalPixelsAfterHit:true,originalIdleBrickPaints:a.idlePaints,cachedIdleBrickPaints:b.idlePaints,score:b.score}));
35
+ }finally{try{await app?.stop()}finally{server.stop(true)}}
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bun
2
+ // External, unmodified games. Only CDP input controls them; evaluations observe.
3
+ // bun test/manual-classic-games.js /path/to/Classic-Games [buninu|chromium] [output-directory]
4
+ import {resolve,join,sep} from 'node:path';
5
+ import {launch} from '../tools/browser-lab.js';
6
+ import {playClassicGame} from './classic-games-scenarios.js';
7
+ const root=resolve(process.argv[2]??'../Classic-Games'),engine=process.argv[3]??'buninu',directory=resolve(process.argv[4]??`/tmp/classic-games-${engine}`);
8
+ const allGames=['tetris','snake','2048','flappy','space','ttt','memory','mine','pong','breakout','dino','sudoku','connect4','typing','simon','puzzle15'];
9
+ const games=process.env.CLASSIC_GAMES?process.env.CLASSIC_GAMES.split(','):allGames;
10
+ const server=Bun.serve({hostname:'127.0.0.1',port:0,fetch(request){
11
+ const path=resolve(root,'.'+decodeURIComponent(new URL(request.url).pathname));
12
+ if(path!==root&&!path.startsWith(root+sep))return new Response('Forbidden',{status:403});
13
+ return new Response(Bun.file(path));
14
+ }});
15
+ let app;const report={engine,games:[]};
16
+ try{
17
+ app=await launch(engine,directory,[]);const c=app.cdp;
18
+ await c.send('Emulation.setDeviceMetricsOverride',{width:1000,height:900,deviceScaleFactor:1,mobile:false});
19
+ const read=()=>c.evaluate(`JSON.stringify({ready:document.readyState,text:document.body.innerText.slice(0,1500),canvases:[...document.querySelectorAll('canvas')].map(e=>({w:e.width,h:e.height})),buttons:[...document.querySelectorAll('button')].map(e=>{const r=e.getBoundingClientRect(),s=getComputedStyle(e);return {id:e.id,text:e.textContent.trim(),x:r.x+r.width/2,y:r.y+r.height/2,w:r.width,h:r.height,visible:r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none'}})})`,90000).then(JSON.parse);
20
+
21
+ const shot=async(name)=>{const {data}=await c.send('Page.captureScreenshot',{format:'png'});await Bun.write(join(directory,name+'.png'),Buffer.from(data,'base64'));};
22
+ for(const game of games){
23
+ const entry={game};report.games.push(entry);const started=performance.now();c.events.length=0;
24
+ const logFile=join(directory,engine+'.log');const logStart=(await Bun.file(logFile).text()).length;
25
+ try{
26
+ await c.send('Page.navigate',{url:`http://127.0.0.1:${server.port}/games/${game}/index.html`});
27
+ await Bun.sleep(1400);entry.initial=await read();await shot(game+'-initial');
28
+ entry.evidence=await playClassicGame(c,game);
29
+ await Bun.sleep(150);entry.after=await read();await shot(game+'-after');
30
+ }catch(error){entry.error=error.stack;}
31
+ entry.exceptions=c.events.filter(e=>e.method==='Runtime.exceptionThrown');
32
+ entry.pageErrors=(await Bun.file(logFile).text()).slice(logStart).split('\n').filter(line=>line.includes('page error:'));
33
+ entry.passed=!entry.error&&!entry.exceptions.length&&!entry.pageErrors.length&&!!entry.evidence?.length;
34
+ entry.ms=performance.now()-started;console.log(JSON.stringify({game,ms:Math.round(entry.ms),passed:entry.passed,error:entry.error,pageErrors:entry.pageErrors,exceptions:entry.exceptions.length,evidence:entry.evidence,buttons:entry.initial?.buttons.map(b=>b.text),text:entry.after?.text.slice(0,180)}));
35
+ await Bun.write(join(directory,'report.json'),JSON.stringify(report,null,2));
36
+ }
37
+ report.passed=report.games.every(game=>game.passed);
38
+ await Bun.write(join(directory,'report.json'),JSON.stringify(report,null,2));
39
+ console.log(JSON.stringify({passed:report.passed,total:report.games.length,passedGames:report.games.filter(game=>game.passed).length}));
40
+ if(!report.passed)process.exitCode=1;
41
+ }finally{try{await app?.stop();}finally{server.stop(true);}}