buninu-browser 0.7.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/AGENTS.md +5 -0
  2. package/README.md +90 -1
  3. package/buninu-browser.js +1 -1
  4. package/frontends/wayland/README.md +35 -2
  5. package/frontends/wayland/client.js +8 -6
  6. package/frontends/wayland/clipboard.js +1 -1
  7. package/frontends/wayland/index.js +1 -1
  8. package/frontends/wayland/input.js +1 -1
  9. package/frontends/wayland/resource-io.js +6 -0
  10. package/frontends/wayland/transport-posix.js +120 -0
  11. package/frontends/wayland/transport-win32.js +9 -0
  12. package/frontends/wayland/transport.js +3 -120
  13. package/frontends/wayland/win32-resources.js +80 -0
  14. package/frontends/wayland/win32-wire.js +62 -0
  15. package/lib/audio/biquad.js +25 -0
  16. package/lib/audio/piano.js +6 -3
  17. package/lib/audio/playback-buffer.js +5 -5
  18. package/lib/audio/pulse-client.js +24 -5
  19. package/lib/audio/web-audio.js +570 -0
  20. package/lib/browser-dialog.js +126 -0
  21. package/lib/cdp-server.js +43 -11
  22. package/lib/file-picker.js +48 -0
  23. package/lib/headless-shell.js +34 -12
  24. package/lib/layout/text-layout.js +112 -19
  25. package/lib/media/mp4-frame-times.js +93 -0
  26. package/lib/paint/direct-canvas.ts +39 -0
  27. package/lib/paint/display-list.js +37 -12
  28. package/lib/paint/raster-readback.js +16 -7
  29. package/lib/renderer/animation-frames.js +10 -5
  30. package/lib/renderer/bindings.js +69 -16
  31. package/lib/renderer/canvas-2d.js +265 -15
  32. package/lib/renderer/canvas-round-rect.js +36 -0
  33. package/lib/renderer/css-animations.js +173 -0
  34. package/lib/renderer/dialog-client.js +30 -0
  35. package/lib/renderer/dialog-worker.js +35 -0
  36. package/lib/renderer/eval-environment.js +28 -0
  37. package/lib/renderer/file-input.js +49 -0
  38. package/lib/renderer/host.js +33 -4
  39. package/lib/renderer/html-drag.js +77 -0
  40. package/lib/renderer/image-loading.js +45 -2
  41. package/lib/renderer/main.js +4 -0
  42. package/lib/renderer/module-transform.js +5 -3
  43. package/lib/renderer/page-builtins.js +59 -0
  44. package/lib/renderer/page-realm.js +177 -40
  45. package/lib/renderer/page-renderer.js +397 -49
  46. package/lib/renderer/piano-input.js +3 -1
  47. package/lib/renderer/raster-surface.js +18 -0
  48. package/lib/renderer/script-rewrite.js +165 -9
  49. package/lib/renderer/video-presentation.js +5 -4
  50. package/lib/renderer/webgl/clip.js +24 -0
  51. package/lib/renderer/webgl/context.js +158 -0
  52. package/lib/renderer/webgl/cpu-fragment.js +211 -0
  53. package/lib/renderer/webgl/draw-cache.js +22 -0
  54. package/lib/renderer/webgl/pipeline.js +70 -0
  55. package/lib/renderer/webgl/shaders.js +58 -0
  56. package/lib/renderer/webgl/textures.js +43 -0
  57. package/lib/renderer/window-named-properties.js +46 -0
  58. package/lib/screenshot-cli.js +4 -7
  59. package/lib/style/animation-values.js +29 -0
  60. package/lib/style/computed-style.js +109 -13
  61. package/lib/style/transform.js +44 -0
  62. package/lib/window-size.js +10 -0
  63. package/package.json +1 -1
  64. package/patches/happy-dom/0007-scope-content-models-to-html-namespace.patch +29 -0
  65. package/patches/happy-dom/README.md +16 -0
  66. package/test/animation-frames.test.js +15 -0
  67. package/test/canvas-2d.test.js +252 -4
  68. package/test/canvas-frame-pacing.test.js +7 -0
  69. package/test/cdp-viewers.test.js +36 -0
  70. package/test/classic-games-patches/0001-fix-game-initialization.patch +177 -0
  71. package/test/classic-games-patches/README.md +156 -0
  72. package/test/classic-games-scenarios.js +133 -0
  73. package/test/clipboard.test.js +16 -0
  74. package/test/computed-style.test.js +18 -0
  75. package/test/css-animation-lifecycle.test.js +28 -0
  76. package/test/css-animations.test.js +83 -0
  77. package/test/direct-canvas.test.js +39 -0
  78. package/test/dynamic-inline-handlers.test.js +25 -0
  79. package/test/eval-declarations.test.js +22 -0
  80. package/test/file-picker.test.js +34 -0
  81. package/test/fixtures/canvas-damage.js +23 -0
  82. package/test/fixtures/canvas-frame-pacing.js +35 -0
  83. package/test/fixtures/direct-canvas.js +96 -0
  84. package/test/fixtures/javascript-dialogs.html +8 -0
  85. package/test/fixtures/media-audio-lifetime.js +65 -0
  86. package/test/fixtures/renderer-memory.js +7 -5
  87. package/test/fixtures/video-black-replay.js +8 -6
  88. package/test/fixtures/video-slow-end.js +39 -0
  89. package/test/fixtures/web-audio-renderer.js +45 -0
  90. package/test/flex.test.js +26 -0
  91. package/test/grid.test.js +13 -0
  92. package/test/headless-shell.test.js +9 -0
  93. package/test/headless-window-size.test.js +28 -0
  94. package/test/html-rcdata.test.js +35 -0
  95. package/test/image-decode.test.js +18 -0
  96. package/test/javascript-dialog.test.js +83 -0
  97. package/test/manual-breakout-paddle.js +42 -0
  98. package/test/manual-breakout-static.js +35 -0
  99. package/test/manual-classic-games.js +41 -0
  100. package/test/manual-clumsy-bird-three-pipes.js +81 -0
  101. package/test/manual-clumsy-bird.js +52 -0
  102. package/test/manual-hextris.js +30 -0
  103. package/test/manual-numbered-music.js +24 -0
  104. package/test/manual-t-rex-runner.js +49 -0
  105. package/test/manual-threejs-scene.js +64 -0
  106. package/test/manual-webgl-benchmark.js +27 -0
  107. package/test/manual-webgl-cpu.html +26 -0
  108. package/test/media-audio-lifetime.test.js +8 -0
  109. package/test/mp4-frame-times.test.js +33 -0
  110. package/test/page-scripts.test.js +96 -1
  111. package/test/piano-input.test.js +15 -2
  112. package/test/piano.test.js +11 -2
  113. package/test/playback-buffer.test.js +11 -0
  114. package/test/pointer-drag.test.js +33 -0
  115. package/test/positioning.test.js +82 -0
  116. package/test/pulse-client.test.js +51 -2
  117. package/test/raster-readback.test.js +14 -0
  118. package/test/script-rewrite.test.js +61 -3
  119. package/test/video-slow-end.test.js +6 -0
  120. package/test/viewport-keyboard-overflow.test.js +25 -0
  121. package/test/wayland-win32-resources.test.js +47 -0
  122. package/test/wayland-win32-wire.test.js +59 -0
  123. package/test/web-audio-renderer.test.js +8 -0
  124. package/test/web-audio.test.js +264 -0
  125. package/test/webgl-clip.test.js +9 -0
  126. package/test/webgl-cpu-fragment.test.js +43 -0
  127. package/test/window-named-properties.test.js +41 -0
  128. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfig.ts +2 -2
  129. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfigContentModelEnum.ts +1 -0
  130. package/vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts +23 -3
  131. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +1 -1
  132. package/vendor/land-renderer/LICENSE +21 -0
  133. package/vendor/land-renderer/README.md +14 -0
  134. package/vendor/land-renderer/direct-compositor.ts +443 -0
  135. package/vendor/land-renderer/frame.js +88 -0
  136. package/vendor/land-renderer/png-fast.js +85 -0
  137. package/vendor/land-renderer/raw-frames.js +66 -0
  138. package/vendor/land-renderer/render-types.ts +7 -0
  139. package/vendor/land-renderer/renderer.ts +134 -0
  140. package/vendor/land-renderer/snapshot.js +37 -0
  141. package/vendor/land-renderer/viewport.js +18 -0
  142. package/vendor/ses/dist/ses.mjs +1 -1
@@ -0,0 +1,133 @@
1
+ // Game-specific acceptance assertions only. Production compatibility fixes are generic.
2
+ export async function playClassicGame(c,game) {
3
+ const evidence=[];
4
+ const read=expression=>c.evaluate(expression,90000);
5
+ const check=(condition,message)=>{if(!condition)throw Error(message);evidence.push(message);};
6
+ const sleep=Bun.sleep;
7
+ const castyKeys=process.env.CLASSIC_CASTY_KEYS;
8
+ const casty=castyKeys?await import('../../casty/lib/keyboard-protocol.js'):null;
9
+ const dispatch=casty?new casty.KeyboardDispatch(c):null;
10
+ async function key(key,hold=60){
11
+ if(dispatch && /^[a-z ]$/i.test(key)) {
12
+ const info=casty.parseKittyKey(`\x1b[${key.codePointAt(0)}u`);
13
+ await dispatch.send(info,castyKeys==='kitty');await sleep(hold);
14
+ if(castyKeys==='kitty')await dispatch.send({...info,eventType:3},true);
15
+ return;
16
+ }
17
+ const names={Escape:'Escape',' ':'Space',ArrowLeft:'ArrowLeft',ArrowRight:'ArrowRight',ArrowUp:'ArrowUp',ArrowDown:'ArrowDown',Enter:'Enter'};
18
+ const codes={Escape:27,' ':32,ArrowLeft:37,ArrowUp:38,ArrowRight:39,ArrowDown:40,Enter:13};
19
+ const event={key,code:names[key]??'Key'+key.toUpperCase(),windowsVirtualKeyCode:codes[key]??key.toUpperCase().charCodeAt(0)};
20
+ await c.send('Input.dispatchKeyEvent',{type:'rawKeyDown',...event});await sleep(hold);await c.send('Input.dispatchKeyEvent',{type:'keyUp',...event});
21
+ }
22
+ async function click(selector,index=0,button='left'){
23
+ const point=await read(`(()=>{const e=document.querySelectorAll(${JSON.stringify(selector)})[${index}];if(!e)return null;e.scrollIntoView({block:'nearest',inline:'nearest'});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2,w:r.width,h:r.height}})()`);
24
+ if(!point?.w||!point?.h)throw Error('No visible target '+selector);
25
+ for(const type of ['mousePressed','mouseReleased'])await c.send('Input.dispatchMouseEvent',{type,button,clickCount:1,x:point.x,y:point.y});
26
+ }
27
+ async function wait(expression,milliseconds=3000){const start=performance.now();while(performance.now()-start<milliseconds){if(await read(expression))return true;await sleep(60);}return false;}
28
+ const pixels=async()=>{const {data}=await c.send('Page.captureScreenshot',{format:'png'});return Bun.hash(data).toString();};
29
+ const start=async()=>{await click('#playBtn');await sleep(150);check(await read('(()=>{const e=document.getElementById("playBtn"),overlay=e.closest(".overlay");return e.getBoundingClientRect().width===0||overlay&&!overlay.classList.contains("show")})()'),'start hides the menu');};
30
+ switch(game){
31
+ case 'tetris':{
32
+ const score=await read('Number(document.getElementById("score-val").textContent)');
33
+ await key('ArrowLeft');await key('ArrowUp');await key(' ');
34
+ check(await wait('Number(document.getElementById("score-val").textContent)>'+score,15000),'hard drop locks a piece and increases score');
35
+ await key('p');check(await read('document.getElementById("pause-overlay").classList.contains("show")'),'P shows the pause overlay');await key('p');check(await read('!document.getElementById("pause-overlay").classList.contains("show")'),'P resumes the game');break;
36
+ }
37
+ case 'snake':{
38
+ // Observe the two head eyes in rendered Canvas output. Their relative
39
+ // positions reveal direction without reaching into the game's closure.
40
+ await read(`(()=>{window.__snakeEyes=[];const arc=CanvasRenderingContext2D.prototype.arc;CanvasRenderingContext2D.prototype.arc=function(x,y,r,...args){if(r===2.2){window.__snakeEyes.push([x,y]);if(window.__snakeEyes.length>32)window.__snakeEyes.shift()}return arc.call(this,x,y,r,...args)}})()`);
41
+ const heading=`(()=>{const a=window.__snakeEyes.at(-2),b=window.__snakeEyes.at(-1);if(!a||!b)return '';const dx=a[0]-b[0],dy=a[1]-b[1];return Math.abs(dx)>Math.abs(dy)?(dx>0?'up':'down'):(dy>0?'right':'left')})()`;
42
+ await start();check(await wait(heading+"==='right'"),'snake initially faces right');
43
+ await key('ArrowLeft');await sleep(200);
44
+ check(await read(heading+"==='right'"),'direct reversal into the body is rejected');
45
+ for(const [input,direction] of [['ArrowUp','up'],['ArrowLeft','left'],['ArrowDown','down'],['ArrowRight','right']]){
46
+ await key(input);check(await wait(heading+'==='+JSON.stringify(direction)),input+' turns the snake '+direction);
47
+ }
48
+ break;
49
+ }
50
+ case '2048':{
51
+ const tiles=()=>read('JSON.stringify([...document.querySelectorAll(".tile")].map(e=>[e.textContent,e.style.left,e.style.top]))');
52
+ await key('ArrowLeft');await sleep(180);const before=await tiles();
53
+ await key('ArrowRight');await sleep(180);check(before!==await tiles(),'arrow moves/spawns numbered tiles');
54
+ await click('#undoBtn');await sleep(160);check(before===await tiles(),'undo restores the previous board');break;
55
+ }
56
+ case 'flappy':{
57
+ await read(`(()=>{window.__classicText=[];const original=CanvasRenderingContext2D.prototype.fillText;CanvasRenderingContext2D.prototype.fillText=function(text,...args){window.__classicText.push(String(text));if(window.__classicText.length>500)window.__classicText.shift();return original.call(this,text,...args)}})()`);
58
+ await key(' ');await sleep(120);
59
+ check(await wait('window.__classicText.some(text=>/^0$/.test(text))'),'Space starts the game and draws the score');
60
+ await key(' ');await sleep(150);check(await read('!window.__classicText.some(text=>text==="GAME OVER")'),'another flap keeps the bird alive');
61
+ await key('p');await key('p');break;
62
+ }
63
+ case 'space':{
64
+ await key(' ');const before=await pixels();await key('ArrowRight',200);await key(' ',350);await sleep(150);
65
+ check(before!==await pixels(),'shooting/movement advances the game canvas');break;
66
+ }
67
+ case 'ttt':{
68
+ await click('.cell',0);check(await wait('document.querySelectorAll(".mark-svg").length>=2'),'human move and AI response both draw marks');
69
+ await click('#btnNew');check(await read('document.querySelectorAll(".mark-svg").length===0'),'new game clears the board');break;
70
+ }
71
+ case 'memory':{
72
+ const cards=await read('[...document.querySelectorAll(".card")].map(e=>e.querySelector(".card-emoji").textContent)');
73
+ const first=cards.findIndex((value,index)=>cards.indexOf(value)!==index),other=cards.indexOf(cards[first]);
74
+ await click('.card',other);await sleep(100);check(await read('document.querySelectorAll(".card.flipped").length===1'),'one click flips one card');
75
+ await click('.card',first);check(await wait('document.querySelectorAll(".card.matched").length===2'),'matching cards are retained as a pair');break;
76
+ }
77
+ case 'mine':{
78
+ await click('#board .cell',0,'right');check(await read('document.querySelectorAll("#board .flag").length===1'),'right click sets a flag');
79
+ await click('#board .cell',0,'right');check(await read('document.querySelectorAll("#board .flag").length===0'),'second right click removes the flag');
80
+ await click('#board .cell',0);check(await wait('document.querySelectorAll("#board .revealed").length>0||document.querySelectorAll("#board .open").length>0||document.querySelectorAll("#board .rv").length>0'),'left click opens cells');break;
81
+ }
82
+ case 'pong':case 'breakout':{
83
+ await start();const before=await pixels();
84
+ const rect=await read('(()=>{const r=document.getElementById("game").getBoundingClientRect();return {x:r.x+r.width*.8,y:r.y+r.height*.8}})()');
85
+ await c.send('Input.dispatchMouseEvent',{type:'mouseMoved',...rect});await key('ArrowRight',150);
86
+ if(game==='breakout')await key(' ');
87
+ await sleep(500);check(before!==await pixels(),'paddle and ball update after input');break;
88
+ }
89
+ case 'dino':{
90
+ await start();const before=await pixels();await key(' ');await sleep(300);check(before!==await pixels(),'jump/runner animation advances');break;
91
+ }
92
+ case 'sudoku':{
93
+ await start();await click('#notesBtn');check(await read('document.getElementById("notesBtn").textContent.includes("On")'),'notes mode toggles');
94
+ await click('#notesBtn');const before=await pixels();await click('#hintBtn');await sleep(120);check(before!==await pixels(),'hint fills a cell');break;
95
+ }
96
+ case 'connect4':{
97
+ check(await read(`(()=>{const cols=[...document.querySelectorAll('.col')];return cols.length===7&&cols.every((col,index)=>{const cells=[...col.querySelectorAll('.cell')].map(e=>e.getBoundingClientRect()),r=col.getBoundingClientRect();return cells.length===6&&cells.every((cell,i)=>cell.width>0&&Math.abs(cell.x-cells[0].x)<1&&(!i||cell.bottom<=cells[i-1].top))&&(!index||r.left>=cols[index-1].getBoundingClientRect().right)})})()`),'seven non-overlapping columns contain six vertically reversed cells');
98
+ for(const col of [0,1,0,1,0,1,0]){await click('.col',col);await sleep(450);}
99
+ check(await wait('document.getElementById("gameOverOverlay").classList.contains("show")'),'four connected discs trigger the winner overlay');
100
+ check(await read('Number(document.getElementById("scoreRed").textContent)===1'),'winning round updates score');break;
101
+ }
102
+ case 'typing':{
103
+ await click('#startBtn');const word=await read('document.querySelector(".word.current").textContent');
104
+ await click('#wordInput');for(const char of word+' ')await c.send('Input.insertText',{text:char});
105
+ check(await read('document.querySelectorAll(".word.correct").length>0||currentWordIdx>0'),'typing the current word advances the exercise');
106
+ const mute=await read('document.getElementById("muteBtn").textContent');
107
+ for(const char of 'pPmM'){
108
+ const event={key:char,code:'Key'+char.toUpperCase(),windowsVirtualKeyCode:char.toUpperCase().charCodeAt(0),modifiers:char===char.toUpperCase()?8:0};
109
+ await c.send('Input.dispatchKeyEvent',{type:'rawKeyDown',...event});
110
+ await c.send('Input.dispatchKeyEvent',{type:'char',...event,text:char});
111
+ await c.send('Input.dispatchKeyEvent',{type:'keyUp',...event});
112
+ }
113
+ check(await read('document.getElementById("wordInput").value==="pPmM"&&!isPaused'),'P and M remain ordinary input in both letter cases');
114
+ check(await read('document.getElementById("muteBtn").textContent')===mute,'typing M does not toggle sound');
115
+ await key('Escape');check(await read('isPaused&&document.getElementById("wordInput").disabled'),'Escape pauses while the input is focused');
116
+ await key('Escape');check(await read('!isPaused&&!document.getElementById("wordInput").disabled'),'Escape resumes typing');break;
117
+ }
118
+ case 'simon':{
119
+ await read(`(()=>{window.__classicLights=[];new MutationObserver(records=>{for(const record of records)if(record.target.matches('.quadrant.active'))window.__classicLights.push(record.target.dataset.idx)}).observe(document.getElementById('board'),{subtree:true,attributes:true,attributeFilter:['class']})})()`);
120
+ await click('#startBtn');let index=null;
121
+ for(let n=0;n<30;n++){index=await read('window.__classicLights[0]??null');if(index!==null)break;await sleep(35);}
122
+ check(index!==null,'first sequence visibly lights a quadrant');
123
+ check(await wait('document.getElementById("status").textContent.includes("Your turn")'),'sequence switches to player input');
124
+ await click(`.quadrant[data-idx="${index}"]`);check(await wait('Number(document.getElementById("roundDisplay").textContent)>=2'),'correct quadrant advances to round two');break;
125
+ }
126
+ case 'puzzle15':{
127
+ check(await read('document.querySelectorAll("#puzzleGrid .tile").length===15'),'fifteen numbered tiles are rendered');
128
+ const index=await read(`(()=>{const g=document.getElementById('puzzleGrid'),r=g.getBoundingClientRect(),cs=r.width/4;const tiles=[...g.querySelectorAll('.tile')],positions=tiles.map(e=>{const t=e.getBoundingClientRect();return [Math.round((t.x-r.x)/cs),Math.round((t.y-r.y)/cs)]});let empty;for(let y=0;y<4;y++)for(let x=0;x<4;x++)if(!positions.some(p=>p[0]===x&&p[1]===y))empty=[x,y];return positions.findIndex(p=>Math.abs(p[0]-empty[0])+Math.abs(p[1]-empty[1])===1)})()`);
129
+ await click('#puzzleGrid .tile',index);check(await read('Number(document.getElementById("moveCount").textContent)>0'),'adjacent tile slides and increases move count');break;
130
+ }
131
+ }
132
+ return evidence;
133
+ }
@@ -52,3 +52,19 @@ test('clipboard read is one-shot, preserves empty/text values, and prefers xclip
52
52
  await Bun.sleep(30);expect(calls).toHaveLength(2);
53
53
  await expect(readClipboard({...options,which:()=>null})).rejects.toThrow('No working');
54
54
  });
55
+
56
+ test('execCommand copy snapshots a temporary selected textarea and uses the existing host clipboard',async()=>{
57
+ const copies=[];const host=new RendererHost({clipboardWrite:async text=>{await Bun.sleep(10);copies.push(text);}});
58
+ try {
59
+ await host.call('loadDocument',{url:'http://clipboard.test/',contentType:'text/html',source:`<body style="margin:0"><button style="width:200px;height:60px" onclick="copyText()">Copy</button><script>
60
+ function copyText(){const field=document.createElement('textarea');field.value='line 1\\n中文 😀';document.body.append(field);field.select();window.copied=document.execCommand('copy');field.remove();}
61
+ </script>`});
62
+ await host.call('whenLoaded');
63
+ expect(await host.call('evaluate',`document.queryCommandSupported('copy')`)).toBe(true);
64
+ await host.call('evaluate','copyText()');expect(await host.call('evaluate','copied')).toBe(false);expect(copies).toEqual([]);
65
+ await host.call('click',30,20);
66
+ for(let i=0;i<40&&!copies.length;i++)await Bun.sleep(25);
67
+ expect(copies).toEqual(['line 1\n中文 😀']);
68
+ expect(await host.call('evaluate','copied')).toBe(true);
69
+ }finally{host.close();}
70
+ },20000);
@@ -957,3 +957,21 @@ test("form controls default to readable dark text but author colors still win",
957
957
  expect(styles.get(document.querySelector('#custom')).color).toBe('rgba(255, 0, 0, 1)');
958
958
  } finally {window.happyDOM.abort()}
959
959
  });
960
+
961
+ test("aspect-ratio parses ratios, auto fallback, inheritance and invalid declarations", () => {
962
+ const { document, window } = parseHTMLDocument(`<div id="a" style="aspect-ratio:16 / 9"><div id="b" style="aspect-ratio:inherit"></div><div id="c"></div></div>
963
+ <div id="auto" style="aspect-ratio:2 auto"></div><div id="zero" style="aspect-ratio:0 / 1"></div>
964
+ <div id="invalid" style="aspect-ratio:3 / 2;aspect-ratio:1 auto / 2;aspect-ratio:-1;aspect-ratio:1 / 2 / 3"></div>
965
+ <div id="reset" style="aspect-ratio:2;aspect-ratio:auto"></div>`);
966
+ try {
967
+ const styles = new StyleEngine().compute(document);
968
+ const ratio = id => styles.get(document.getElementById(id)).aspectRatio;
969
+ expect(ratio('a')).toEqual({ numerator:16, denominator:9, auto:false });
970
+ expect(ratio('b')).toEqual(ratio('a'));
971
+ expect(ratio('c')).toBeNull();
972
+ expect(ratio('auto')).toEqual({ numerator:2, denominator:1, auto:true });
973
+ expect(ratio('zero')).toEqual({ numerator:0, denominator:1, auto:false });
974
+ expect(ratio('invalid')).toEqual({ numerator:3, denominator:2, auto:false });
975
+ expect(ratio('reset')).toBeNull();
976
+ } finally { window.happyDOM.abort(); }
977
+ });
@@ -0,0 +1,28 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {RendererHost} from '../lib/renderer/host.js';
3
+
4
+ test('inline prefixed animation runs, bubbles real AnimationEvents and can be cleared in the end handler',async()=>{
5
+ const renderer=new RendererHost({timeout:20000});
6
+ try{
7
+ await renderer.start();
8
+ await renderer.call('loadDocument',{url:'https://animation.test/',contentType:'text/html',source:`<!doctype html>
9
+ <style>@-webkit-keyframes expand {from{width:10px}to{width:110px}}</style>
10
+ <div id="box" style="width:200px;height:20px"></div><script>
11
+ var box=document.getElementById('box'),events=[];
12
+ document.addEventListener('animationend',e=>events.push([e.type,e.animationName,e.elapsedTime,e.pseudoElement,e instanceof AnimationEvent,e.target===box]));
13
+ box.addEventListener('webkitAnimationEnd',e=>{events.push([e.type,e.animationName,e.elapsedTime]);box.style.webkitAnimation='';});
14
+ box.style.webkitAnimation='expand .1s linear 1 both';
15
+ </script>`});
16
+ await renderer.call('whenLoaded');
17
+ for(let n=0;n<100;n++){
18
+ if(await renderer.call('evaluate','events.length>=2'))break;
19
+ await Bun.sleep(30);
20
+ }
21
+ expect(JSON.parse(await renderer.call('evaluate','JSON.stringify(events)'))).toEqual([
22
+ ['animationend','expand',.1,'',true,true],['webkitAnimationEnd','expand',.1],
23
+ ]);
24
+ expect(await renderer.call('evaluate',"box.style.animation")).toBe('');
25
+ expect(await renderer.call('evaluate',"getComputedStyle(box).width")).toBe('200px');
26
+ expect(await renderer.call('evaluate',"new AnimationEvent('animationend',{animationName:'test',elapsedTime:2}).elapsedTime")).toBe(2);
27
+ }finally{await renderer.close();}
28
+ },20000);
@@ -0,0 +1,83 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {createCSSAnimations} from '../lib/renderer/css-animations.js';
3
+ import {extractAnimationKeyframes,StyleEngine} from '../lib/style/computed-style.js';
4
+ import {animationShorthand,animationProperty} from '../lib/style/animation-values.js';
5
+ import {parseHTMLDocument} from '../lib/happy-dom/parser.js';
6
+
7
+ function timeline() {
8
+ let time=0,next=0;const tasks=new Map(),events=[];
9
+ const element={isConnected:true};
10
+ const animation=createCSSAnimations({now:()=>time,invalidate:()=>{},dispatch:(_e,type,detail)=>events.push({type,...detail}),
11
+ setTimer:(fn,delay)=>{tasks.set(++next,{fn,at:time+delay});return next;},clearTimer:id=>tasks.delete(id)});
12
+ animation.setSheets(['@keyframes grow {from{width:10px;opacity:0} to{width:110px;opacity:1}}'],{});
13
+ const style={width:200,height:20,opacity:1,display:'block',cssAnimations:animationShorthand('grow 1s linear both')};
14
+ return {animation,element,style,events,tasks,setTime:value=>time=value,
15
+ flush:()=>{for(const [id,task] of [...tasks])if(task.at<=time){tasks.delete(id);task.fn();}}};
16
+ }
17
+
18
+ test('CSS keyframes use balanced blocks, media filtering and prefix aliases',()=>{
19
+ const frames=extractAnimationKeyframes('@media (min-width:500px){@-webkit-keyframes move{from{width:10px}50%,to{width:20px}}} @media (max-width:400px){@keyframes absent{to{opacity:0}}}',{width:800});
20
+ expect([...frames.keys()]).toEqual(['move']);expect(frames.get('move').map(f=>f.offset)).toEqual([0,.5,1]);
21
+ expect(animationProperty('webkitAnimationDuration')).toBe('animation-duration');
22
+ expect(animationProperty('-webkit-animation')).toBe('animation');expect(animationProperty('--MyVar')).toBe('--MyVar');
23
+ });
24
+
25
+ test('numeric animations interpolate without changing DOM declarations, and end once',()=>{
26
+ const t=timeline();
27
+ expect(t.animation.sample(t.element,t.style).width).toBe(10);t.flush();
28
+ t.setTime(500);expect(t.animation.sample(t.element,t.style).width).toBe(60);
29
+ expect(t.animation.sample(t.element,t.style).opacity).toBe(.5);expect(t.style.width).toBe(200);
30
+ t.setTime(1000);expect(t.animation.sample(t.element,t.style).width).toBe(110);t.flush();
31
+ t.setTime(2000);t.animation.sample(t.element,t.style);t.flush();
32
+ expect(t.events.map(e=>[e.type,e.animationName,e.elapsedTime])).toEqual([['animationstart','grow',0],['animationend','grow',1]]);
33
+ t.animation.dispose();expect(t.tasks.size).toBe(0);
34
+ });
35
+
36
+ test('delay, repeat, pause/resume, cancellation and missing definitions',()=>{
37
+ const t=timeline();t.style.cssAnimations=animationShorthand('grow 1s 200ms linear 2');
38
+ expect(t.animation.sample(t.element,t.style).width).toBe(200);t.flush();expect(t.events).toEqual([]);
39
+ t.setTime(700);expect(t.animation.sample(t.element,t.style).width).toBe(60);t.flush();
40
+ t.style.cssAnimations=[{...t.style.cssAnimations[0],playState:'paused'}];t.animation.sample(t.element,t.style);
41
+ t.setTime(1700);expect(t.animation.sample(t.element,t.style).width).toBe(60);
42
+ t.style.cssAnimations=[{...t.style.cssAnimations[0],playState:'running'}];t.animation.sample(t.element,t.style);
43
+ t.setTime(2300);t.animation.sample(t.element,t.style);t.flush();
44
+ expect(t.events.some(e=>e.type==='animationiteration')).toBe(true);
45
+ t.animation.sample(t.element,{display:'block'});t.flush();expect(t.events.at(-1).type).toBe('animationcancel');
46
+ t.animation.sample(t.element,{display:'block',cssAnimations:animationShorthand('missing 1s')});t.flush();
47
+ expect(t.events.filter(e=>e.type==='animationstart')).toHaveLength(1);
48
+ t.animation.dispose();expect(t.tasks.size).toBe(0);
49
+ });
50
+
51
+ test('cascade retains shorthand and longhand timing with vendor aliases',()=>{
52
+ const {document,window}=parseHTMLDocument('<div id=a style="animation-delay:2s; -webkit-animation:grow 1s linear; animation-duration:3s"></div>');
53
+ try{const engine=new StyleEngine().compute(document);const style=engine.get(document.getElementById('a'));
54
+ expect(style.cssAnimations[0].delay).toBe('0s');expect(style.cssAnimationLonghands).toEqual({duration:['3s']});
55
+ }finally{window.happyDOM.close();}
56
+ });
57
+
58
+ test('transform keyframes animate a drop and bounce with easing per segment',()=>{
59
+ const t=timeline();
60
+ t.animation.setSheets(['@keyframes drop{0%{transform:translateY(-300px);opacity:0}60%{transform:translateY(8px)}80%{transform:translateY(-4px)}100%{transform:translateY(0);opacity:1}}'],{});
61
+ t.style.transform=null;t.style.cssAnimations=animationShorthand('drop 1s ease-in forwards');
62
+ try{
63
+ expect(t.animation.sample(t.element,t.style).transform.y).toBe(-300);
64
+ t.setTime(600);expect(t.animation.sample(t.element,t.style).transform.y).toBe(8);
65
+ t.setTime(700);const y=t.animation.sample(t.element,t.style).transform.y;
66
+ expect(y).toBeLessThan(8);expect(y).toBeGreaterThan(2);
67
+ t.setTime(800);expect(t.animation.sample(t.element,t.style).transform.y).toBe(-4);
68
+ t.setTime(1000);expect(t.animation.sample(t.element,t.style).transform.y).toBe(0);
69
+ expect(t.style.transform).toBeNull();
70
+ }finally{t.animation.dispose()}
71
+ });
72
+
73
+ test('transform interpolation preserves percentage translations and underlying endpoints',()=>{
74
+ const t=timeline();
75
+ t.animation.setSheets(['@keyframes move{to{transform:translateX(100%) scale(2)}}'],{});
76
+ t.style.transform=null;t.style.cssAnimations=animationShorthand('move 1s linear both');
77
+ try{
78
+ t.animation.sample(t.element,t.style);t.setTime(500);
79
+ const value=t.animation.sample(t.element,t.style).transform;
80
+ expect(value.scaleX).toBe(1.5);expect(value.x.kind).toBe('sum');
81
+ t.setTime(1000);expect(t.animation.sample(t.element,t.style).transform.x).toEqual({unit:'%',value:100});
82
+ }finally{t.animation.dispose()}
83
+ });
@@ -0,0 +1,39 @@
1
+ import {expect,test} from 'bun:test';
2
+ test('Land Canvas layers match Skia and safely return to full painting',async()=>{
3
+ const p=Bun.spawn([process.execPath,new URL('./fixtures/direct-canvas.js',import.meta.url).pathname],{stdout:'pipe',stderr:'pipe'});
4
+ const [exit,stderr]=await Promise.all([p.exited,new Response(p.stderr).text()]);
5
+ expect({exit,stderr}).toEqual({exit:0,stderr:''});
6
+ },15000);
7
+
8
+ test('Land adapter preserves RGBA, premultiplied alpha, unaligned inputs and immutable output',async()=>{
9
+ const {CanvasComposer}=await import('../lib/paint/direct-canvas.ts');
10
+ const composer=new CanvasComposer();
11
+ const source=Uint8Array.of(50,0,0,128,99,88,77,255,0,0,0,0);
12
+ const storage=new Uint8Array(source.length+2).fill(9),unaligned=storage.subarray(1,-1);unaligned.set(source);
13
+ try{
14
+ const layer=pixels=>({pixels,width:3,height:1,x:0,y:0,drawWidth:3,drawHeight:1});
15
+ const aligned=composer.compose(3,1,[10,20,30,255],[layer(source)]).slice();
16
+ expect([...aligned]).toEqual([55,10,15,255,99,88,77,255,10,20,30,255]);
17
+ expect([...composer.compose(3,1,[10,20,30,255],[layer(unaligned)])]).toEqual([...aligned]);
18
+ expect([...source]).toEqual([50,0,0,128,99,88,77,255,0,0,0,0]);
19
+ expect(storage[0]).toBe(9);expect(storage.at(-1)).toBe(9);
20
+ const previous=composer.compose(3,1,[10,20,30,255],[layer(source)]);
21
+ composer.compose(3,1,[255,0,0,255],[]);expect([...previous]).toEqual([...aligned]);
22
+ }finally{composer.close();composer.close()}
23
+ });
24
+
25
+ test('the complete vendored Land renderer supports PNG and negotiated raw screenshots',async()=>{
26
+ const {createRenderer}=await import('../vendor/land-renderer/renderer.ts');
27
+ const surface={pixels:{bytes:Uint8Array.of(0,0,255,255,255,0,0,255),width:2,height:1,stride:8,format:0},scale:1,transform:0};
28
+ const scene={width:2,height:1,fastPng:true,input:{x:0,y:0,pointer:false},wm:{renderSurfaces:()=>[surface],origin:()=>({x:0,y:0})}};
29
+ let raw,closed=false;
30
+ const renderer=await createRenderer(scene,{rawFrames:{write(bytes,width,height,format){raw={bytes:[...bytes],width,height,format};return 'raw-frame';},close(){closed=true;}}});
31
+ try{
32
+ const data=await renderer.screenshot({format:'png'});
33
+ const {CanvasKit:kit}=await (await import('../lib/renderer/page-renderer.js')).loadGraphics();
34
+ const image=kit.MakeImageFromEncoded(Buffer.from(data,'base64'));
35
+ try{expect([...image.readPixels(0,0,{width:2,height:1,colorType:kit.ColorType.RGBA_8888,alphaType:kit.AlphaType.Unpremul,colorSpace:kit.ColorSpace.SRGB})]).toEqual([255,0,0,255,0,0,255,255]);}finally{image.delete()}
36
+ expect(await renderer.screenshot({format:'rgba'})).toBe('raw-frame');
37
+ expect(raw).toEqual({bytes:[255,0,0,255,0,0,255,255],width:2,height:1,format:'rgba'});
38
+ }finally{renderer.close();expect(closed).toBe(true)}
39
+ });
@@ -0,0 +1,25 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {RendererHost} from '../lib/renderer/host.js';
3
+
4
+ test('dynamic inline handlers compile for dispatch and reflect updates without replacing property handlers',async()=>{
5
+ const host=new RendererHost();
6
+ try {
7
+ await host.call('loadDocument',{url:'https://example.test/',contentType:'text/html',source:'<body><script>window.hits=[]</script></body>'});
8
+ await host.call('whenLoaded');
9
+ expect(await host.call('evaluate',`(()=>{
10
+ document.body.innerHTML='<div onclick="hits.push(2)"><button onclick="hits.push(1)">go</button></div>';
11
+ const button=document.querySelector('button');
12
+ button.dispatchEvent(new MouseEvent('click',{bubbles:true}));
13
+ button.onclick=()=>hits.push(3);
14
+ button.dispatchEvent(new MouseEvent('click'));
15
+ button.setAttribute('onclick','hits.push(4)');
16
+ button.dispatchEvent(new MouseEvent('click'));
17
+ button.removeAttribute('onclick');
18
+ button.dispatchEvent(new MouseEvent('click'));
19
+ button.setAttribute('onclick','hits.push(5)');
20
+ const reflected=typeof button.onclick;
21
+ button.dispatchEvent(new MouseEvent('click'));
22
+ return [hits,reflected];
23
+ })()`)).toEqual([[1,2,3,4,5],'function']);
24
+ } finally {host.close();}
25
+ },20000);
@@ -0,0 +1,22 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {RendererHost} from '../lib/renderer/host.js';
3
+ test('sloppy direct eval declares caller-local functions and vars without leaking between calls',async()=>{
4
+ const host=new RendererHost();try{
5
+ await host.call('loadDocument',{url:'https://eval.test/',contentType:'text/html',source:`<script>
6
+ function make(source) {
7
+ let prefix='local:';
8
+ eval(source);
9
+ const first=helper();
10
+ eval('count += 1');
11
+ return [first,helper(),typeof count,()=>helper()];
12
+ }
13
+ window.a=make('var count=2; function helper(){return prefix+count;}');
14
+ window.b=make('var count=7; function helper(){return prefix+count;}');
15
+ window.same=(function(){var existing=1;eval('var existing=3');return existing;})();
16
+ window.strict=(function(){'use strict';eval('var hidden=3');return typeof hidden;})();
17
+ window.lexical=(function(){eval('let hidden=3');return typeof hidden;})();
18
+ </script>`});
19
+ await host.call('whenLoaded');
20
+ expect(await host.call('evaluate',`[a[0],a[1],a[2],a[3](),b[0],b[3](),same,strict,lexical,typeof helper,typeof count]`)).toEqual(['local:2','local:3','number','local:3','local:7','local:8',3,'undefined','undefined','undefined','undefined']);
21
+ }finally{host.close();}
22
+ },20000);
@@ -0,0 +1,34 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {mkdtemp,rm} from 'node:fs/promises';
3
+ import {tmpdir} from 'node:os';
4
+ import {join} from 'node:path';
5
+ import {createBrowser} from '../lib/headless-shell.js';
6
+ import {CdpServer} from '../lib/cdp-server.js';
7
+ import {CDP} from '../tools/browser-lab.js';
8
+ test('file input opens browser-owned picker, selects files, and FileReader emits asynchronous load',async()=>{
9
+ const dir=await mkdtemp(join(tmpdir(),'buninu-picker-'));await Bun.write(join(dir,'one.txt'),'Hello 選檔');await Bun.write(join(dir,'two.txt'),'second');
10
+ const b=await createBrowser({spareRenderer:false}),server=CdpServer.create(b.context).listen(0),c=new CDP(`ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);
11
+ try{
12
+ await c.send('Page.navigate',{url:'data:text/html,'+encodeURIComponent(`<input id="f" type="file" multiple><script>window.events=[];f.oninput=()=>events.push('input');f.onchange=async()=>{events.push('change');window.names=[...f.files].map(f=>f.name);const r=new FileReader();r.onload=e=>{window.text=e.target.result;events.push('load')};r.readAsText(f.files[0]);};</script>`)});
13
+ await c.evaluate('f.click();window.clickReturned=true');
14
+ for(let i=0;i<200&&!b.context.dialogState;i++)await Bun.sleep(10);
15
+ expect(b.context.dialogState?.type).toBe('file');
16
+ 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'});};
17
+ await action({kind:'browse',path:dir});
18
+ expect(await c.evaluate('document.body.textContent.includes("one.txt")')).toBe(true);
19
+ const point=await c.evaluate(`(()=>{const b=document.querySelector('button.entry');const r=b.getBoundingClientRect();return {x:r.x+20,y:r.y+r.height/2}})()`);
20
+ // Casty lets modal input bypass the page's blocked input turn. Down/up
21
+ // requests may overlap: one click must consume its picker action once.
22
+ for(const expected of ['one.txt','']) {
23
+ await Promise.all(['mousePressed','mouseReleased'].map(type=>c.send('Input.dispatchMouseEvent',{type,...point,button:'left',clickCount:1})));
24
+ expect(b.context.dialogState?.type).toBe('file');
25
+ expect(await c.evaluate('document.getElementById("chosen").textContent')).toBe(expected);
26
+ }
27
+ await action({kind:'select',index:0});await action({kind:'select',index:1});await action({kind:'open'});
28
+ for(let i=0;i<100;i++){if(await c.evaluate('window.text!==undefined'))break;await Bun.sleep(10);}
29
+ expect(await c.evaluate('clickReturned')).toBe(true);expect(await c.evaluate('names')).toEqual(['one.txt','two.txt']);
30
+ expect(await c.evaluate('text')).toBe('Hello 選檔');expect(await c.evaluate('events')).toEqual(['input','change','load']);
31
+ expect(await c.evaluate('f.value')).toBe('C:\\fakepath\\one.txt');
32
+ expect(await c.evaluate('f.value="";({length:f.files.length,value:f.value})')).toEqual({length:0,value:''});
33
+ }finally{c.close();server.stop(true);b.close();await rm(dir,{recursive:true,force:true});}
34
+ },30000);
@@ -24,6 +24,29 @@ try{
24
24
  r.resize(401,300,1);r.resize(400,300,1);
25
25
  assert.deepEqual(await pixels(),partial,'partial pixels must match a full repaint, including transparency, clipping and occlusion');
26
26
  }
27
+ // A changing coordinate/status label must not disable local canvas damage.
28
+ await r.evaluate("document.querySelector('p').id='status'");await pixels();
29
+ for(const text of ['Dragging 123, 456','Short','拖曳 8, 9','Italic fj']){
30
+ traces.length=0;
31
+ await r.evaluate(`document.getElementById('status').textContent=${JSON.stringify(text)};ctx.fillStyle='red';ctx.fillRect(10,10,20,20)`);
32
+ const partial=await pixels();
33
+ assert.ok(traces.some(s=>s.includes('canvas damage')),'status text and canvas share partial repaint');
34
+ r.resize(401,300,1);r.resize(400,300,1);
35
+ assert.deepEqual(await pixels(),partial,'status replacement clears old glyphs and matches full repaint');
36
+ }
37
+ // Changed font metrics / wrapping retain the complete-paint fallback.
38
+ traces.length=0;
39
+ await r.evaluate("document.getElementById('status').style.fontSize='30px';ctx.fillRect(0,0,5,5)");
40
+ const restyled=await pixels();
41
+ assert.ok(!traces.some(s=>s.includes('canvas damage')),'changed fonts must not use text damage');
42
+ r.resize(401,300,1);r.resize(400,300,1);assert.deepEqual(await pixels(),restyled);
43
+ // Italic overhang and fractional raster scaling must preserve the same pixels.
44
+ await r.evaluate("document.getElementById('status').style.fontStyle='italic'");
45
+ r.resize(400,300,1.5);await pixels();traces.length=0;
46
+ await r.evaluate("document.getElementById('status').textContent='fj';ctx.clearRect(0,0,120,100)");
47
+ const italic=await pixels();assert.ok(traces.some(s=>s.includes('canvas damage')));
48
+ r.resize(401,300,1.5);r.resize(400,300,1.5);assert.deepEqual(await pixels(),italic);
49
+ r.resize(400,300,1);
27
50
  // Geometry/transform changes must fall back, then remain pixel-correct.
28
51
  traces.length=0;
29
52
  await r.evaluate("document.querySelector('.clip').style.transform='scale(1.2)';ctx.fillRect(0,0,80,80)");
@@ -0,0 +1,35 @@
1
+ import assert from 'node:assert/strict';
2
+ import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
3
+ import {LayoutEngine} from '../../lib/layout/text-layout.js';
4
+ const sleep=Bun.sleep,now=performance.now.bind(performance),layout=LayoutEngine.prototype.layout,write=process.stdout.write.bind(process.stdout);
5
+ let slow=false,layouts=0,frames=0;
6
+ LayoutEngine.prototype.layout=function(...args){
7
+ layouts++;
8
+ if(slow){slow=false;const until=now()+300;while(now()<until){}}
9
+ return layout.apply(this,args);
10
+ };
11
+ const renderer=await createPageRenderer({onFrame:()=>frames++});
12
+ try{
13
+ renderer.resize(128,96,1);renderer.setScripting('host');
14
+ await renderer.loadDocument({url:'https://canvas.test/pacing',contentType:'text/html',source:`<!doctype html><html><head><style>body{margin:0}</style></head><body><canvas id=c width=128 height=96></canvas><script>var ctx=document.getElementById('c').getContext('2d');</script></body></html>`});
15
+ await renderer.whenLoaded();await renderer.evaluate("var ctx=document.getElementById('c').getContext('2d')");
16
+ // Simulate a slow font/resource-driven reflow, then animate only the bitmap.
17
+ slow=true;renderer.resize(129,96,1);frames=0;const before=layouts;
18
+ await renderer.evaluate("var step=0;var painting=setInterval(()=>{ctx.fillStyle=++step%2?'red':'blue';ctx.fillRect(0,0,128,96)},20)");
19
+ await sleep(350);const presented=frames;
20
+ await renderer.evaluate('clearInterval(painting)');
21
+ assert.ok(presented>=2,`Canvas-only updates must not inherit the old 300ms layout cost; got ${presented} frames`);
22
+ assert.equal(layouts,before,'bitmap animation does not require layout');
23
+ const beforeBatch=frames;
24
+ await renderer.evaluate(`new Promise(resolve=>{
25
+ let count=0;function draw(){
26
+ const end=performance.now()+20;while(performance.now()<end){}
27
+ ctx.fillStyle=++count%2?'green':'yellow';ctx.fillRect(0,0,128,96);
28
+ if(count<5)requestAnimationFrame(draw);else setTimeout(resolve,30);
29
+ }requestAnimationFrame(draw);
30
+ })`);
31
+ const animationPresented=frames-beforeBatch;
32
+ assert.ok(animationPresented>=5,`each expensive Canvas rAF must reach presentation, got ${animationPresented}/5`);
33
+
34
+ write(JSON.stringify({presented,animationPresented,layoutsDuringAnimation:layouts-before})+'\n');
35
+ }finally{renderer.dispose();LayoutEngine.prototype.layout=layout;}
@@ -0,0 +1,96 @@
1
+ import {strict as assert} from 'node:assert';
2
+ import {createPageRenderer,loadGraphics} from '../../lib/renderer/page-renderer.js';
3
+ const {CanvasKit:kit}=await loadGraphics();
4
+ const traces=[];
5
+ const r=await createPageRenderer({onFrame:()=>{},trace:s=>traces.push(s)});
6
+ async function pixels(){
7
+ const image=kit.MakeImageFromEncoded(Buffer.from(await r.screenshot(),'base64'));
8
+ try{return image.readPixels(0,0,{width:image.width(),height:image.height(),colorType:kit.ColorType.RGBA_8888,alphaType:kit.AlphaType.Unpremul,colorSpace:kit.ColorSpace.SRGB})}
9
+ finally{image.delete()}
10
+ }
11
+ try{
12
+ r.resize(64,48,1);r.setScripting('host');
13
+ await r.loadDocument({url:'https://canvas.test/',contentType:'text/html',source:`<!doctype html><style>body{margin:0;background:#196ab3}canvas{display:block}</style><canvas id=c width=64 height=48></canvas><script>window.c=document.getElementById('c');window.ctx=c.getContext('2d');</script>`});
14
+ await r.whenLoaded();
15
+ for(const command of [
16
+ "ctx.clearRect(0,0,64,48)",
17
+ "ctx.fillStyle='red';ctx.fillRect(0,0,64,48)",
18
+ "{let data=ctx.createImageData(64,48);for(let i=0;i<data.data.length;i+=4){data.data[i]=(i*37)%256;data.data[i+1]=(i*17+71)%256;data.data[i+2]=(i*53+119)%256;data.data[i+3]=(i/4)%256;}ctx.putImageData(data,0,0);}",
19
+ "ctx.clearRect(0,0,64,48);ctx.fillStyle='rgba(230,80,40,.5)';ctx.fillRect(3,4,40,30);ctx.fillStyle='rgba(10,240,150,.2)';ctx.fillRect(20,10,30,35)",
20
+ ]){
21
+ traces.length=0;await r.evaluate("c.style.transform='none';"+command);
22
+ const direct=await pixels();
23
+ assert.ok(traces.some(s=>s.includes('direct canvas composition')),'exercise direct composition');
24
+ traces.length=0;await r.evaluate("c.style.transform='translate(0px,0px)'");
25
+ assert.deepEqual(await pixels(),direct,'direct copy/blend must match Skia pixel for pixel');
26
+ assert.ok(!traces.some(s=>s.includes('direct canvas composition')),'transform keeps native fallback');
27
+ }
28
+ // A single Canvas need not fill the page: keep surrounding background and
29
+ // the native nearest-sampling pixel-center rule for fractional placement.
30
+ r.resize(96,80,1);
31
+ for(const [x,y] of [[0,0],[8,5],[8.12,5.24],[8.49,5.49],[8.51,5.51],[-9.2,-4.7],[60.2,50.8]]){
32
+ await r.evaluate(`c.style.cssText='position:absolute;left:${x}px;top:${y}px';ctx.clearRect(0,0,64,48);ctx.fillStyle='rgba(240,20,80,.5)';ctx.fillRect(1,1,40,30)`);
33
+ traces.length=0;const placed=await pixels();
34
+ // evaluate may have already published the direct frame.
35
+ await r.evaluate("ctx.fillRect(1,1,1,1)");const updated=await pixels();
36
+ assert.ok(traces.some(s=>s.includes('direct canvas composition')),'inset/partially clipped canvas uses direct composition');
37
+ await r.evaluate("c.style.transform='translate(0px,0px)'");
38
+ assert.deepEqual(await pixels(),updated,`placed canvas at ${x},${y} matches Skia`);
39
+ }
40
+ r.resize(64,48,1);await r.evaluate("c.style.cssText=''");
41
+ // A high-DPI canvas can still be passed through when bitmap pixels match
42
+ // the tile's physical dimensions, with no CSS resampling.
43
+ r.resize(64,48,2);
44
+ traces.length=0;
45
+ await r.evaluate("c.width=128;c.height=96;c.style.cssText='width:64px;height:48px';ctx.fillStyle='rgba(200,30,70,.6)';ctx.fillRect(0,0,128,96)");
46
+ const highDpi=await pixels();assert.ok(traces.some(s=>s.includes('direct canvas composition')));
47
+ traces.length=0;await r.evaluate("c.style.transform='translate(0px,0px)'");
48
+ assert.deepEqual(await pixels(),highDpi,'high-DPI direct pixels match Skia');
49
+ r.resize(64,48,1);await r.evaluate("c.width=64;c.height=48;c.style.cssText=''");
50
+ // Returning from direct pixels to Skia must repaint the retained surface in
51
+ // full; it cannot apply damage to the surface from before the bypass.
52
+ for(const css of ['opacity:.5','border-radius:8px']){
53
+ await r.evaluate("c.style.cssText='';ctx.fillStyle='blue';ctx.fillRect(0,0,64,48)");await pixels();
54
+ traces.length=0;await r.evaluate(`c.style.cssText=${JSON.stringify(css)}`);
55
+ const fallback=await pixels();
56
+ assert.ok(!traces.some(s=>s.includes('direct canvas composition')),css);
57
+ r.resize(65,48,1);r.resize(64,48,1);
58
+ assert.deepEqual(await pixels(),fallback,'fallback after direct output matches full repaint');
59
+ }
60
+ await r.evaluate("c.style.cssText='';document.body.insertAdjacentHTML('beforeend','<span style=\"position:absolute;left:4px;top:4px;background:yellow\">overlay</span>')");
61
+ traces.length=0;await r.evaluate("ctx.fillStyle='red';ctx.fillRect(1,1,2,2)");
62
+ const overlay=await pixels();assert.ok(traces.some(s=>s.includes('direct canvas composition')),'foreground DOM is a cached layer');
63
+ await r.evaluate("c.style.transform='translate(0px,0px)'");assert.deepEqual(await pixels(),overlay,'foreground layer preserves paint order');
64
+ await r.evaluate("c.style.cssText='width:32px'");traces.length=0;
65
+ await r.evaluate("ctx.clearRect(0,0,64,48);ctx.fillStyle='blue';ctx.fillRect(0,0,32,48)");
66
+ const scaled=await pixels();assert.ok(traces.some(s=>s.includes('direct canvas composition')),'scaled Canvas uses Land sampler');
67
+ await r.evaluate("c.style.transform='translate(0px,0px)'");assert.deepEqual(await pixels(),scaled,'Land scaling matches Skia');
68
+ // Cached DOM layers stay out of the frame loop, but DOM mutations invalidate
69
+ // them. Multiple translucent canvases retain their original paint order.
70
+ await r.evaluate("c.style.cssText='';document.body.insertAdjacentHTML('beforeend','<canvas id=second width=20 height=10 style=\"position:absolute;left:8px;top:12px\"></canvas>');var second=document.getElementById('second');var other=second.getContext('2d');other.fillStyle='rgba(0,240,50,.5)';other.fillRect(0,0,20,10)");
71
+ await pixels();traces.length=0;
72
+ await r.evaluate("ctx.fillStyle='blue';ctx.fillRect(0,0,64,48)");const multiple=await pixels();
73
+ assert.ok(traces.some(s=>s.includes('direct canvas composition')));
74
+ assert.ok(!traces.some(s=>s.includes('DOM layers rebuilt')),'unchanged DOM layers are reused across Canvas updates');
75
+ await r.evaluate("c.style.transform='translate(0px,0px)'");assert.deepEqual(await pixels(),multiple,'multiple translucent layers match Skia');
76
+ await r.evaluate("c.style.transform='none';document.querySelector('span').textContent='changed'");
77
+ const changedDom=await pixels();
78
+ await r.evaluate("c.style.transform='translate(0px,0px)'");assert.deepEqual(await pixels(),changedDom,'DOM cache invalidates when overlay text changes');
79
+ // Ancestor overflow and rounded clips rasterize only the changing Canvas,
80
+ // retaining static DOM caches and matching the native full-page painter.
81
+ await r.evaluate(`document.body.innerHTML='<div id="clip" style="position:absolute;left:5px;top:7px;width:44px;height:36px;overflow:hidden;border-radius:8px"><canvas id="clipped" width="64" height="48" style="position:absolute;left:-3px;top:-2px"></canvas></div>';window.c=document.getElementById('clipped');window.ctx=c.getContext('2d');ctx.fillStyle='rgba(240,30,80,.7)';ctx.fillRect(0,0,64,48)`);
82
+ for(const dpr of [1,1.5,2]){
83
+ r.resize(64,48,dpr);
84
+ for(const position of ['left:-3px;top:-2px','left:3.25px;top:2.5px']){
85
+ await r.evaluate(`c.style.cssText='position:absolute;${position}';ctx.fillStyle='rgba(30,220,80,.7)';ctx.fillRect(1,1,5,5)`);
86
+ traces.length=0;await r.evaluate("ctx.fillRect(2,2,2,2)");const clipped=await pixels();
87
+ const inside=(Math.round(15*dpr)*Math.round(64*dpr)+Math.round(15*dpr))*4;
88
+ assert.notDeepEqual([...clipped.slice(inside,inside+4)],[25,106,179,255],'clipped Canvas content is painted, not just its background');
89
+ assert.ok(traces.some(s=>s.includes('direct canvas composition')),'clipped Canvas retains direct composition');
90
+ assert.ok(!traces.some(s=>s.includes('DOM layers rebuilt')),'clipped Canvas updates reuse DOM layers');
91
+ await r.evaluate("c.style.transform='translate(0px,0px)'");
92
+ assert.deepEqual(await pixels(),clipped,`rounded overflow clip at DPR ${dpr}, ${position} matches Skia`);
93
+ }
94
+ }
95
+ console.log('direct canvas composition passed');
96
+ }finally{r.dispose()}
@@ -0,0 +1,8 @@
1
+ <!doctype html><meta charset="utf-8"><title>JavaScript dialogs</title>
2
+ <style>body{font:20px sans-serif;padding:24px}button{font:inherit;margin:8px;padding:12px}#result{white-space:pre-wrap}</style>
3
+ <h1>Browser-owned dialogs</h1>
4
+ <button onclick="alert(1+2)">alert(1+2)</button>
5
+ <button onclick="alert(Array.from({length:200},(_,i)=>'Line '+(i+1)+' — 可以選取、複製和捲動的訊息').join('\n'))">Long selectable alert</button>
6
+ <button onclick="document.getElementById('result').textContent='confirm: '+confirm('Continue?')">Confirm</button>
7
+ <button onclick="document.getElementById('result').textContent='prompt: '+JSON.stringify(prompt('Your name','預設文字'))">Prompt</button>
8
+ <pre id="result">Try Enter, Escape, Tab, mouse selection, copying, scrolling and resizing.</pre>