buninu-browser 0.6.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 (144) hide show
  1. package/AGENTS.md +5 -0
  2. package/README.md +94 -4
  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/index.js +10 -6
  16. package/lib/audio/biquad.js +25 -0
  17. package/lib/audio/piano.js +6 -3
  18. package/lib/audio/playback-buffer.js +5 -5
  19. package/lib/audio/pulse-client.js +24 -5
  20. package/lib/audio/web-audio.js +570 -0
  21. package/lib/browser-dialog.js +116 -0
  22. package/lib/cdp-server.js +128 -72
  23. package/lib/file-picker.js +48 -0
  24. package/lib/headless-shell.js +34 -12
  25. package/lib/layout/text-layout.js +112 -19
  26. package/lib/media/mp4-frame-times.js +93 -0
  27. package/lib/paint/direct-canvas.ts +39 -0
  28. package/lib/paint/display-list.js +37 -12
  29. package/lib/paint/raster-readback.js +16 -7
  30. package/lib/renderer/animation-frames.js +10 -5
  31. package/lib/renderer/bindings.js +69 -16
  32. package/lib/renderer/canvas-2d.js +265 -15
  33. package/lib/renderer/canvas-round-rect.js +36 -0
  34. package/lib/renderer/css-animations.js +173 -0
  35. package/lib/renderer/dialog-client.js +30 -0
  36. package/lib/renderer/dialog-worker.js +35 -0
  37. package/lib/renderer/eval-environment.js +28 -0
  38. package/lib/renderer/file-input.js +49 -0
  39. package/lib/renderer/host.js +33 -4
  40. package/lib/renderer/html-drag.js +77 -0
  41. package/lib/renderer/image-loading.js +45 -2
  42. package/lib/renderer/main.js +4 -0
  43. package/lib/renderer/module-transform.js +5 -3
  44. package/lib/renderer/page-builtins.js +59 -0
  45. package/lib/renderer/page-realm.js +177 -40
  46. package/lib/renderer/page-renderer.js +397 -49
  47. package/lib/renderer/piano-input.js +3 -1
  48. package/lib/renderer/raster-surface.js +18 -0
  49. package/lib/renderer/script-rewrite.js +165 -9
  50. package/lib/renderer/video-presentation.js +5 -4
  51. package/lib/renderer/webgl/clip.js +24 -0
  52. package/lib/renderer/webgl/context.js +158 -0
  53. package/lib/renderer/webgl/cpu-fragment.js +211 -0
  54. package/lib/renderer/webgl/draw-cache.js +22 -0
  55. package/lib/renderer/webgl/pipeline.js +70 -0
  56. package/lib/renderer/webgl/shaders.js +58 -0
  57. package/lib/renderer/webgl/textures.js +43 -0
  58. package/lib/renderer/window-named-properties.js +46 -0
  59. package/lib/screenshot-cli.js +4 -7
  60. package/lib/style/animation-values.js +29 -0
  61. package/lib/style/computed-style.js +109 -13
  62. package/lib/style/transform.js +44 -0
  63. package/lib/window-size.js +10 -0
  64. package/package.json +1 -1
  65. package/patches/happy-dom/0007-scope-content-models-to-html-namespace.patch +29 -0
  66. package/patches/happy-dom/README.md +16 -0
  67. package/test/animation-frames.test.js +15 -0
  68. package/test/canvas-2d.test.js +252 -4
  69. package/test/canvas-frame-pacing.test.js +7 -0
  70. package/test/cdp-viewers.test.js +79 -0
  71. package/test/classic-games-patches/0001-fix-game-initialization.patch +177 -0
  72. package/test/classic-games-patches/README.md +156 -0
  73. package/test/classic-games-scenarios.js +133 -0
  74. package/test/clipboard.test.js +16 -0
  75. package/test/computed-style.test.js +18 -0
  76. package/test/css-animation-lifecycle.test.js +28 -0
  77. package/test/css-animations.test.js +83 -0
  78. package/test/direct-canvas.test.js +39 -0
  79. package/test/dynamic-inline-handlers.test.js +25 -0
  80. package/test/eval-declarations.test.js +22 -0
  81. package/test/file-picker.test.js +26 -0
  82. package/test/fixtures/canvas-damage.js +23 -0
  83. package/test/fixtures/canvas-frame-pacing.js +35 -0
  84. package/test/fixtures/direct-canvas.js +96 -0
  85. package/test/fixtures/javascript-dialogs.html +8 -0
  86. package/test/fixtures/media-audio-lifetime.js +65 -0
  87. package/test/fixtures/renderer-memory.js +7 -5
  88. package/test/fixtures/video-black-replay.js +8 -6
  89. package/test/fixtures/video-slow-end.js +39 -0
  90. package/test/fixtures/web-audio-renderer.js +45 -0
  91. package/test/flex.test.js +26 -0
  92. package/test/grid.test.js +13 -0
  93. package/test/headless-shell.test.js +9 -0
  94. package/test/headless-window-size.test.js +28 -0
  95. package/test/html-rcdata.test.js +35 -0
  96. package/test/image-decode.test.js +18 -0
  97. package/test/index.test.js +1 -0
  98. package/test/javascript-dialog.test.js +83 -0
  99. package/test/manual-breakout-paddle.js +42 -0
  100. package/test/manual-breakout-static.js +35 -0
  101. package/test/manual-classic-games.js +41 -0
  102. package/test/manual-clumsy-bird-three-pipes.js +81 -0
  103. package/test/manual-clumsy-bird.js +52 -0
  104. package/test/manual-hextris.js +30 -0
  105. package/test/manual-numbered-music.js +24 -0
  106. package/test/manual-t-rex-runner.js +49 -0
  107. package/test/manual-threejs-scene.js +64 -0
  108. package/test/manual-webgl-benchmark.js +27 -0
  109. package/test/manual-webgl-cpu.html +26 -0
  110. package/test/media-audio-lifetime.test.js +8 -0
  111. package/test/mp4-frame-times.test.js +33 -0
  112. package/test/page-scripts.test.js +96 -1
  113. package/test/piano-input.test.js +15 -2
  114. package/test/piano.test.js +11 -2
  115. package/test/playback-buffer.test.js +11 -0
  116. package/test/pointer-drag.test.js +33 -0
  117. package/test/positioning.test.js +82 -0
  118. package/test/pulse-client.test.js +51 -2
  119. package/test/raster-readback.test.js +14 -0
  120. package/test/script-rewrite.test.js +61 -3
  121. package/test/video-slow-end.test.js +6 -0
  122. package/test/viewport-keyboard-overflow.test.js +25 -0
  123. package/test/wayland-win32-resources.test.js +47 -0
  124. package/test/wayland-win32-wire.test.js +59 -0
  125. package/test/web-audio-renderer.test.js +8 -0
  126. package/test/web-audio.test.js +264 -0
  127. package/test/webgl-clip.test.js +9 -0
  128. package/test/webgl-cpu-fragment.test.js +43 -0
  129. package/test/window-named-properties.test.js +41 -0
  130. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfig.ts +2 -2
  131. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfigContentModelEnum.ts +1 -0
  132. package/vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts +23 -3
  133. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +1 -1
  134. package/vendor/land-renderer/LICENSE +21 -0
  135. package/vendor/land-renderer/README.md +14 -0
  136. package/vendor/land-renderer/direct-compositor.ts +443 -0
  137. package/vendor/land-renderer/frame.js +88 -0
  138. package/vendor/land-renderer/png-fast.js +85 -0
  139. package/vendor/land-renderer/raw-frames.js +66 -0
  140. package/vendor/land-renderer/render-types.ts +7 -0
  141. package/vendor/land-renderer/renderer.ts +134 -0
  142. package/vendor/land-renderer/snapshot.js +37 -0
  143. package/vendor/land-renderer/viewport.js +18 -0
  144. package/vendor/ses/dist/ses.mjs +1 -1
@@ -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);}}
@@ -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);