buninu-browser 0.7.0 → 0.9.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 (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 +116 -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 +26 -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,81 @@
1
+ #!/usr/bin/env bun
2
+ // Live acceptance test; no game patches or third-party packages.
3
+ // bun test/manual-clumsy-bird-three-pipes.js [output-directory] [buninu|chromium]
4
+ import {resolve} from 'node:path';
5
+ import {launch} from '../tools/browser-lab.js';
6
+
7
+ const directory=resolve(process.argv[2]??'/tmp/clumsy-bird-three-pipes');
8
+ const engine=process.argv[3]??'buninu';
9
+ const url='https://ellisonleao.github.io/clumsy-bird/';
10
+ const app=await launch(engine,directory),cdp=app.cdp;
11
+ const report={engine,url,width:900,height:600,passed:false,attempts:[],exceptions:[]};
12
+ // Observation only: all controls use ordinary CDP keyboard events.
13
+ const readState=`JSON.stringify({
14
+ play:me.state.isCurrent(me.state.PLAY),menu:me.state.isCurrent(me.state.MENU),
15
+ over:me.state.isCurrent(me.state.GAME_OVER),start:game.data.start,score:game.data.steps,
16
+ y:me.state.current().bird?.pos.y,force:me.state.current().bird?.gravityForce,
17
+ collided:me.state.current().bird?.collided,
18
+ pipes:me.game.world.children.filter(o=>o.type==='pipe'&&o.pos.y>0&&o.pos.x>-90)
19
+ .map(o=>({x:o.pos.x,y:o.pos.y})).sort((a,b)=>a.x-b.x)
20
+ })`;
21
+ const state=async()=>JSON.parse(await cdp.evaluate(readState));
22
+ let held=null;
23
+ async function key(key,code,keyCode,hold=80){
24
+ const event={key,code,windowsVirtualKeyCode:keyCode};held=event;
25
+ try{
26
+ await cdp.send('Input.dispatchKeyEvent',{type:'rawKeyDown',...event});
27
+ await Bun.sleep(hold);
28
+ }finally{
29
+ await cdp.send('Input.dispatchKeyEvent',{type:'keyUp',...event});held=null;
30
+ }
31
+ }
32
+ try{
33
+ await cdp.send('Emulation.setDeviceMetricsOverride',{width:900,height:600,deviceScaleFactor:1,mobile:false});
34
+ await cdp.send('Page.navigate',{url});
35
+ let loaded=false;
36
+ for(let n=0;n<240;n++){
37
+ if(await cdp.evaluate('typeof me!=="undefined"&&me.state.isCurrent(me.state.MENU)')){loaded=true;break;}
38
+ await Bun.sleep(200);
39
+ }
40
+ if(!loaded)throw Error('Game menu did not load during the manual test');
41
+ for(let attempt=1;attempt<=16&&!report.passed;attempt++){
42
+ if((await state()).over){await key('Enter','Enter',13);await Bun.sleep(700);}
43
+ await key('Enter','Enter',13);
44
+ const started=performance.now(),record={attempt,states:[]};report.attempts.push(record);
45
+ let lastFlap=0,lastScore=-1;
46
+ while(performance.now()-started<180000){
47
+ const s=await state(),elapsedMs=performance.now()-started;
48
+ record.states.push({elapsedMs,...s});
49
+ if(s.score!==lastScore){console.log(JSON.stringify({attempt,elapsedMs,score:s.score}));lastScore=s.score;}
50
+ // The third scoring marker alone is not enough: its pipe must also have
51
+ // cleared the bird, and play must still be active without a collision.
52
+ if(s.score>=3&&s.pipes[0]?.x>150&&s.start&&!s.collided){
53
+ const {data}=await cdp.send('Page.captureScreenshot',{format:'png'});
54
+ const after=await state();
55
+ if(after.start&&!after.collided){
56
+ report.passed=true;report.final=after;report.elapsedMs=elapsedMs;
57
+ report.screenshot=directory+'/three-pipes.png';
58
+ await Bun.write(report.screenshot,Buffer.from(data,'base64'));
59
+ console.log('Passed three pipes:',report.screenshot);
60
+ break;
61
+ }
62
+ }
63
+ if(s.over||s.collided){await Bun.sleep(2200);break;}
64
+ if(s.start&&s.play){
65
+ const pipe=s.pipes[0];
66
+ const target=pipe&&pipe.x<650?Math.max(25,Math.min(430,pipe.y-88)):200;
67
+ const now=performance.now();
68
+ if(s.y>target+8&&now-lastFlap>100){lastFlap=now;await key(' ','Space',32,55);}
69
+ }
70
+ await Bun.sleep(10);
71
+ }
72
+ }
73
+ report.exceptions=cdp.events.filter(event=>event.method==='Runtime.exceptionThrown');
74
+ if(!report.passed)throw Error('CDP pilot did not clear three pipes during this run');
75
+ }finally{
76
+ try{await Bun.write(directory+'/three-pipes-report.json',JSON.stringify(report,null,2));}
77
+ finally{
78
+ if(held)await cdp.send('Input.dispatchKeyEvent',{type:'keyUp',...held}).catch(()=>{});
79
+ await app.stop();
80
+ }
81
+ }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env bun
2
+ // Live reference check: no page patches, downloaded source or extra packages.
3
+ // bun test/manual-clumsy-bird.js [buninu|chromium] [output-directory]
4
+ import {resolve} from 'node:path';
5
+ import {launch,snapshot} from '../tools/browser-lab.js';
6
+ const engine=process.argv[2]??'buninu',directory=resolve(process.argv[3]??'/tmp/clumsy-bird');
7
+ const url='https://ellisonleao.github.io/clumsy-bird/';
8
+ const app=await launch(engine,directory);
9
+ const report={engine,url};
10
+ try{
11
+ const {cdp}=app;
12
+ await cdp.send('Emulation.setDeviceMetricsOverride',{width:900,height:600,deviceScaleFactor:1,mobile:false});
13
+ console.log('navigate');const started=performance.now();await cdp.send('Page.navigate',{url});
14
+ console.log('waiting for menu');
15
+ const until=async expression=>{
16
+ const deadline=performance.now()+30000;
17
+ while(performance.now()<deadline){
18
+ if(await cdp.evaluate(expression,2000).catch(()=>false))return;
19
+ await Bun.sleep(250);
20
+ }
21
+ throw Error('Timed out waiting for '+expression);
22
+ };
23
+ await until('typeof me==="object" && me.state.isCurrent(me.state.MENU)');
24
+ report.menuMs=performance.now()-started;console.log('menu',report.menuMs);
25
+ await snapshot(cdp,directory,'menu');console.log('menu captured');
26
+ const rect=await cdp.evaluate('(()=>{const r=document.querySelector("canvas").getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()');
27
+ for(const type of ['mousePressed','mouseReleased'])await cdp.send('Input.dispatchMouseEvent',{type,...rect,button:'left',clickCount:1});
28
+ await until('me.state.isCurrent(me.state.PLAY)');
29
+ for(const type of ['keyDown','keyUp'])await cdp.send('Input.dispatchKeyEvent',{type,key:' ',code:'Space',windowsVirtualKeyCode:32});
30
+ for(let n=0;n<30&&!await cdp.evaluate('game.data.start');n++){
31
+ for(const type of ['keyDown','keyUp'])await cdp.send('Input.dispatchKeyEvent',{type,key:' ',code:'Space',windowsVirtualKeyCode:32});
32
+ await Bun.sleep(100);
33
+ }
34
+ if(!await cdp.evaluate('game.data.start'))throw Error('Game did not leave its ready screen');
35
+ report.inputStartedGame=true;
36
+ // Starting is a timed ready-screen transition, not proof that jump input works.
37
+ // Keep keyDown held across a real game update before releasing it.
38
+ const before=await cdp.evaluate('me.state.current().bird.pos.y');
39
+ await cdp.send('Input.dispatchKeyEvent',{type:'keyDown',key:' ',code:'Space',windowsVirtualKeyCode:32});
40
+ await Bun.sleep(100);
41
+ const after=await cdp.evaluate('me.state.current().bird.pos.y');
42
+ await cdp.send('Input.dispatchKeyEvent',{type:'keyUp',key:' ',code:'Space',windowsVirtualKeyCode:32});
43
+ report.flight={before,after};
44
+ if(!(after<before))throw Error('Space did not make the bird fly upward');
45
+
46
+ report.images=await cdp.evaluate('["clumsy","pipe","ground","logo"].map(name=>{const image=me.loader.getImage(name);return {name,width:image.width,height:image.height,naturalWidth:image.naturalWidth,naturalHeight:image.naturalHeight}})');
47
+ if(report.images.some(image=>!image.width||!image.height))throw Error('Loaded sprite image has no dimensions');
48
+ await snapshot(cdp,directory,'playing');
49
+ report.exceptions=cdp.events.filter(event=>event.method==='Runtime.exceptionThrown');
50
+ await Bun.write(resolve(directory,'clumsy-report.json'),JSON.stringify(report,null,2));
51
+ console.log(JSON.stringify(report,null,2));
52
+ }finally{await app.stop();}
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bun
2
+ // env NODE_TLS_REJECT_UNAUTHORIZED=0 bun test/manual-hextris.js [buninu|chromium] [output-directory]
3
+ // No game patches: Runtime.evaluate observes state; only CDP input operates it.
4
+ import assert from 'node:assert/strict';
5
+ import {resolve} from 'node:path';
6
+ import {launch} from '../tools/browser-lab.js';
7
+ const engine=process.argv[2]??'buninu',directory=resolve(process.argv[3]??`/tmp/hextris-validation-${engine}`);
8
+ const app=await launch(engine,directory,engine==='chromium'&&process.env.NODE_TLS_REJECT_UNAUTHORIZED==='0'?['--ignore-certificate-errors']:[]),c=app.cdp;
9
+ const report={engine,url:'https://hextris.io/',states:[]};
10
+ const state=()=>c.evaluate('JSON.stringify({state:gameState,position:MainHex.position,blocks:blocks.length,score})').then(JSON.parse);
11
+ async function key(key,code,n){for(const type of ['rawKeyDown','keyUp']){await c.send('Input.dispatchKeyEvent',{type,key,code,windowsVirtualKeyCode:n});if(type==='rawKeyDown')await Bun.sleep(60);}await Bun.sleep(120);}
12
+ try{
13
+ await c.send('Page.navigate',{url:report.url});
14
+ for(let n=0;n<300;n++){if(await c.evaluate('typeof MainHex!=="undefined"&&!!MainHex'))break;await Bun.sleep(100);}
15
+ report.states.push(await state());assert.equal(report.states[0].state,0);
16
+ const point=await c.evaluate('(()=>{const r=document.getElementById("startBtn").getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()');
17
+ for(const type of ['mousePressed','mouseReleased'])await c.send('Input.dispatchMouseEvent',{type,button:'left',clickCount:1,...point});
18
+ await Bun.sleep(250);assert.equal((await state()).state,1);
19
+ await key('ArrowLeft','ArrowLeft',37);report.states.push(await state());assert.equal(report.states.at(-1).position,1);
20
+ await key('ArrowRight','ArrowRight',39);assert.equal((await state()).position,0);
21
+ await key('p','KeyP',80);assert.equal((await state()).state,-1);
22
+ await Bun.sleep(700);await key('p','KeyP',80);
23
+ for(let n=0;n<50&&(await state()).state!==1;n++)await Bun.sleep(100);
24
+ assert.equal((await state()).state,1);
25
+ let generated=false;
26
+ for(let n=0;n<100;n++){const s=await state();if(s.blocks>0){generated=true;report.states.push(s);break;}await Bun.sleep(100);}
27
+ assert.ok(generated,'live blocks must be generated');
28
+ const {data}=await c.send('Page.captureScreenshot',{format:'png'});await Bun.write(directory+'/playing.png',Buffer.from(data,'base64'));
29
+ report.passed=true;console.log(JSON.stringify(report));
30
+ }finally{try{await Bun.write(directory+'/report.json',JSON.stringify(report,null,2));}finally{await app.stop();}}
@@ -0,0 +1,24 @@
1
+ // Live reference test; opens only its own headless browser and closes it.
2
+ // bun test/manual-numbered-music.js [buninu|chromium] [output-directory]
3
+ import {launch} from '../tools/browser-lab.js';
4
+ import assert from 'node:assert/strict';
5
+ const engine=process.argv[2]??'buninu',dir=process.argv[3]??'/tmp/numbered-music-playback-'+Date.now();
6
+ const b=await launch(engine,dir,engine==='chromium'?['--autoplay-policy=no-user-gesture-required']:[]),c=b.cdp;
7
+ try{
8
+ await c.send('Page.addScriptToEvaluateOnNewDocument',{source:`window.testContexts=[];window.testFrequencies=[];window.testOscillators=[];const OriginalAudioContext=window.AudioContext;window.AudioContext=class extends OriginalAudioContext{constructor(...a){super(...a);testContexts.push(this);}createOscillator(){const o=super.createOscillator();testOscillators.push(o);const p=o.frequency,set=p.setValueAtTime.bind(p);p.setValueAtTime=(v,t)=>{testFrequencies.push({v,t});return set(v,t)};return o;}};`});
9
+ await c.send('Page.navigate',{url:'https://drjohnjstest.blogspot.com/2023/10/text-to-numbered-musical-notation.html'});
10
+ for(let i=0;i<150;i++){if(await c.evaluate('typeof playsheet==="function"'))break;await Bun.sleep(200);}
11
+ const song='Title=Web Audio test\nSpeed=180\n1234567*1';
12
+ await c.evaluate(`tsheet.value=${JSON.stringify(song)};document.querySelector('button[onclick="playsheet(tsheet.value);"]').click();`);
13
+ for(let i=0;i<100;i++) {
14
+ if(await c.evaluate('testFrequencies.filter(e=>e.v>20).length>=8'))break;
15
+ await Bun.sleep(100);
16
+ }
17
+ await Bun.sleep(400);
18
+ let state=await c.evaluate('({contexts:testContexts.map(c=>({state:c.state,time:c.currentTime})),frequencies:testFrequencies,oscillators:testOscillators.length})');console.log('play button',{contexts:state.contexts,pitches:state.frequencies.filter(e=>e.v>20).map(e=>e.v)});assert.equal(state.oscillators,1);assert.ok(state.contexts[0].time>1);assert.ok(state.frequencies.filter(e=>e.v>20).length>=8);
19
+ await c.evaluate(`Promise.all([playsheet(${JSON.stringify('Speed=240\n1234')}),playsheet2(${JSON.stringify('Speed=240\n567*1')})])`);await Bun.sleep(2400);
20
+ state=await c.evaluate('({contexts:testContexts.map(c=>({state:c.state,time:c.currentTime})),frequencies:testFrequencies,oscillators:testOscillators.length})');console.log('two parts',{contexts:state.contexts,oscillators:state.oscillators});assert.equal(state.oscillators,2);assert.ok(state.contexts.every(c=>c.state==='running'&&c.time>1));
21
+ const exceptions=c.events.filter(e=>e.method==='Runtime.exceptionThrown');assert.deepEqual(exceptions,[]);
22
+ const shot=await c.send('Page.captureScreenshot',{format:'png'});await Bun.write(dir+'/playback.png',Buffer.from(shot.data,'base64'));console.log('screenshot',dir+'/playback.png');
23
+ console.log('PASS: original Play button, changing pitches and two-part playback use running Web Audio outputs');
24
+ }finally{await b.stop();}
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env bun
2
+ // Real desktop game, ordinary CDP input only. No patches to game state/physics.
3
+ // bun test/manual-t-rex-runner.js [buninu|chromium] [output-directory]
4
+ import {resolve} from 'node:path';
5
+ import {launch} from '../tools/browser-lab.js';
6
+ const engine=process.argv[2]??'buninu',directory=resolve(process.argv[3]??`/tmp/t-rex-${engine}`);
7
+ const app=await launch(engine,directory),cdp=app.cdp;
8
+ const report={engine,url:'https://wayou.github.io/t-rex-runner/',states:[]};
9
+ const state=()=>cdp.evaluate(`JSON.stringify({playing:Runner.instance_.playing,intro:Runner.instance_.playingIntro,crashed:Runner.instance_.crashed,time:Runner.instance_.runningTime,obstacles:Runner.instance_.horizon.obstacles.map(o=>({x:o.xPos,y:o.yPos,width:o.width,type:o.typeConfig.type})),score:Runner.instance_.distanceRan})`).then(JSON.parse);
10
+ try{
11
+ await cdp.send('Emulation.setDeviceMetricsOverride',{width:900,height:600,deviceScaleFactor:1,mobile:false});
12
+ await cdp.send('Page.navigate',{url:report.url});
13
+ for(let n=0;n<200;n++){
14
+ if(await cdp.evaluate('typeof Runner!=="undefined"&&!!Runner.instance_?.tRex'))break;
15
+ await Bun.sleep(100);
16
+ }
17
+ const rect=await cdp.evaluate(`(()=>{const r=document.querySelector('canvas').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`);
18
+ await cdp.send('Input.dispatchMouseEvent',{type:'mousePressed',button:'left',clickCount:1,...rect});
19
+ await cdp.send('Input.dispatchMouseEvent',{type:'mouseReleased',button:'left',clickCount:1,...rect});
20
+ await Bun.sleep(200);report.afterMouse=await state();
21
+ // Upstream desktop game does not start on a mouse click, including Chromium.
22
+ if(report.afterMouse.playing)throw Error('Desktop click unexpectedly started the game');
23
+ await cdp.send('Input.dispatchKeyEvent',{type:'rawKeyDown',key:' ',code:'Space',windowsVirtualKeyCode:32});
24
+ await Bun.sleep(100);
25
+ await cdp.send('Input.dispatchKeyEvent',{type:'keyUp',key:' ',code:'Space',windowsVirtualKeyCode:32});
26
+ let captured=false;
27
+ for(let n=0;n<200;n++){
28
+ await Bun.sleep(100);const s=await state();report.states.push(s);
29
+ if(!captured&&s.obstacles.some(o=>o.x>80&&o.x<500)){
30
+ const {data}=await cdp.send('Page.captureScreenshot',{format:'png'});
31
+ await Bun.write(directory+'/cactus.png',Buffer.from(data,'base64'));captured=true;
32
+ }
33
+ if(s.crashed)break;
34
+ }
35
+ report.final=await state();report.exceptions=cdp.events.filter(e=>e.method==='Runtime.exceptionThrown');
36
+ if(!captured||!report.final.crashed||report.final.intro||report.exceptions.length)throw Error('Expected visible moving cactus and a real collision');
37
+ const placement=()=>cdp.evaluate(`JSON.stringify((()=>{const r=Runner.instance_.containerEl.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height,scrollY}})())`).then(JSON.parse);
38
+ report.beforeRestart=await placement();
39
+ await Bun.sleep(1000);
40
+ await cdp.send('Input.dispatchKeyEvent',{type:'rawKeyDown',key:' ',code:'Space',windowsVirtualKeyCode:32});
41
+ await Bun.sleep(100);
42
+ await cdp.send('Input.dispatchKeyEvent',{type:'keyUp',key:' ',code:'Space',windowsVirtualKeyCode:32});
43
+ await Bun.sleep(500);report.afterRestart=await placement();
44
+ report.restartState=await state();
45
+ if(JSON.stringify(report.beforeRestart)!==JSON.stringify(report.afterRestart)||!report.restartState.playing)throw Error('Restart moved the game or failed to start');
46
+ console.log(JSON.stringify({engine,passed:true,screenshot:directory+'/cactus.png',final:report.final,beforeRestart:report.beforeRestart,afterRestart:report.afterRestart}));
47
+ }finally{
48
+ try{await Bun.write(directory+'/report.json',JSON.stringify(report,null,2));}finally{await app.stop();}
49
+ }
@@ -0,0 +1,64 @@
1
+ // Network integration check against the unchanged Three.js example. Not a CI
2
+ // unit test: it needs the website and (optionally) local floor/wall images.
3
+ // bun test/manual-threejs-scene.js [/path/to/3D素材] [/tmp/threejs-check]
4
+ import assert from 'node:assert/strict';
5
+ import {resolve,join} from 'node:path';
6
+ import {launch} from '../tools/browser-lab.js';
7
+ const assets=process.argv[2]&&resolve(process.argv[2]),out=resolve(process.argv[3]??'/tmp/buninu-threejs-check');
8
+ const browser=await launch('buninu',out),c=browser.cdp;
9
+ async function wait(expression){for(let i=0;i<300;i++){if(await c.evaluate(expression,180000))return;await Bun.sleep(100);}throw Error('Not ready: '+expression);}
10
+ async function click(text){
11
+ const expression=`[...document.querySelectorAll('button')].find(b=>b.textContent.trim()===${JSON.stringify(text)})`;
12
+ await c.evaluate(`(${expression}).scrollIntoView({block:'center'})`);
13
+ const point=await c.evaluate(`(()=>{const r=(${expression}).getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2}})()`);
14
+ for(const type of['mousePressed','mouseReleased'])await c.send('Input.dispatchMouseEvent',{type,button:'left',clickCount:1,...point},180000);
15
+ console.log('clicked',text);
16
+ }
17
+ async function pick(names){
18
+ await wait('!!document.getElementById("path")');
19
+ // Exercise the same controller actions as the browser-owned chooser buttons.
20
+ const action=async value=>{await c.evaluate(`window.__fileAction=${JSON.stringify(value)}`);await c.send('Input.dispatchKeyEvent',{type:'keyDown',key:'Shift'});await c.send('Input.dispatchKeyEvent',{type:'keyUp',key:'Shift'});};
21
+ await action({kind:'browse',path:assets});
22
+ for(const name of names){const index=await c.evaluate(`[...document.querySelectorAll('.entry')].findIndex(e=>e.textContent.endsWith(${JSON.stringify(name)}))`);assert(index>=0,'missing '+name);await action({kind:'select',index});}
23
+ await action({kind:'open'});
24
+ }
25
+ try{
26
+ await c.send('Page.navigate',{url:'https://drjohnjstest.blogspot.com/2023/11/3dsbsthreejsdemo.html'});
27
+ await wait('typeof te!=="undefined"&&!!te.openo');
28
+ if(assets){
29
+ await c.evaluate('window.bgReady=false;te.selectbg().then(()=>window.bgReady=true);true');
30
+ await pick(['floor.jpg','wall.jpg']);await wait('window.bgReady&&te.floor.material.map.image.complete&&te.wallr.material.map.image.complete');
31
+ }
32
+ const initialCount=await c.evaluate('te.obj.length');
33
+ for(const name of['+人','+複製','+球','+立方']){
34
+ const count=await c.evaluate('te.obj.length');await click(name);assert.equal(await c.evaluate('te.obj.length'),count+1);
35
+ }
36
+ assert.equal(await c.evaluate('te.obj.filter(o=>o.type==="Group"&&o.children.length===7).length'),2);
37
+ const typing=click('+字');await wait('!!document.getElementById("answer")');await c.send('Page.handleJavaScriptDialog',{accept:true,promptText:'Hello 文字'});await typing;
38
+ assert.equal(await c.evaluate('te.obj.length'),initialCount+5);
39
+ const active=await c.evaluate('te.cv.active');await click('Next Element');assert.notEqual(await c.evaluate('te.cv.active'),active);await click('Previous Element');assert.equal(await c.evaluate('te.cv.active'),active);
40
+ const visible=await c.evaluate('te.ca.obj.visible');await click('Toggle');assert.equal(await c.evaluate('te.ca.obj.visible'),!visible);await click('Toggle');
41
+ await click('直向');assert.equal(await c.evaluate('getComputedStyle(te.cv).transform'),'none');await click('橫向');assert.equal(await c.evaluate('getComputedStyle(te.cv).transform'),'matrix(0, 1, -1, 0, 0, 0)');
42
+ await c.evaluate('te.cv.scrollIntoView()');const r=await c.evaluate('(()=>{const r=te.cv.getBoundingClientRect();return{x:r.x,y:r.y,width:r.width}})()');
43
+ const point=(x,y)=>({x:r.x+r.width-y,y:r.y+x});
44
+ const before=await c.evaluate('te.ca.obj.position.toArray()');
45
+ for(const [x,y]of[[150,30],[30,150],[270,150],[150,270]])for(const type of['mousePressed','mouseReleased'])await c.send('Input.dispatchMouseEvent',{type,button:'left',clickCount:1,...point(x,y)},180000);
46
+ assert.deepEqual(await c.evaluate('te.ca.obj.position.toArray()'),before);
47
+ const rotation=await c.evaluate('te.ca.obj.rotation.y');
48
+ await c.send('Input.dispatchMouseEvent',{type:'mousePressed',button:'left',buttons:1,...point(40,40)});
49
+ for(const x of[70,200])await c.send('Input.dispatchMouseEvent',{type:'mouseMoved',button:'none',buttons:1,...point(x,40)},180000);
50
+ await c.send('Input.dispatchMouseEvent',{type:'mouseReleased',button:'left',buttons:0,...point(200,40)},180000);assert.notEqual(await c.evaluate('te.ca.obj.rotation.y'),rotation);
51
+ if(assets&&await Bun.file(join(assets,'小凱L.png')).exists()&&await Bun.file(join(assets,'小凱R.png')).exists()){
52
+ const adding=click('+立體圖');await wait('!!document.getElementById("ok")');await c.send('Page.handleJavaScriptDialog',{accept:true});await adding;await pick(['小凱L.png','小凱R.png']);await wait('te.opensbs_success&&te.sbsl[0].obj.material.map.image.complete&&te.sbsr[0].obj.material.map.image.complete');
53
+ }
54
+ await c.evaluate('tdebug.value="window.smokeRun=123"');await click('Run');assert.equal(await c.evaluate('window.smokeRun'),123);
55
+ await click('→');await c.evaluate('tdebug.value="second slot"');await click('←');assert.equal(await c.evaluate('tdebug.value'),'window.smokeRun=123');await click('→');assert.equal(await c.evaluate('tdebug.value'),'second slot');
56
+ const clearing=click('Clear');await wait('!!document.getElementById("ok")');await c.send('Page.handleJavaScriptDialog',{accept:true});await clearing;assert.equal(await c.evaluate('tdebug.value'),'');
57
+ // Copy uses the existing browser clipboard bridge. Its non-destructive mocked
58
+ // host contract is covered separately; do not replace the user's clipboard.
59
+ await click('Open Console');await wait('typeof eruda!=="undefined"&&!!document.querySelector("#eruda")');
60
+ await c.evaluate('eruda.hide();te.cv.scrollIntoView();te.rd()');
61
+ const shot=await c.send('Page.captureScreenshot',{format:'png'},180000);await Bun.write(join(out,'scene.png'),Buffer.from(shot.data,'base64'));
62
+ assert.deepEqual(c.events.filter(e=>e.method==='Runtime.exceptionThrown'),[]);
63
+ console.log('PASS; screenshot:',join(out,'scene.png'));
64
+ }finally{await browser.stop();}
@@ -0,0 +1,27 @@
1
+ // Completed-frame timing, not merely asynchronous GPU command submission.
2
+ // bun test/manual-webgl-benchmark.js buninu|chromium /path/to/3D素材 [/tmp/output]
3
+ // Select a software Vulkan ICD externally for the Chromium CPU comparison.
4
+ import{launch}from'../tools/browser-lab.js';
5
+ import{resolve,join}from'node:path';
6
+ const engine=process.argv[2]??'buninu',assets=process.argv[3];
7
+ if(!assets)throw Error('Pass a directory containing floor.jpg and wall.jpg');
8
+ const out=resolve(process.argv[4]??'/tmp/threejs-benchmark-'+engine);
9
+ const b=await launch(engine,out,engine==='chromium'?['--use-gl=angle','--use-angle=vulkan','--disable-gpu-sandbox','--ignore-gpu-blocklist']:[]);
10
+ try{
11
+ const c=b.cdp;await c.send('Page.navigate',{url:'https://drjohnjstest.blogspot.com/2023/11/3dsbsthreejsdemo.html'});
12
+ for(let i=0;i<300;i++){if(await c.evaluate('typeof te!=="undefined"&&!!te.openo'))break;await Bun.sleep(100);}
13
+ const images=await Promise.all(['floor.jpg','wall.jpg'].map(async name=>'data:image/jpeg;base64,'+(await Bun.file(join(assets,name)).bytes()).toBase64()));
14
+ await c.evaluate(`te.selectbg(${JSON.stringify(images[0])},${JSON.stringify(images[1])}).then(()=>true)`,180000);
15
+ for(let i=0;i<100;i++){if(await c.evaluate('te.floor.material.map.image.complete&&te.wallr.material.map.image.complete'))break;await Bun.sleep(50);}
16
+ const result=await c.evaluate(`(()=>{
17
+ const contexts=[te.renderer.getContext(),te.renderer2.getContext()],pixel=new Uint8Array(4);
18
+ const fence=()=>{for(const gl of contexts){gl.finish();if(gl.readPixels)gl.readPixels(0,0,1,1,gl.RGBA,gl.UNSIGNED_BYTE,pixel);}};
19
+ let start=performance.now();te.add('man');te.rd();fence();const preparationMs=performance.now()-start,frames=[];
20
+ for(let i=0;i<30;i++){start=performance.now();te.obj[0].position.x=6.1*i;te.rd();fence();frames.push(performance.now()-start);}
21
+ return {preparationMs,frames,canvas:[te.cv.width,te.cv.height],errors:contexts.map(g=>g.getError())};
22
+ })()`,300000);
23
+ const sorted=result.frames.slice(3).sort((a,b)=>a-b),median=sorted[Math.floor(sorted.length/2)],p95=sorted[Math.floor(sorted.length*.95)];
24
+ const report={engine,...result,medianMs:median,medianFps:1000/median,p95Ms:p95,p95Fps:1000/p95};
25
+ await Bun.write(join(out,'timing.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report,null,2));
26
+ await c.evaluate('te.cv.scrollIntoView()');const shot=await c.send('Page.captureScreenshot',{format:'png'},180000);await Bun.write(join(out,'scene.png'),Buffer.from(shot.data,'base64'));
27
+ }finally{await b.stop();}
@@ -0,0 +1,26 @@
1
+ <!doctype html><meta charset="utf-8"><title>CPU WebGL triangle</title>
2
+ <style>body{background:#222;color:white}canvas{border:1px solid white}</style>
3
+ <h1>CPU WebGL / CanvasKit</h1><p>Click left/right to move. Drag horizontally to rotate.</p><canvas draggable="true" width="320" height="240"></canvas><pre id="result"></pre>
4
+ <script>
5
+ const canvas=document.querySelector('canvas'),gl=canvas.getContext('webgl');
6
+ function compile(type,source){const s=gl.createShader(type);gl.shaderSource(s,source);gl.compileShader(s);if(!gl.getShaderParameter(s,gl.COMPILE_STATUS))throw Error(gl.getShaderInfoLog(s));return s;}
7
+ try{
8
+ const p=gl.createProgram();
9
+ gl.attachShader(p,compile(gl.VERTEX_SHADER,'attribute vec3 position; uniform float dx; uniform float angle; varying vec3 color; void main(){color=position*0.5+0.5;vec2 p=mat2(cos(angle),sin(angle),-sin(angle),cos(angle))*position.xy;gl_Position=vec4(p+vec2(dx,0.0),position.z,1.0);}'));
10
+ gl.attachShader(p,compile(gl.FRAGMENT_SHADER,'precision highp float; varying vec3 color;void main(){gl_FragColor=vec4(color,1.0);}'));
11
+ gl.linkProgram(p);gl.useProgram(p);
12
+ const b=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,b);gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-.8,-.8,0,.8,-.8,0,0,.8,0]),gl.STATIC_DRAW);
13
+ const a=gl.getAttribLocation(p,'position');gl.enableVertexAttribArray(a);gl.vertexAttribPointer(a,3,gl.FLOAT,false,0,0);
14
+ let dx=0,angle=0,start=0;
15
+ const offset=gl.getUniformLocation(p,'dx'),rotation=gl.getUniformLocation(p,'angle');
16
+ function draw(){
17
+ gl.uniform1f(offset,dx);gl.uniform1f(rotation,angle);
18
+ gl.clearColor(.05,.1,.15,1);gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);gl.drawArrays(gl.TRIANGLES,0,3);
19
+ document.querySelector('pre').textContent=`offset=${dx.toFixed(2)} angle=${angle.toFixed(2)} error=${gl.getError()}`;
20
+ }
21
+ canvas.onclick=e=>{dx+=e.offsetX<canvas.clientWidth/2?-.1:.1;draw();};
22
+ canvas.ondragstart=e=>{start=e.offsetX;e.dataTransfer.setData('text/plain','rotate');};
23
+ canvas.ondragend=e=>{angle+=(e.offsetX-start)/100;draw();};
24
+ draw();
25
+ }catch(e){document.querySelector('pre').textContent=e.stack;}
26
+ </script>
@@ -0,0 +1,8 @@
1
+ import {expect,test} from 'bun:test';
2
+
3
+ test('completed audio releases bounded decoders while preserving metadata, replay and background playback',async()=>{
4
+ const child=Bun.spawn([process.execPath,new URL('./fixtures/media-audio-lifetime.js',import.meta.url).pathname],{stdout:'pipe',stderr:'pipe'});
5
+ const [out,error,code]=await Promise.all([new Response(child.stdout).text(),new Response(child.stderr).text(),child.exited]);
6
+ expect({code,error}).toEqual({code:0,error:''});
7
+ expect(out).toContain('audio decoder lifetime integration passed');
8
+ },15000);
@@ -0,0 +1,33 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {mp4FrameTimes,timeVideoDecoder} from '../lib/media/mp4-frame-times.js';
3
+ const words=(...ns)=>{const b=new Uint8Array(ns.length*4),v=new DataView(b.buffer);ns.forEach((n,i)=>v.setUint32(i*4,n));return b};
4
+ const box=(name,...parts)=>{const body=Buffer.concat(parts),head=Buffer.alloc(8);head.writeUInt32BE(body.length+8);head.write(name,4);return Buffer.concat([head,body])};
5
+ function movie(){return box('moov',box('trak',box('mdia',
6
+ box('hdlr',words(0,0,0x76696465)),box('mdhd',words(0,0,0,1000)),
7
+ box('minf',box('stbl',box('stts',words(0,3,1,50,1,100,2,25)),box('stss',words(0,2,1,3)))))))}
8
+ test('MP4 variable-rate frames use sample times rather than average FPS',()=>{
9
+ const t=mp4FrameTimes(movie());expect([...t.frames]).toEqual([0,.05,.15,.175]);expect(t.end).toBe(.2);
10
+ let seek;const decoder=timeVideoDecoder({width:2,height:2,fps:20,nextFrame:()=>new Uint8Array(16),seek:ms=>seek=ms,close(){}},movie());
11
+ decoder.nextFrame();expect(decoder.frameTime).toBe(0);expect(decoder.nextFrameTime).toBe(.05);
12
+ decoder.nextFrame();expect(decoder.frameTime).toBe(.05);expect(decoder.nextFrameTime).toBe(.15);
13
+ decoder.seek(160);expect(seek).toBe(160);decoder.nextFrame();expect(decoder.frameTime).toBe(.15);
14
+ });
15
+ test('unsupported or truncated containers retain the original decoder',()=>{
16
+ const decoder={};for(const data of [new Uint8Array(),movie().subarray(0,20),box('free',words(0))]){
17
+ expect(mp4FrameTimes(data)).toBeNull();expect(timeVideoDecoder(decoder,data)).toBe(decoder);
18
+ }
19
+ });
20
+
21
+ test('composition offsets and an edit map reordered samples onto presentation time',()=>{
22
+ const data=box('moov',box('trak',
23
+ box('edts',box('elst',words(0,1,200,50,65536))),
24
+ box('mdia',box('hdlr',words(0,0,0x76696465)),box('mdhd',words(0,0,0,1000)),
25
+ box('minf',box('stbl',box('stts',words(0,1,4,50)),
26
+ box('ctts',words(0,4,1,50,1,100,1,0,1,50)),box('stss',words(0,1,1)))))));
27
+ const t=mp4FrameTimes(data);expect([...t.frames]).toEqual([0,.05,.1,.15]);expect(t.end).toBe(.2);
28
+ });
29
+ test('a progressive mdat does not hide already complete sample timing tables',()=>{
30
+ const head=movie(),payload=box('mdat',new Uint8Array(1000));
31
+ const t=mp4FrameTimes(Buffer.concat([head,payload.subarray(0,16)]));
32
+ expect([...t.frames]).toEqual([0,.05,.15,.175]);
33
+ });
@@ -1,5 +1,6 @@
1
1
  import { afterAll, beforeAll, expect, test } from "bun:test";
2
2
  import { RendererHost } from "../lib/renderer/host.js";
3
+ import { supportsFrameCheckpoint } from "../lib/renderer/animation-frames.js";
3
4
 
4
5
  /**
5
6
  * Page JavaScript runs in a real renderer process (seccomp + SES lockdown),
@@ -1221,6 +1222,22 @@ test("import methods keep their names and receivers alongside real dynamic impor
1221
1222
  expect(await evaluate("dynamicImportNames")).toEqual([42, true, "module:e"]);
1222
1223
  });
1223
1224
 
1225
+ test("dynamic module imports preserve grouped specifiers after property rewriting", async () => {
1226
+ scripts.set("https://page.test/grouped-dependency.js", "export const answer=42;");
1227
+ scripts.set("https://page.test/grouped-main.js", `
1228
+ let reads=0;
1229
+ const entry={get module(){reads++;return './grouped-dependency.js'}};
1230
+ const a=await import(entry.module);
1231
+ const b=await import((entry.module));
1232
+ const c=await import /* comment */ (((entry.module)));
1233
+ const d=await import /* comment */ ('./grouped-dependency.js');
1234
+ window.groupedImports=[a.answer,a===b,b===c,c===d,reads];
1235
+ `);
1236
+ await load('<script type="module" src="./grouped-main.js"></script>');
1237
+ await waitFor("typeof groupedImports !== 'undefined'");
1238
+ expect(await evaluate("groupedImports")).toEqual([42,true,true,true,3]);
1239
+ });
1240
+
1224
1241
  test("ES modules: graphs, live exports, JSON, import.meta, dynamic import, TLA, cycles, import maps", async () => {
1225
1242
  const base = "https://page.test/modules/";
1226
1243
  scripts.set(`${base}math.js`, `
@@ -1724,7 +1741,9 @@ test("a video starts from a prefix while the rest of the file is still downloadi
1724
1741
  await evaluate("document.getElementById('v').play()");
1725
1742
  expect(dripFinished).toBe(false);
1726
1743
  expect(await evaluate(`(() => { const v = document.getElementById('v');
1727
- return !v.paused && v.readyState >= 1 && v.currentTime > 0 && v.networkState === 2; })()`)).toBe(true);
1744
+ // The first presented frame has timestamp zero; wall time alone
1745
+ // must not advance a silent video before its next picture.
1746
+ return !v.paused && v.readyState >= 1 && v.videoWidth > 0 && v.currentTime >= 0 && v.networkState === 2; })()`)).toBe(true);
1728
1747
  const deadline = Date.now() + 4000;
1729
1748
  while (!dripFinished && Date.now() < deadline) await Bun.sleep(50);
1730
1749
  expect(dripFinished).toBe(true);
@@ -2304,3 +2323,79 @@ test("NodeIterator validates nullable callback-interface filters at creation", a
2304
2323
  return JSON.stringify(results);
2305
2324
  })()`)).toBe(JSON.stringify([...Array(8).fill('TypeError'),...Array(5).fill(true)]));
2306
2325
  });
2326
+
2327
+
2328
+ test("mouse offsets use target padding edges and CSS pixels, including bubbling and canvas scaling", async () => {
2329
+ await load(`<body style="margin:0"><div id="parent"><canvas id="cv" width="1200" height="1200"
2330
+ style="position:absolute;left:40px;top:30px;width:300px;height:300px;border:5px solid;padding:10px"></canvas></div>
2331
+ <script>
2332
+ window.offsets=[];
2333
+ cv.onclick=e=>{window.lastMouse=e;offsets.push([e.offsetX,e.offsetY]);};
2334
+ document.getElementById('parent').addEventListener('click',e=>offsets.push([e.offsetX,e.offsetY]));
2335
+ </script>`);
2336
+ await renderer.call("click", 75, 85);
2337
+ expect(await evaluate("offsets")).toEqual([[30,50],[30,50]]);
2338
+ expect(await evaluate("[lastMouse.offsetX,lastMouse.offsetY]")).toEqual([75,85]);
2339
+ expect(await evaluate("(()=>{const e=new MouseEvent('click',{clientX:12,clientY:14});return [e.offsetX,e.offsetY]})()")).toEqual([12,14]);
2340
+ });
2341
+
2342
+ (supportsFrameCheckpoint() ? test : test.skip)("classic script cleanup drains promise jobs before restoring currentScript", async () => {
2343
+ await load(`<script id="outer">
2344
+ var checkpointLog = [];
2345
+ const nested = document.createElement('script');
2346
+ nested.id = 'nested';
2347
+ nested.textContent = "checkpointLog.push(document.currentScript.id); Promise.resolve().then(() => checkpointLog.push(document.currentScript?.id ?? null));";
2348
+ document.head.appendChild(nested);
2349
+ checkpointLog.push('after nested');
2350
+ Promise.resolve().then(() => Promise.resolve().then(() => checkpointLog.push(document.currentScript?.id ?? null)));
2351
+ setTimeout(() => checkpointLog.push(document.currentScript?.id ?? null), 0);
2352
+ </script>`);
2353
+ await Bun.sleep(50);
2354
+ expect(await evaluate('checkpointLog')).toEqual(['nested', 'after nested', 'outer', 'outer', null]);
2355
+ expect(await evaluate('document.currentScript')).toBeNull();
2356
+ });
2357
+
2358
+ test('primitive and literal extension methods stay page-local without exposing host capabilities',async()=>{
2359
+ await load(`<script>
2360
+ Number.prototype.pageClamp=function(a,b){return Math.max(a,Math.min(b,this));};
2361
+ Number.prototype.strictReceiver=function(){'use strict';return [typeof this,this];};
2362
+ String.prototype.pageSuffix=function(){return this+'!';};
2363
+ Boolean.prototype.pageToggle=function(){return !this.valueOf();};
2364
+ Array.prototype.pageDrop=function(){return this.pop();};
2365
+ Function.prototype.pageInvoke=function(...args){return this.apply(null,args);};
2366
+ </script><script>
2367
+ window.primitiveResults=[(300).pageClamp(0,255),(-1)['pageClamp'](0,255),'test'.pageSuffix(),true.pageToggle(),[4,5].pageDrop(),(x=>x+1).pageInvoke(3),(2).strictReceiver(),(1).constructor===Number,'a'.constructor===String];
2368
+ window.keyReads=0;const key={[Symbol.toPrimitive](){keyReads++;return 'pageClamp'}};
2369
+ primitiveResults.push((5)[key](1,3),keyReads,eval("(3).pageClamp(0,2)"),Object(1) instanceof Number,new Number(1) instanceof Number);
2370
+ window.escapeResults=[typeof Bun,typeof process,typeof require,Function('return typeof Bun')(),Function('return typeof process')(),(async()=>{}).constructor('return typeof Bun')() instanceof Promise];
2371
+ window.sharedFrozen=[Object.isFrozen(Object.getPrototypeOf(function(){})),Object.isFrozen(Object.getPrototypeOf([]))];
2372
+ </script>`);
2373
+ expect(JSON.parse(await evaluate('JSON.stringify(primitiveResults)'))).toEqual([255,0,'test!',false,5,4,['number',2],true,true,3,1,2,true,true]);
2374
+ expect(JSON.parse(await evaluate('JSON.stringify(escapeResults)'))).toEqual(['undefined','undefined','undefined','undefined','undefined',true]);
2375
+ expect(JSON.parse(await evaluate('JSON.stringify(sharedFrozen)'))).toEqual([true,true]);
2376
+ // A navigation reuses the renderer but must not retain any of these extensions.
2377
+ await load('<script>window.clean=[typeof (1).pageClamp,typeof "a".pageSuffix,typeof true.pageToggle,typeof [].pageDrop,typeof (function(){}).pageInvoke,typeof Bun,typeof process];</script>');
2378
+ expect(JSON.parse(await evaluate('JSON.stringify(clean)'))).toEqual(Array(7).fill('undefined'));
2379
+ });
2380
+
2381
+ test('CanvasRenderingContext2D has a page-local prototype for feature detection and method extensions',async()=>{
2382
+ await load('<canvas id="surface" width="20" height="20"></canvas>');
2383
+ expect(await evaluate(`(()=>{
2384
+ const ctx=document.getElementById('surface').getContext('2d');
2385
+ CanvasRenderingContext2D.prototype.redBox=function(){this.fillStyle='red';this.fillRect(0,0,20,20)};
2386
+ ctx.redBox();
2387
+ return [ctx instanceof CanvasRenderingContext2D,Object.getPrototypeOf(ctx)===CanvasRenderingContext2D.prototype,typeof CanvasRenderingContext2D.prototype.roundRect,ctx.getImageData(5,5,1,1).data[0]].join(',');
2388
+ })()`)).toBe('true,true,function,255');
2389
+ await load('<canvas id="surface"></canvas>');
2390
+ expect(await evaluate('typeof CanvasRenderingContext2D.prototype.redBox')).toBe('undefined');
2391
+ });
2392
+
2393
+ test('decoded video dimensions invalidate retained intrinsic layout without a DOM mutation',async()=>{
2394
+ await load('<video id="intrinsicVideo" src="/video.mp4"></video><div id="afterVideo">after</div>');
2395
+ const before=await evaluate('intrinsicVideo.getBoundingClientRect().width');
2396
+ expect(before).toBe(300);
2397
+ await evaluate('intrinsicVideo.play().then(()=>intrinsicVideo.pause())');
2398
+ const size=await evaluate(`(()=>{const r=intrinsicVideo.getBoundingClientRect();return [intrinsicVideo.videoWidth,intrinsicVideo.videoHeight,r.width,r.height]})()`);
2399
+ expect(size[0]).toBeGreaterThan(0);expect(size[1]).toBeGreaterThan(0);
2400
+ expect(size[2]).toBe(size[0]);expect(size[3]).toBe(size[1]);
2401
+ });
@@ -14,7 +14,9 @@ test("CDP raw keydown uses the physical key even with an empty text field", () =
14
14
  expect(notes.at(-1)).toBe(38);
15
15
  expect(handlePianoInput(input, { key: " ", code: "Space", text: " ", repeat: true }, play)).toBe(true);
16
16
  expect(notes).toHaveLength(4);
17
- expect(handlePianoInput(input, { key: "Tab", code: "Tab", text: "" }, play)).toBe(false);
17
+ expect(handlePianoInput(input, { key: "Tab", code: "Tab", text: "" }, play)).toBe(true);
18
+ expect(notes.at(-1)).toBe(47);
19
+ expect(handlePianoInput(input, { key: "Shift", code: "ShiftRight", text: "" }, play)).toBe(false);
18
20
  });
19
21
 
20
22
  test("piano consumes batched terminal/IME text and plays every mapped note", () => {
@@ -32,10 +34,21 @@ test("piano consumes repeats without retriggering and supports shifted physical
32
34
  expect(handlePianoInput(input, { key: "J", code: "KeyJ", modifiers: 8 }, play)).toBe(true);
33
35
  expect(handlePianoInput(input, { key: "j", modifiers: 2 }, play)).toBe(true);
34
36
  expect(notes).toEqual([71]);
35
- expect(handlePianoInput(input, { key: "Tab" }, play)).toBe(false);
37
+ expect(handlePianoInput(input, { key: "Alt", code: "AltLeft", modifiers: 1 }, play)).toBe(false);
36
38
  expect(handlePianoInput(input, { key: "Backspace" }, play)).toBe(false);
37
39
  });
38
40
 
41
+ test("left-hand control keys and AltGr play even while reporting their own modifier", () => {
42
+ const notes = [];
43
+ const play = n => notes.push(n);
44
+ for (const [key, code] of [["Escape", "Escape"], ["`", "Backquote"], ["Tab", "Tab"], ["CapsLock", "CapsLock"]])
45
+ expect(handlePianoInput(input, { key, code }, play)).toBe(true);
46
+ expect(handlePianoInput(input, { key: "Shift", code: "ShiftLeft", modifiers: 8 }, play)).toBe(true);
47
+ expect(handlePianoInput(input, { key: "AltGraph", code: "AltRight", modifiers: 3 }, play)).toBe(true);
48
+ expect(handlePianoInput(input, { key: "Alt", code: "AltRight", modifiers: 1 }, play)).toBe(true);
49
+ expect(notes).toEqual([35, 42, 47, 50, 54, 64, 64]);
50
+ });
51
+
39
52
  test("ordinary, disabled and readonly editors are unaffected", () => {
40
53
  expect(handlePianoInput({ getAttribute: () => "editor" }, { text: "jk" })).toBe(false);
41
54
  expect(handlePianoInput({ ...input, disabled: true }, { text: "jk" })).toBe(false);
@@ -2,7 +2,7 @@ import { test, expect } from "bun:test";
2
2
  import { PIANO_KEYS, PIANO_DO, pianoNote, pianoTone, PianoMixer } from "../lib/audio/piano.js";
3
3
 
4
4
  test("piano keyboard mapping is editable in one table", () => {
5
- expect(Object.keys(PIANO_KEYS)).toHaveLength(61);
5
+ expect(Object.keys(PIANO_KEYS)).toHaveLength(66);
6
6
  expect(PIANO_DO).toBe(69);
7
7
  expect(PIANO_KEYS[" "]).toBe(60);
8
8
  expect(pianoNote(" ")).toBe(69);
@@ -16,7 +16,16 @@ test("piano keyboard mapping is editable in one table", () => {
16
16
  expect(pianoNote("/", "Slash")).toBe(71);
17
17
  expect(pianoNote("Unidentified", "KeyJ")).toBe(71);
18
18
  expect(pianoNote("!", "Digit1")).toBe(45);
19
- expect(pianoNote("Escape")).toBeNull();
19
+ expect(pianoNote("Escape", "Escape")).toBe(35);
20
+ expect(pianoNote("`", "Backquote")).toBe(42);
21
+ expect(pianoNote("Tab", "Tab")).toBe(47);
22
+ expect(pianoNote("CapsLock", "CapsLock")).toBe(50);
23
+ expect(pianoNote("Shift", "ShiftLeft")).toBe(54);
24
+ expect(pianoNote("Shift", "ShiftRight")).toBeNull();
25
+ expect(pianoNote("Alt", "AltRight")).toBe(64);
26
+ expect(pianoNote("AltGraph")).toBe(64);
27
+ expect(pianoNote("Alt", "AltLeft")).toBeNull();
28
+ expect(pianoNote("Backspace")).toBeNull();
20
29
  });
21
30
 
22
31
  test("piano tone is 48 kHz mono PCM with a nonzero, decaying sound", () => {
@@ -29,3 +29,14 @@ test("short clips drain and piano stays interactive", () => {
29
29
  expect(piano.request(0, 0, 0, 120)).toBe(120);
30
30
  expect(piano.request(0, 120, 0, 120)).toBe(0);
31
31
  });
32
+
33
+
34
+ test('live synthesis prepares a small reserve while server credits are delayed, without startup gating',()=>{
35
+ const live=new PlaybackBuffer(1000,2,.12,.12);
36
+ expect(live.ready(20,0,false)).toBe(true);
37
+ expect(live.request(0,0,0,0)).toBe(120);
38
+ expect(live.request(0,120,0,0)).toBe(0);
39
+ expect(live.request(20,0,40,0)).toBe(60);
40
+ expect(live.request(20,60,40,0)).toBe(0);
41
+ expect(live.request(0,0,200,0)).toBe(0);
42
+ });