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,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>
@@ -0,0 +1,65 @@
1
+ // Isolate host scripting globals and model the decoder's bounded session pool.
2
+ import {strict as assert} from 'node:assert';
3
+ import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
4
+
5
+ let active=0,opened=0,closed=0;
6
+ const outputs=[],errors=[];
7
+ const renderer=await createPageRenderer({
8
+ fetchResource:async url=>({ok:true,contentType:'audio/mpeg',body:new Uint8Array([url.includes('background')?1:2])}),
9
+ reportError:error=>errors.push(String(error)),
10
+ videoDecoder:{
11
+ probe:async bytes=>({duration:bytes[0]===1?100:0.1,audio:{sampleRate:1000}}),
12
+ async decodeAudio(bytes){
13
+ if(active>=8)throw Error('audio decoder session pool exhausted');
14
+ active++;opened++;
15
+ let remaining=bytes[0]===1?100000:100,disposed=false;
16
+ return {sampleRate:1000,channels:1,
17
+ nextSamples(){assert.equal(disposed,false);if(!remaining)return null;const size=Math.min(100,remaining);remaining-=size;return new Float32Array(size).fill(.25);},
18
+ close(){assert.equal(disposed,false,'each decoder closes once');disposed=true;active--;closed++;},
19
+ };
20
+ },
21
+ },
22
+ audio:{
23
+ open(options,listener){outputs.push({listener,samples:0});return outputs.length;},
24
+ write(id,bytes){outputs[id-1].samples+=bytes.length/2;},
25
+ finish(id){queueMicrotask(()=>outputs[id-1].listener({event:'ended'}));},
26
+ close(id){return Promise.resolve(outputs[id-1].samples*2);},
27
+ },
28
+ });
29
+ const evaluate=source=>renderer.evaluate(source);
30
+ const waitFor=async predicate=>{for(let i=0;i<100;i++){if(await predicate())return;await Bun.sleep(5);}assert.fail('audio did not finish');};
31
+ async function finishSound(){
32
+ const output=outputs.at(-1);
33
+ output.listener({event:'request',bytes:1000});
34
+ await waitFor(()=>evaluate('effect.ended'));
35
+ assert.equal(output.samples,100);
36
+ assert.equal(await evaluate('effect.currentTime'),.1);
37
+ assert.equal(await evaluate('effect.error'),null);
38
+ assert.equal(await evaluate('background.paused'),false);
39
+ assert.equal(active,1,'only the playing background decoder remains after each sound ends');
40
+ }
41
+ try{
42
+ renderer.setScripting('host');
43
+ await renderer.loadDocument({url:'https://audio-lifetime.test/',contentType:'text/html',source:'<body></body>'});
44
+ await evaluate(`globalThis.background=new Audio('background.mp3');background.play()`);
45
+ assert.equal(active,1);
46
+ // Games can retain dozens of short-lived Audio objects without asking them
47
+ // to release a decoder explicitly. Keeping those DOM objects is harmless.
48
+ await evaluate('globalThis.saved=[]');
49
+ for(let i=0;i<12;i++){
50
+ await evaluate(`globalThis.effect=new Audio('effect.mp3');saved.push(effect);effect.play()`);
51
+ assert.equal(active,2);
52
+ await finishSound();
53
+ }
54
+ // Released decoders must be reconstructed for replay and for a seek.
55
+ await evaluate('effect.play()');await finishSound();
56
+ await evaluate('effect.currentTime=0');
57
+ await waitFor(()=>evaluate('effect.readyState>=2'));
58
+ await evaluate('effect.play()');await finishSound();
59
+ assert.equal(opened,15);
60
+ assert.equal(closed,14);
61
+ assert.deepEqual(errors,[]);
62
+ process.stdout.write('audio decoder lifetime integration passed\n');
63
+ }finally{renderer.dispose();}
64
+ assert.equal(active,0,'navigation/disposal releases the background too');
65
+ assert.equal(closed,opened);
@@ -8,18 +8,17 @@ import { createPageRenderer, loadGraphics } from '../../lib/renderer/page-render
8
8
  const { CanvasKit: kit } = await loadGraphics();
9
9
  lockdown({ errorTaming: 'safe', overrideTaming: 'severe', consoleTaming: 'unsafe',
10
10
  localeTaming: 'unsafe', stackFiltering: 'concise', reporting: 'none' });
11
- const malloc = kit._malloc, free = kit._free, makeSurface = kit.MakeSurface;
11
+ const malloc = kit._malloc, free = kit._free, makeSurface = kit.MakeSurface, makeRasterDirectSurface = kit.MakeRasterDirectSurface;
12
12
  const allocations = new Map(), surfaces = new Set(), images = new Set();
13
13
  const errors = [];
14
14
  let reads = 0, failAt = Infinity;
15
15
  kit._malloc = size => { const pointer = malloc(size); allocations.set(pointer, size); return pointer; };
16
16
  kit._free = pointer => { allocations.delete(pointer); return free(pointer); };
17
- kit.MakeSurface = (...args) => {
18
- const surface = makeSurface(...args);
17
+ const trackSurface = surface => {
19
18
  if (!surface) return surface;
20
19
  surfaces.add(surface);
21
- const dispose = surface.dispose.bind(surface), snapshot = surface.makeImageSnapshot.bind(surface);
22
- surface.dispose = () => { surfaces.delete(surface); return dispose(); };
20
+ const releaseSurface = surface.delete.bind(surface), snapshot = surface.makeImageSnapshot.bind(surface);
21
+ surface.delete = () => { surfaces.delete(surface); return releaseSurface(); };
23
22
  surface.makeImageSnapshot = (...args) => {
24
23
  const image = snapshot(...args), release = image.delete.bind(image), read = image.readPixels.bind(image);
25
24
  images.add(image);
@@ -32,6 +31,8 @@ kit.MakeSurface = (...args) => {
32
31
  };
33
32
  return surface;
34
33
  };
34
+ kit.MakeSurface = (...args) => trackSurface(makeSurface(...args));
35
+ kit.MakeRasterDirectSurface = (...args) => trackSurface(makeRasterDirectSurface(...args));
35
36
  const liveBytes = () => [...allocations.values()].reduce((sum, size) => sum + size, 0);
36
37
  let warmBytes;
37
38
  try {
@@ -107,4 +108,5 @@ try {
107
108
  kit._malloc = malloc;
108
109
  kit._free = free;
109
110
  kit.MakeSurface = makeSurface;
111
+ kit.MakeRasterDirectSurface = makeRasterDirectSurface;
110
112
  }
@@ -7,9 +7,9 @@ import {captureViewport} from '../../lib/paint/frame.js';
7
7
  const {CanvasKit:kit} = await loadGraphics();
8
8
  lockdown({errorTaming:'safe',overrideTaming:'severe',consoleTaming:'unsafe',localeTaming:'unsafe',stackFiltering:'concise',reporting:'none'});
9
9
  let readbacks = 0, checks = 0;
10
- const makeSurface = kit.MakeSurface;
11
- kit.MakeSurface = (...args) => {
12
- const surface = makeSurface(...args), snapshot = surface.makeImageSnapshot.bind(surface);
10
+ const makeSurface = kit.MakeSurface, makeRasterDirectSurface=kit.MakeRasterDirectSurface;
11
+ const trackSurface = surface => {
12
+ const snapshot = surface.makeImageSnapshot.bind(surface);
13
13
  surface.makeImageSnapshot = () => {
14
14
  const image = snapshot(), read = image.readPixels.bind(image);
15
15
  image.readPixels = (...args) => { readbacks++; return read(...args); };
@@ -17,6 +17,8 @@ kit.MakeSurface = (...args) => {
17
17
  };
18
18
  return surface;
19
19
  };
20
+ kit.MakeSurface=(...args)=>trackSurface(makeSurface(...args));
21
+ kit.MakeRasterDirectSurface=(...args)=>trackSurface(makeRasterDirectSurface(...args));
20
22
  const source = `<style>body{margin:0;background:#182331;color:white;height:500px}
21
23
  #wrap{position:absolute;left:12px;top:14px;width:160px;height:90px;overflow:hidden;border-radius:13px}
22
24
  video{display:block;width:160px;height:90px}#caption{position:absolute;left:5px;top:55px;background:#6548;font:12px sans-serif}
@@ -116,9 +118,9 @@ for (const alpha of [255,127]) {
116
118
  await repaint('incomplete-shared-frame',"document.getElementById('marker').style.background='lime'");
117
119
  seed(candidate,alpha,4);
118
120
  // Controls alone currently need a separate layout invalidation to refresh
119
- // their display-list bit, so force geometry here to exercise the fallback.
120
- await repaint('controls',"document.getElementById('v').controls=true;document.getElementById('marker').style.width='26px'",false);
121
- assert.equal(candidate.tile.videoLayer,null);
121
+ // their display-list bit, so force geometry here to exercise the layer.
122
+ await repaint('controls',"document.getElementById('v').controls=true;document.getElementById('marker').style.width='26px'");
123
+ assert.ok(candidate.tile.videoLayer,'controls retain independent video presentation');
122
124
  await mutate("document.getElementById('v').controls=false;document.getElementById('marker').style.width='27px'");
123
125
  for (const client of clients) await layer(client);seed(candidate,alpha);
124
126
  await repaint('controls-restored',"document.getElementById('marker').style.background='cyan'");
@@ -0,0 +1,39 @@
1
+ import {strict as assert} from 'node:assert';
2
+ import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
3
+ let decoded=0;const errors=[];
4
+ const renderer=await createPageRenderer({
5
+ fetchResource:async()=>({ok:true,contentType:'video/mp4',body:new Uint8Array([1])}),
6
+ reportError:e=>errors.push(String(e)),
7
+ videoDecoder:{
8
+ probe:async()=>({duration:.12,video:{width:2,height:2,fpsNum:100,fpsDen:1}}),
9
+ decode:async()=>{let index=0;return {width:2,height:2,fps:100,
10
+ seek(){index=0},close(){},nextFrame(width=2,height=2){
11
+ if(index===12)return null;
12
+ const end=performance.now()+25;while(performance.now()<end){}
13
+ index++;decoded=index;const pixels=new Uint8Array(width*height*4);
14
+ for(let i=0;i<pixels.length;i+=4){pixels[i]=index*20;pixels[i+3]=255}return pixels;
15
+ }}},
16
+ },
17
+ });
18
+ try{
19
+ renderer.setScripting('host');
20
+ await renderer.loadDocument({url:'https://slow-video.test/',contentType:'text/html',source:'<video id="v" src="slow.mp4" controls></video>'});
21
+ await renderer.evaluate(`globalThis.v=document.querySelector("video");
22
+ globalThis.positions=[];v.addEventListener('timeupdate',()=>{
23
+ if(v.ended)return;
24
+ const c=document.createElement('canvas');c.width=c.height=2;
25
+ const x=c.getContext('2d');x.drawImage(v,0,0,2,2);
26
+ positions.push([v.currentTime,x.getImageData(0,0,1,1).data[0]]);
27
+ });v.play()`);
28
+ for(let i=0;i<100&&!await renderer.evaluate('v.ended');i++)await Bun.sleep(10);
29
+ assert.equal(await renderer.evaluate('v.ended'),true);
30
+ assert.equal(decoded,12,'ended must wait for all frames of a slow silent decoder');
31
+ const last=await renderer.evaluate(`(()=>{const c=document.createElement('canvas');c.width=c.height=2;const x=c.getContext('2d');x.drawImage(v,0,0,2,2);return x.getImageData(0,0,1,1).data[0]})()`);
32
+ assert.equal(last,240,'the actual final frame must be retained at ended');
33
+ const positions=await renderer.evaluate('positions');
34
+ assert.ok(positions.length>0,'playback must report progress');
35
+ for(const [time,red] of positions)assert.ok(time<=(red/20-1)/100+1e-6,
36
+ `progress ${time} must not lead displayed frame ${red}`);
37
+ assert.deepEqual(errors,[]);
38
+ process.stdout.write('slow video end integration passed\n');
39
+ }finally{renderer.dispose()}
@@ -0,0 +1,45 @@
1
+ import assert from 'node:assert/strict';
2
+ import {createPageRenderer,loadGraphics} from '../../lib/renderer/page-renderer.js';
3
+
4
+ const mode=process.argv[2]??'ses';
5
+ if(mode==='ses'){
6
+ await import('../../vendor/ses/dist/ses.mjs');await loadGraphics();
7
+ lockdown({errorTaming:'safe',overrideTaming:'severe',consoleTaming:'unsafe',localeTaming:'unsafe',stackFiltering:'concise',reporting:'none'});
8
+ }
9
+ const outputs=[],closed=[],errors=[];
10
+ const renderer=await createPageRenderer({reportError:error=>errors.push(String(error)),audio:{
11
+ open(options,listener){outputs.push({options,listener,pcm:[]});return outputs.length;},
12
+ write(id,bytes){outputs[id-1].pcm.push(new Int16Array(bytes.buffer,bytes.byteOffset,bytes.byteLength/2).slice());},
13
+ close(id){closed.push(id);return Promise.resolve();},
14
+ }});
15
+ try{
16
+ renderer.setScripting(mode);
17
+ await renderer.loadDocument({url:'https://web-audio.test/',contentType:'text/html',source:'<body>Web Audio</body>'});
18
+ const result=await renderer.evaluate(`(()=>{
19
+ globalThis.c=new AudioContext({sampleRate:8000});globalThis.other=new webkitAudioContext({sampleRate:8000});
20
+ globalThis.osc=c.createOscillator();globalThis.gain=c.createGain();
21
+ osc.frequency.setValueAtTime(500,c.currentTime);gain.gain.value=.25;
22
+ osc.connect(gain).connect(c.destination);osc.start();globalThis.ends=0;osc.onended=()=>ends++;
23
+ return {context:c instanceof BaseAudioContext,node:osc instanceof AudioScheduledSourceNode,eventTarget:osc instanceof EventTarget,
24
+ param:osc.frequency instanceof AudioParam,alias:AudioContext===webkitAudioContext,name:AudioContext.name,
25
+ native:AudioContext.prototype.createOscillator.toString().includes('[native code]'),
26
+ output:osc.context===c&&gain.context===c,state:c.state};
27
+ })()`);
28
+ assert.deepEqual(result,{context:true,node:true,eventTarget:true,param:true,alias:true,name:'AudioContext',native:mode==='ses',output:true,state:'running'});
29
+ assert.equal(outputs.length,2);
30
+ assert.deepEqual(outputs[0].options,{rate:8000,channels:2,name:'Web Audio'});
31
+ outputs[0].listener({event:'request',bytes:512});
32
+ assert.ok(Math.abs(outputs[0].pcm[0][8]-8192)<=1,'real page graph produces the expected sine samples');
33
+ assert.equal(await renderer.evaluate('c.currentTime'),.016);
34
+ await renderer.evaluate('c.suspend()');assert.deepEqual(closed,[1]);
35
+ await renderer.evaluate('c.resume()');assert.equal(outputs.length,3);
36
+ await renderer.evaluate('osc.stop()');outputs[2].listener({event:'request',bytes:512});
37
+ await Bun.sleep(30);assert.equal(await renderer.evaluate('ends'),1);
38
+ assert.ok(outputs[2].pcm[0].every(x=>x===0));
39
+ await renderer.loadDocument({url:'https://web-audio.test/next',contentType:'text/html',source:'<p>next</p>'});
40
+ assert.deepEqual(closed.sort(),[1,2,3],'navigation closes every document-owned context');
41
+ const count=outputs[2].pcm.length;outputs[2].listener({event:'request',bytes:512});
42
+ assert.equal(outputs[2].pcm.length,count,'stale requests cannot revive an old graph');
43
+ assert.deepEqual(errors,[]);
44
+ process.stdout.write(`${mode} Web Audio page PCM and disposal passed\n`);
45
+ }finally{renderer.dispose();}
package/test/flex.test.js CHANGED
@@ -159,3 +159,29 @@ test("justify-content distributes a column container's extra height (min-height
159
159
  done();
160
160
  }
161
161
  });
162
+
163
+ test('reverse flex directions reflect items without reversing descendants',()=>{
164
+ for(const direction of ['row-reverse','column-reverse']){
165
+ const column=direction.startsWith('column');
166
+ const f=flex('<div id="root"><div id="a"><div id="nested"></div></div><div id="b"></div></div>',
167
+ `#root{display:flex;flex-flow:${direction} nowrap;width:100px;height:100px;gap:5px} #a,#b{width:20px;height:20px;flex-shrink:0} #nested{width:5px;height:5px}`);
168
+ try{
169
+ const a=f.rect('#a'),b=f.rect('#b'),n=f.rect('#nested');
170
+ expect(column?a.top:a.left).toBe(80);
171
+ expect(column?b.top:b.left).toBe(55);
172
+ expect(n.top).toBe(a.top);expect(n.left).toBe(a.left);
173
+ }finally{f.done()}
174
+ }
175
+ });
176
+
177
+ test('column-reverse resolves intrinsic height, spacing and physical margins',()=>{
178
+ const f=flex('<div id="root"><div id="a"></div><div id="b"></div></div>',
179
+ '#root{display:flex;flex-direction:column-reverse;width:30px;gap:5px} #a{height:10px;margin-top:3px;margin-bottom:7px} #b{height:20px}');
180
+ try{expect(f.rect('#root').height).toBe(45);expect(f.rect('#a').top).toBe(28);expect(f.rect('#b').top).toBe(0)}finally{f.done()}
181
+ });
182
+
183
+ test('column-reverse preserves the sign of child transform translations',()=>{
184
+ const f=flex('<div id="root"><div id="a"></div><div id="b"></div></div>',
185
+ '#root{display:flex;flex-direction:column-reverse;width:40px;height:100px} #a,#b{height:20px} #a{transform:translateY(-30px)}');
186
+ try{expect(f.rect('#a').top).toBe(50);expect(f.rect('#b').top).toBe(60)}finally{f.done()}
187
+ });
package/test/grid.test.js CHANGED
@@ -309,3 +309,16 @@ test("an item stretches to its area by default and justify-items can center it",
309
309
  expect(render(grid("grid-template-columns: 8px; justify-items: center", "<i>ab</i>")).item(0))
310
310
  .toEqual({ left: 3, top: 0, width: 2, height: 1 });
311
311
  });
312
+
313
+ test('fractional rows fill a definite aspect-ratio grid even when cells are empty',()=>{
314
+ const {items}=render(grid('width:100px;aspect-ratio:1;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:8px', '<i></i><i></i><i></i><i></i>'),100);
315
+ expect(items()).toEqual([
316
+ {left:0,top:0,width:46,height:46},{left:54,top:0,width:46,height:46},
317
+ {left:0,top:54,width:46,height:46},{left:54,top:54,width:46,height:46},
318
+ ]);
319
+ });
320
+
321
+ test('fractional row sizing respects fixed tracks and intrinsic minimum heights',()=>{
322
+ const {items}=render(grid('width:40px;height:100px;grid-template-columns:40px;grid-template-rows:20px 1fr 2fr;row-gap:10px','<i></i><i></i><i></i>'),40);
323
+ expect(items().map(item=>item.height)).toEqual([20,20,40]);
324
+ });
@@ -224,6 +224,15 @@ test("top-level shell exposes the parsed document through Bun.WebView", async ()
224
224
  await view.cdp('Input.dispatchKeyEvent', {type,key:' ',code:'Space',windowsVirtualKeyCode:32,text:type==='char'?' ':undefined});
225
225
  }
226
226
  }
227
+ await view.evaluate(`window.heldKey=false;window.heldEvents=[];
228
+ document.addEventListener('keydown',e=>{if(e.code==='KeyW'){heldKey=true;heldEvents.push('down');e.preventDefault()}});
229
+ document.addEventListener('keyup',e=>{if(e.code==='KeyW'){heldKey=false;heldEvents.push('up')}});`);
230
+ await view.cdp('Input.dispatchKeyEvent',{type:'keyDown',key:'w',code:'KeyW',windowsVirtualKeyCode:87});
231
+ expect(await view.evaluate('heldKey')).toBe(true);
232
+ expect(await view.evaluate('new Promise(resolve=>requestAnimationFrame(()=>resolve(heldKey)))')).toBe(true);
233
+ await view.cdp('Input.dispatchKeyEvent',{type:'keyUp',key:'w',code:'KeyW',windowsVirtualKeyCode:87});
234
+ expect(await view.evaluate('heldKey')).toBe(false);
235
+ expect(await view.evaluate('heldEvents')).toEqual(['down','up']);
227
236
  expect(await view.evaluate('spaceKeys')).toEqual(Array.from({length:6},()=>[32,32]));
228
237
  expect(await view.evaluate('scrollY')).toBe(0);
229
238
  await view.cdp("Input.dispatchMouseEvent", {
@@ -0,0 +1,28 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {parseWindowSize} from '../lib/window-size.js';
3
+ import {CDP} from '../tools/browser-lab.js';
4
+ test('window size defaults and validation',()=>{
5
+ expect(parseWindowSize([])).toEqual({width:800,height:600});
6
+ expect(parseWindowSize(['--window-size=1920,1080'])).toEqual({width:1920,height:1080});
7
+ expect(parseWindowSize(['--window-size=640x480'])).toEqual({width:640,height:480});
8
+ for(const size of ['', '0,600','-1,600','800,','800,600,1','1.5,600','9007199254740992,600'])
9
+ expect(()=>parseWindowSize(['--window-size='+size])).toThrow('positive integers');
10
+ });
11
+ test('persistent headless CLI size reaches renderer, CDP targets and captures; CDP can override it',async()=>{
12
+ const child=Bun.spawn([process.execPath,'index.js','--headless','--window-size=960,540','--remote-debugging-port=0'],{cwd:new URL('..',import.meta.url).pathname,stdout:'ignore',stderr:'pipe'});
13
+ let client;const reader=child.stderr.getReader();
14
+ const deadline=setTimeout(()=>child.kill(),20000);let log='';
15
+ try{
16
+ let match;
17
+ while(!(match=log.match(/DevTools listening on (ws:\/\/\S+)/))){const {value,done}=await reader.read();if(done)throw Error(log);log+=new TextDecoder().decode(value);}
18
+ client=new CDP(match[1]);await client.ready;
19
+ const {targetId}=await client.send('Target.createTarget',{url:'about:blank'});
20
+ const {sessionId}=await client.send('Target.attachToTarget',{targetId,flatten:true});client.sessionId=sessionId;
21
+ const target=await client.send('Target.getTargetInfo',{targetId});expect(target.targetInfo.viewport).toMatchObject({width:960,height:540});
22
+ const metrics=await client.send('Page.getLayoutMetrics');expect(metrics.cssVisualViewport).toMatchObject({clientWidth:960,clientHeight:540});
23
+ const capture=await client.send('Page.captureScreenshot',{format:'png'});const png=Buffer.from(capture.data,'base64');
24
+ expect([png.readUInt32BE(16),png.readUInt32BE(20)]).toEqual([960,540]);
25
+ await client.send('Emulation.setDeviceMetricsOverride',{width:640,height:360,deviceScaleFactor:1,mobile:false});
26
+ const resized=await client.send('Page.getLayoutMetrics');expect(resized.cssVisualViewport).toMatchObject({clientWidth:640,clientHeight:360});
27
+ }finally{clearTimeout(deadline);client?.close();reader.releaseLock();child.kill();await child.exited;}
28
+ },25000);
@@ -0,0 +1,35 @@
1
+ import {test, expect} from 'bun:test';
2
+ import {parseHTMLDocument} from '../lib/happy-dom/parser.js';
3
+
4
+ test('textarea preserves embedded markup as text and decodes character references', () => {
5
+ const {document} = parseHTMLDocument('<textarea>\nconst html = `<div>&amp;<br></div>`;<!-- literal -->\n</TEXTAREA><p>after</p>');
6
+ const area = document.querySelector('textarea');
7
+ expect(area.value).toBe('const html = `<div>&<br></div>`;<!-- literal -->\n');
8
+ expect(area.children.length).toBe(0);
9
+ expect(document.querySelector('p').textContent).toBe('after');
10
+ });
11
+
12
+ test('RCDATA ends only at matching end tag, retaining all content through EOF', () => {
13
+ const {document} = parseHTMLDocument('<textarea>a</textareax><b>c &lt;d\0');
14
+ expect(document.querySelector('textarea').value).toBe('a</textareax><b>c <d\uFFFD');
15
+ const other = parseHTMLDocument('<title>A <b>B</b> &amp; C</title><textarea>&#10;hello</textarea>').document;
16
+ expect(other.title).toBe('A <b>B</b> & C');
17
+ expect(other.querySelector('textarea').value).toBe('hello');
18
+ });
19
+
20
+ test('textarea parsing also works inside HTML fragments without changing script raw text', () => {
21
+ const {document} = parseHTMLDocument('');
22
+ document.body.innerHTML='<textarea><div>&amp;</div></textarea><script>"<div>&amp;</div>"</script>';
23
+ expect(document.querySelector('textarea').value).toBe('<div>&</div>');
24
+ expect(document.querySelector('script').textContent).toBe('"<div>&amp;</div>"');
25
+ });
26
+
27
+ test('SVG title remains foreign content and does not swallow following SVG or HTML siblings',()=>{
28
+ const {document,window}=parseHTMLDocument('<div><svg><title>Share &amp; score</title><g><text><tspan>label</tspan></text></g></svg><button id="after">play</button></div>');
29
+ try{
30
+ expect(document.querySelector('svg title').textContent).toBe('Share & score');
31
+ expect(document.querySelector('svg g tspan').textContent).toBe('label');
32
+ expect(document.getElementById('after').textContent).toBe('play');
33
+ expect(document.querySelector('svg title').namespaceURI).toBe('http://www.w3.org/2000/svg');
34
+ }finally{window.happyDOM.close();}
35
+ });
@@ -0,0 +1,18 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {createBrowser} from '../lib/headless-shell.js';
3
+ import {CdpServer} from '../lib/cdp-server.js';
4
+ import {CDP} from '../tools/browser-lab.js';
5
+ const png=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a9ZkAAAAASUVORK5CYII=','base64');
6
+ test('image decode waits for real pixels, handles detached images and rejects broken or replaced requests',async()=>{
7
+ const server=Bun.serve({hostname:'127.0.0.1',port:0,async fetch(req){const path=new URL(req.url).pathname;if(path==='/'){return new Response('<img id="initial" src="/ok.png">',{headers:{'Content-Type':'text/html'}});}if(path==='/slow.png')await Bun.sleep(80);return path==='/bad'?new Response('broken',{status:404}):new Response(png,{headers:{'Content-Type':'image/png'}});}});
8
+ const browser=await createBrowser({spareRenderer:false}),cdpServer=CdpServer.create(browser.context).listen(0),c=new CDP(`ws://127.0.0.1:${cdpServer.port}/devtools/browser/cdp-server`);
9
+ try{
10
+ await c.send('Page.navigate',{url:server.url.href});
11
+ expect(await c.evaluate('(async()=>{await initial.decode();const c=document.createElement("canvas").getContext("2d");c.drawImage(initial,0,0);return [initial.complete,initial.naturalWidth,initial.naturalHeight,c.getImageData(0,0,1,1).data[3]]})()')).toEqual([true,1,1,255]);
12
+ expect(await c.evaluate('(async()=>{const im=new Image();const p=im.decode();im.src="/slow.png";await p;return [im.complete,im.naturalWidth]})()')).toEqual([true,1]);
13
+ expect(await c.evaluate('(async()=>{const im=new Image();im.src="/slow.png";await Promise.all([im.decode(),im.decode()]);let sync=true;const p=im.decode().then(()=>sync);sync=false;return await p})()')).toBe(false);
14
+ expect(await c.evaluate(`(async()=>{const im=new Image();im.src='/ok.png';await im.decode();const before=[im.width,im.height];im.width=7;im.height=9;const explicit=[im.width,im.height];im.removeAttribute('width');im.removeAttribute('height');im.style.cssText='width:30px;height:20px;padding:2px;border:3px solid black;transform:scale(2)';document.body.appendChild(im);const displayed=[im.width,im.height];im.style.display='none';return [before,explicit,displayed,[im.width,im.height]]})()`)).toEqual([[1,1],[7,9],[30,20],[1,1]]);
15
+ for(const src of['','/bad','http://['])expect(await c.evaluate(`(async()=>{const im=new Image();im.src=${JSON.stringify(src)};try{await im.decode();return 'resolved'}catch(e){return e.name}})()`)).toBe('EncodingError');
16
+ expect(await c.evaluate('(async()=>{const im=new Image();im.src="/slow.png";const p=im.decode().then(()=>"resolved",e=>e.name);await Promise.resolve();im.removeAttribute("src");return await p})()')).toBe('EncodingError');
17
+ }finally{c.close();cdpServer.stop(true);browser.close();server.stop(true);}
18
+ },30000);
@@ -4,6 +4,7 @@ import { castyCommand, chooseFrontend } from "../index.js";
4
4
  test("the first argument picks the frontend; anything else goes to casty whole", () => {
5
5
  expect(chooseFrontend(["--casty", "example.com", "--", "--mobile"])).toEqual({ frontend: "casty", args: ["example.com", "--", "--mobile"] });
6
6
  expect(chooseFrontend(["--headless", "--remote-debugging-port=9222"])).toEqual({ frontend: "headless", args: ["--remote-debugging-port=9222"] });
7
+ expect(chooseFrontend(["--win32", "example.com"])).toEqual({ frontend: "win32", args: ["example.com"] });
7
8
  expect(chooseFrontend(["--gtk", "x"])).toEqual({ frontend: "gtk", args: ["x"], reserved: true });
8
9
  expect(chooseFrontend(["github.com", "--", "--mobile"])).toEqual({ frontend: "casty", args: ["github.com", "--", "--mobile"] });
9
10
  expect(chooseFrontend(["--mobile"])).toEqual({ frontend: "casty", args: ["--mobile"] });