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,156 @@
1
+ # Classic-Games and Hextris compatibility checks
2
+
3
+ Run these commands from the Buninu Browser repository. No npm packages or
4
+ native additions are needed. Games stay in a separate checkout, outside the
5
+ browser package:
6
+
7
+ ```sh
8
+ git clone --no-hardlinks https://github.com/KoRifCan/Classic-Games.git ../Classic-Games
9
+ ```
10
+
11
+ The tested upstream revision is
12
+ `83f36f3687814c1f8e7b79f581f6fc69da72ba5e`.
13
+
14
+ ## Upstream game fixes
15
+
16
+ `0001-fix-game-initialization.patch` fixes six bugs in the game sources,
17
+ separately from browser compatibility. Apply it once:
18
+
19
+ ```sh
20
+ git -C ../Classic-Games apply ../buninu-browser/test/classic-games-patches/0001-fix-game-initialization.patch
21
+ ```
22
+
23
+ - Space Shooter registers a `mousemove` listener before assigning `canvas`.
24
+ Move registration after canvas initialization. The original also throws
25
+ in Chromium.
26
+ - 15-Puzzle's `findTile()` returns `{r,c}`, but callers use `row/col`.
27
+ Return the names those callers expect; the original shuffle cannot finish.
28
+ - Minesweeper advertises right-click flagging, but its `contextmenu` handler
29
+ only prevents the menu. Connect that handler to its existing `toggleFlag()`;
30
+ Ctrl/Meta-click and touch flagging retain their existing paths.
31
+ - Snake uses direction objects as ordinary object keys, so all four entries
32
+ collide as `"[object Object]"` and left turns are always rejected. Use a
33
+ `Map` and compare against the last committed direction; rapid inputs must
34
+ not queue a reversal into the body before the next movement tick.
35
+
36
+ - Typing Test handles P/M shortcuts on `document` even while editing its
37
+ text field, cancelling ordinary typing. Ignore letter shortcuts in editable
38
+ controls while preserving Escape pause/resume and shortcuts outside them.
39
+
40
+ - Breakout limits mouse-follow movement to 8 pixels per animation frame,
41
+ making the paddle lag at low FPS. Use 480 pixels per second, preserving
42
+ its 60 Hz speed; cap elapsed time at 100 ms to avoid a large resume jump.
43
+ Ball and particle physics retain their existing behavior. Cache the dot
44
+ background and brick shadows/gradients in an offscreen Canvas; invalidate
45
+ it on board initialization and each destroyed brick, retaining the same pixels.
46
+
47
+ These patches never replace browser behavior with a game-specific exception.
48
+ The hosted upstream originals will still contain their own bugs until their
49
+ maintainer fixes them.
50
+
51
+ ## Run all sixteen desktop scenarios
52
+
53
+ ```sh
54
+ bun test/manual-classic-games.js ../Classic-Games buninu /tmp/classic-games
55
+ # Optional reference run, if Chromium headless shell is installed:
56
+ bun test/manual-classic-games.js ../Classic-Games chromium /tmp/classic-games-chromium
57
+ ```
58
+
59
+ To browse the local patched games interactively through the default Casty frontend:
60
+
61
+ ```sh
62
+ bun index.js file://"$PWD/../Classic-Games/index.html"
63
+ ```
64
+
65
+ The runner serves the checkout on an automatically assigned loopback port,
66
+ launches the browser, and closes its server and browser when finished. It
67
+ writes `report.json` and initial/played PNG screenshots per game. A failed
68
+ scenario, page error, or CDP exception causes a nonzero exit status. Restrict
69
+ a diagnostic run with `CLASSIC_GAMES=memory,simon` if needed.
70
+
71
+ The browser remains in its default SES/seccomp configuration. No host-JS
72
+ bypass, game physics changes, score assignment, or game-state function calls
73
+ are used. Flappy Bird's Canvas text, Snake's rendered head-eye positions, and Simon's light sequence are observed
74
+ through bounded text logging / a MutationObserver; these record output and
75
+ do not change game decisions.
76
+
77
+ | Game | Interaction verified |
78
+ |---|---|
79
+ | Tetris | Move/rotate, hard-drop score, pause/resume input |
80
+ | Snake | All four directions turn correctly; direct reversal is rejected |
81
+ | 2048 | Move/spawn tiles and undo restores the board |
82
+ | Flappy Bird | Space starts, draws the score, and a second flap remains alive |
83
+ | Space Shooter | Start/fire and movement change the canvas |
84
+ | Tic Tac Toe | Human move, AI response, new game clears marks |
85
+ | Memory | Flip a card, match a pair; hidden backs render correctly |
86
+ | Minesweeper | Set/remove a right-click flag and reveal cells |
87
+ | Pong | Start and paddle/ball updates |
88
+ | Breakout | Start, move paddle, launch ball |
89
+ | Dino Runner | Start and jump/runner animation |
90
+ | Sudoku | Start, toggle pencil notes, fill a hint |
91
+ | Connect 4 | Seven drops produce a four-disc win and update the round score |
92
+ | Typing Test | Type/advance words; P/M remain input; Escape pauses and resumes |
93
+ | Simon Says | Observe the first light, enter it, advance to round two |
94
+ | 15-Puzzle | Render fifteen tiles and slide an adjacent tile |
95
+
96
+ This is a functional compatibility check, not a completed campaign for each
97
+ game or an end-to-end Casty/Kitty FPS benchmark. Sudoku's synchronous puzzle
98
+ generation can take noticeably longer in the instrumented JS environment;
99
+ the manual runner gives observations enough time to finish.
100
+
101
+ ## Breakout paddle timing
102
+
103
+ ```sh
104
+ bun test/manual-breakout-paddle.js ../Classic-Games buninu
105
+ bun test/manual-breakout-paddle.js ../Classic-Games chromium
106
+ bun test/manual-breakout-static.js ../Classic-Games buninu
107
+ bun test/manual-breakout-static.js ../Classic-Games chromium
108
+ ```
109
+
110
+ This separate timing check supplies controlled RAF timestamps to the actual
111
+ game. At 15, 60 and 120 FPS, 400 ms must move its paddle 192 pixels. A
112
+ one-second frame gap must contribute at most 100 ms of catch-up movement.
113
+ The static-layer check compares the initial Canvas pixels and the pixels
114
+ after destroying a brick with the unpatched upstream source from the checkout's
115
+ HEAD. They must match exactly while idle brick draws fall from 60 to zero.
116
+ The ordinary sixteen-game runner above still uses real-time CDP input.
117
+
118
+ ## Hextris
119
+
120
+ ```sh
121
+ env NODE_TLS_REJECT_UNAUTHORIZED=0 bun test/manual-hextris.js buninu /tmp/hextris
122
+ # Reference; the helper also forwards the opt-out to Chromium's TLS flag:
123
+ env NODE_TLS_REJECT_UNAUTHORIZED=0 bun test/manual-hextris.js chromium /tmp/hextris-chromium
124
+ ```
125
+
126
+ The TLS environment variable is only needed if this site's certificate fails
127
+ validation on your system. The test checks a mouse-start, both arrow rotations,
128
+ pause/resume, and generated blocks, then saves `playing.png` and `report.json`.
129
+ Production fixes are HTML namespace-aware SVG/RCDATA parsing and preservation
130
+ of automatic semicolon insertion before member-access rewriting. No Hextris
131
+ source patch is required.
132
+
133
+ ## Generic browser changes exercised
134
+
135
+ - Canvas `roundRect()` and a page-local `CanvasRenderingContext2D` prototype,
136
+ so native feature detection and prototype extensions work.
137
+ - CPU Web Audio biquad filters and fractional AudioBuffer length conversion;
138
+ filter tests verify both frequency response and actual filtered PCM.
139
+ - Orthographic `rotateX`/`rotateY` projection and backface hiding across
140
+ `preserve-3d` card faces. This is not full perspective/3D CSS support.
141
+ - Fractional Grid row tracks use a definite height, including aspect-ratio
142
+ sizing, so empty Simon quadrants get a usable painted and clickable area.
143
+ - SVG elements do not enter HTML RCDATA parsing; ASI boundaries survive
144
+ member-access rewriting before subsequent parsing.
145
+ - Unsupported scroll-driven animation properties remain absent from feature
146
+ detection even though ordinary CSS animation aliases now work.
147
+
148
+ ## Recorded result
149
+
150
+ All sixteen desktop scenarios passed in one default-sandbox run on this
151
+ checkout, with no recorded page errors or CDP exceptions. The associated
152
+ browser regression run passed 272 tests across fourteen files; eight additional
153
+ piano mapping/input tests also passed. Screenshots and
154
+ per-game evidence from this session are in `/tmp/classic-games-final-regression/`;
155
+ that temporary directory is not a packaged artifact. Re-running the command
156
+ above recreates equivalent reports in your chosen output directory.
@@ -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,26 @@
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
+ await action({kind:'select',index:0});await action({kind:'select',index:1});await action({kind:'open'});
20
+ for(let i=0;i<100;i++){if(await c.evaluate('window.text!==undefined'))break;await Bun.sleep(10);}
21
+ expect(await c.evaluate('clickReturned')).toBe(true);expect(await c.evaluate('names')).toEqual(['one.txt','two.txt']);
22
+ expect(await c.evaluate('text')).toBe('Hello 選檔');expect(await c.evaluate('events')).toEqual(['input','change','load']);
23
+ expect(await c.evaluate('f.value')).toBe('C:\\fakepath\\one.txt');
24
+ expect(await c.evaluate('f.value="";({length:f.files.length,value:f.value})')).toEqual({length:0,value:''});
25
+ }finally{c.close();server.stop(true);b.close();await rm(dir,{recursive:true,force:true});}
26
+ },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)");