buninu-browser 0.4.0 → 0.5.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.
- package/README.md +22 -1
- package/frontends/wayland/README.md +50 -21
- package/frontends/wayland/index.js +14 -0
- package/frontends/wayland/input.js +24 -5
- package/frontends/wayland/urlbar.js +9 -5
- package/lib/audio/shared-connection.js +31 -0
- package/lib/cdp-server.js +15 -2
- package/lib/headless-shell.js +7 -4
- package/lib/paint/display-list.js +2 -1
- package/lib/qml/README.md +456 -0
- package/lib/qml/builtins.js +76 -5
- package/lib/qml/canvas.js +76 -0
- package/lib/qml/document.js +47 -24
- package/lib/qml/fonts.js +69 -0
- package/lib/qml/host.js +28 -0
- package/lib/qml/loader.js +7 -3
- package/lib/qml/models.js +1 -1
- package/lib/qml/reactive.js +26 -3
- package/lib/qml/runtime.js +263 -41
- package/lib/qml/shader/fragment.js +83 -0
- package/lib/qml/shader/qsb.js +27 -0
- package/lib/qml/shader/renderer.js +39 -0
- package/lib/qml/shader/runtime.js +61 -0
- package/lib/qml/shapes.js +9 -1
- package/lib/qml/shortcuts.js +65 -0
- package/lib/qml/textures.js +4 -1
- package/lib/qml/window-constraints.js +16 -0
- package/lib/renderer/bindings.js +5 -1
- package/lib/renderer/canvas-2d.js +16 -0
- package/lib/renderer/document-selection.js +19 -0
- package/lib/renderer/host.js +14 -11
- package/lib/renderer/main.js +2 -0
- package/lib/renderer/page-realm.js +4 -9
- package/lib/renderer/page-renderer.js +273 -35
- package/lib/renderer/pointer-move.js +5 -3
- package/lib/renderer/render-interval.js +9 -0
- package/lib/style/computed-style.js +8 -2
- package/package.json +1 -1
- package/test/audio-connection-lifetime.test.js +46 -0
- package/test/canvas-paint.test.js +8 -0
- package/test/computed-style.test.js +12 -0
- package/test/document-selection.test.js +27 -0
- package/test/double-click.test.js +85 -0
- package/test/fixtures/canvas-damage.js +40 -0
- package/test/fixtures/fake-renderer.js +1 -0
- package/test/fixtures/omastorm-helpers/Dockerfile +35 -0
- package/test/fixtures/omastorm-helpers/check.mjs +56 -0
- package/test/fixtures/omastorm-helpers/launch.mjs +24 -0
- package/test/fixtures/omastorm-helpers/prepare.mjs +17 -0
- package/test/fixtures/omastorm-helpers/runtime.mjs +32 -0
- package/test/fixtures/pointer-paint-batching.js +23 -0
- package/test/fixtures/qml-fonts/main.qml +10 -0
- package/test/fixtures/qml-locale/check.mjs +6 -0
- package/test/fixtures/qml-locale/main.qml +8 -0
- package/test/fixtures/qml-shader/grab.qml +14 -0
- package/test/fixtures/qml-shader/gradient.frag +5 -0
- package/test/fixtures/qml-shader/main.qml +14 -0
- package/test/fixtures/qml-shader/texture-grab.qml +25 -0
- package/test/fixtures/qml-shader/texture-size.frag +5 -0
- package/test/fixtures/qml-shader-check.mjs +24 -0
- package/test/fixtures/title-no-paint.js +20 -0
- package/test/manual-double-click.html +21 -0
- package/test/manual-mouse-buttons.html +18 -0
- package/test/manual-omastorm-engine.mjs +19 -0
- package/test/manual-omastorm-shaders.mjs +28 -0
- package/test/manual-omastorm-software.mjs +25 -0
- package/test/manual-pointer-scroll.html +43 -0
- package/test/pointer-drag.test.js +104 -0
- package/test/qml-canvas.test.js +21 -0
- package/test/qml-connections-timing.test.js +22 -0
- package/test/qml-controls-window.test.js +182 -0
- package/test/qml-fonts.test.js +31 -0
- package/test/qml-grid-multiline.test.js +48 -0
- package/test/qml-locale.test.js +45 -0
- package/test/qml-nonvisual-visible.test.js +19 -0
- package/test/qml-shader-runtime.test.js +6 -0
- package/test/qml-shortcuts.test.js +39 -0
- package/test/qml-socket.test.js +60 -0
- package/test/qml-software-shader.test.js +55 -0
- package/test/qml-window-update.test.js +36 -0
- package/test/render-interval.test.js +33 -0
- package/test/title-no-paint.test.js +6 -0
- package/test/wayland-scroll.test.js +40 -0
- package/test/wayland-urlbar.test.js +23 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Explicit experiment: copy the application and remove only its GPU policy.
|
|
2
|
+
// Does not alter the checkout, fake GraphicsInfo, or bypass shader/pixel checks.
|
|
3
|
+
import {mkdtemp,cp,readFile,writeFile} from 'node:fs/promises';
|
|
4
|
+
import {resolve} from 'node:path';
|
|
5
|
+
import {tmpdir} from 'node:os';
|
|
6
|
+
const root=resolve(process.argv[2]||'../omastorm');
|
|
7
|
+
const directory=await mkdtemp(resolve(tmpdir(),'omastorm-software-'));
|
|
8
|
+
await cp(root+'/ui',directory+'/ui',{recursive:true});
|
|
9
|
+
const replacements=[
|
|
10
|
+
['TextureCheck.qml', / if \(GraphicsInfo\.api === GraphicsInfo\.Software\) \{\n fail\("Radar requires GPU rendering \(OpenGL\/Vulkan\)\."\);\n return;\n \}\n/],
|
|
11
|
+
['RadarMap.qml', / Component\.onCompleted: if \(GraphicsInfo\.api === GraphicsInfo\.Software\) map\.graphicsError = "Radar requires GPU rendering \(OpenGL\/Vulkan\)\."\n/],
|
|
12
|
+
];
|
|
13
|
+
for(const [file,pattern] of replacements){
|
|
14
|
+
const path=directory+'/ui/'+file,source=await readFile(path,'utf8');
|
|
15
|
+
if(!pattern.test(source))throw Error('Upstream policy changed; review '+file+' before modifying it');
|
|
16
|
+
await writeFile(path,source.replace(pattern,''));
|
|
17
|
+
}
|
|
18
|
+
console.log('Software test copy: '+directory);
|
|
19
|
+
console.log('Original engine must already be running in XDG_RUNTIME_DIR/omastorm.');
|
|
20
|
+
if(process.argv.includes('--prepare-only'))process.exit(0);
|
|
21
|
+
const child=Bun.spawn([process.execPath,new URL('../lib/qml/qswayland',import.meta.url).pathname,'-p',directory+'/ui/shell.qml'],{
|
|
22
|
+
env:{...process.env,OMASTORM_ROOT:root},stdin:'inherit',stdout:'inherit',stderr:'inherit',
|
|
23
|
+
});
|
|
24
|
+
for(const signal of ['SIGINT','SIGTERM','SIGHUP'])process.on(signal,()=>child.kill(signal));
|
|
25
|
+
process.exitCode=await child.exited;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-Hant">
|
|
3
|
+
<meta charset="utf-8">
|
|
4
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
5
|
+
<title>滑鼠與捲動測試</title>
|
|
6
|
+
<style>
|
|
7
|
+
*{box-sizing:border-box}body{margin:0;padding:16px;background:#101923;color:#e4edf5;font:16px sans-serif}h1{font-size:24px}h2{font-size:18px;margin:10px 0}section{padding:14px;margin:16px 0;background:#1c2a38;border:1px solid #446079;border-radius:8px}button,textarea{font:inherit}button{padding:10px;margin:4px;background:#bcebea;color:#102c35;border:0;border-radius:4px}code,pre{font:13px monospace}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:240px;overflow:auto}p{line-height:1.5}.box{width:100%;height:220px;border:2px solid #69c7dd;background:#172431}.v{overflow-x:hidden;overflow-y:auto}.h{overflow-x:auto;overflow-y:hidden}.both{overflow:auto}.wide{width:1800px;height:180px;background:linear-gradient(90deg,#375f8b,#426e4d,#9c4f49);padding:20px}.plane{width:1800px;height:1200px;background:repeating-linear-gradient(0deg,transparent,transparent 99px,#7cabc4 100px),repeating-linear-gradient(90deg,#263d52,#263d52 99px,#7cabc4 100px);padding:20px}.row{padding:18px;border-bottom:1px solid #436479}.readout{color:#8de1e8;margin:8px 0}#target{display:block;width:100%;min-height:100px;background:#286a75;color:white}canvas{display:block;width:100%;height:220px;background:#f5fafb;touch-action:none}textarea{width:100%;height:110px}label{display:block;margin:8px 0}
|
|
8
|
+
</style>
|
|
9
|
+
<h1>滑鼠與捲動測試</h1>
|
|
10
|
+
<p>各區獨立捲動。先把游標放在指定區域再操作,觀察位置與事件紀錄。Shift/Ctrl/Alt/Super 是否到達頁面取決於外層終端與系統;本頁不自行交換滾輪軸向。</p>
|
|
11
|
+
<section><h2>1. 垂直捲動</h2><div class="readout" id="v-state"></div><div id="v" class="box v"></div></section>
|
|
12
|
+
<section><h2>2. 水平捲動</h2><div class="readout" id="h-state"></div><div id="h" class="box h"><div class="wide">左端 0 ← 水平 1800 px → 右端<br><br>試水平手勢或 Shift+滾輪;若事件仍是 deltaY,請看外層終端是否轉換。</div></div></section>
|
|
13
|
+
<section><h2>3. 雙向捲動</h2><div class="readout" id="both-state"></div><div id="both" class="box both"><div class="plane">左上角 (0, 0) — 每格約 100 px;寬 1800、高 1200。</div></div><button id="reset">重設三區捲動</button></section>
|
|
14
|
+
<section><h2>4. 按鍵、雙擊與修飾鍵</h2><p>此按鈕攔截原生右鍵選單,只在這裡記錄 contextmenu。中鍵不捲頁。可按住修飾鍵再點擊。</p><button id="target">左鍵/右鍵/中鍵/雙擊這裡</button><div id="counts" class="readout"></div></section>
|
|
15
|
+
<section><h2>5. 按住左鍵拖曳畫線</h2><p>手機可雙擊並按住第二下拖曳。放開後再移動不應畫線;也可測移出畫布後放開。按住狀態與座標列在下方。</p><canvas id="paint" width="1000" height="300"></canvas><div id="drag" class="readout">尚未按下</div><button id="clear-paint">清除畫布</button></section>
|
|
16
|
+
<section id="text-selection"><h2>6. 文字選取與編輯</h2><p>這段可拖曳選取:春天的河水流過城市,0123456789,The quick brown fox。</p><textarea>第一行:滑鼠點擊定位,Shift+方向鍵選取。
|
|
17
|
+
第二行:測試 Ctrl-C/Ctrl-V、原生長按貼上。
|
|
18
|
+
第三行:這裡不自行實作 PRIMARY 選取或中鍵貼上。</textarea></section>
|
|
19
|
+
<section><h2>事件紀錄(最多 40 筆)</h2><label><input id="moves" type="checkbox"> 記錄 mousemove(會產生大量事件)</label><button id="clear-log">清除紀錄</button><pre id="log" aria-live="off"></pre></section>
|
|
20
|
+
<script>
|
|
21
|
+
const $=id=>document.getElementById(id), records=[], counts={left:0,right:0,middle:0,double:0};
|
|
22
|
+
function log(e){const mods=['shiftKey','ctrlKey','altKey','metaKey'].filter(k=>e[k]).map(k=>k.replace('Key','')).join('+')||'-';let msg=e.type+' target='+(e.target.id||e.target.tagName)+' button='+e.button+' buttons='+e.buttons+' xy='+Math.round(e.clientX||0)+','+Math.round(e.clientY||0)+' modifiers='+mods;if(e.type==='wheel')msg+=' delta='+e.deltaX+','+e.deltaY+' mode='+e.deltaMode;if(e.type==='click'||e.type==='dblclick'||e.type==='auxclick')msg+=' detail='+e.detail;records.unshift(msg);records.length=Math.min(40,records.length);$('log').textContent=records.join('\n')}
|
|
23
|
+
for(const type of ['mousedown','mouseup','click','dblclick','auxclick','contextmenu','wheel'])document.addEventListener(type,log,{capture:true,passive:true});
|
|
24
|
+
document.addEventListener('mousemove',e=>{if($('moves').checked)log(e)},{passive:true});
|
|
25
|
+
for(let i=0;i<20;i++){const r=document.createElement('div');r.className='row';r.textContent='垂直第 '+(i+1)+' 列';$('v').appendChild(r)}
|
|
26
|
+
for(const id of ['v','h','both']){const box=$(id),update=()=>{$(id+'-state').textContent='scrollLeft='+Math.round(box.scrollLeft)+' / '+Math.max(0,box.scrollWidth-box.clientWidth)+' · scrollTop='+Math.round(box.scrollTop)+' / '+Math.max(0,box.scrollHeight-box.clientHeight)};box.addEventListener('scroll',update);window.addEventListener('resize',update);update()}
|
|
27
|
+
$('reset').onclick=()=>{for(const id of ['v','h','both']){$(id).scrollLeft=0;$(id).scrollTop=0}};
|
|
28
|
+
const count=()=>{$('counts').textContent='左鍵 '+counts.left+' · 右鍵 '+counts.right+' · 中鍵 '+counts.middle+' · 雙擊 '+counts.double};count();
|
|
29
|
+
$('target').onclick=e=>{if(e.button===0){counts.left++;count()}};
|
|
30
|
+
$('target').oncontextmenu=e=>{e.preventDefault();counts.right++;count()};
|
|
31
|
+
$('target').onauxclick=e=>{e.preventDefault();if(e.button===1){counts.middle++;count()}};
|
|
32
|
+
$('target').onmousedown=e=>{if(e.button===1)e.preventDefault()};
|
|
33
|
+
$('target').ondblclick=()=>{counts.double++;count()};
|
|
34
|
+
const canvas=$('paint'),ctx=canvas.getContext('2d');let drawing=false,last=null;
|
|
35
|
+
const point=e=>{const r=canvas.getBoundingClientRect();return [(e.clientX-r.left)*canvas.width/r.width,(e.clientY-r.top)*canvas.height/r.height]};
|
|
36
|
+
canvas.onmousedown=e=>{if(e.button!==0)return;e.preventDefault();drawing=true;last=point(e);$('drag').textContent='按住左鍵'};
|
|
37
|
+
document.addEventListener('mousemove',e=>{if(!drawing)return;if(!(e.buttons&1)){drawing=false;$('drag').textContent='左鍵已放開';return}const p=point(e);ctx.strokeStyle='#136a8b';ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(...last);ctx.lineTo(...p);ctx.stroke();last=p;$('drag').textContent='拖曳 '+p.map(Math.round).join(',')});
|
|
38
|
+
document.addEventListener('mouseup',e=>{if(e.button===0){drawing=false;$('drag').textContent='左鍵已放開'}});
|
|
39
|
+
window.addEventListener('blur',()=>{drawing=false;$('drag').textContent='失焦,停止畫線'});
|
|
40
|
+
$('clear-paint').onclick=()=>ctx.clearRect(0,0,canvas.width,canvas.height);
|
|
41
|
+
$('clear-log').onclick=()=>{records.length=0;$('log').textContent=''};
|
|
42
|
+
</script>
|
|
43
|
+
</html>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {mkdtempSync,rmSync} from 'node:fs';
|
|
3
|
+
import {createBrowser} from '../lib/headless-shell.js';
|
|
4
|
+
import {CdpServer} from '../lib/cdp-server.js';
|
|
5
|
+
import {CDP,launch} from '../tools/browser-lab.js';
|
|
6
|
+
import {WaylandUrlBar} from '../frontends/wayland/urlbar.js';
|
|
7
|
+
|
|
8
|
+
const source=`<style>body{margin:0}#box{width:300px;height:160px}#a,#b{display:inline-block;width:100px;height:100px}canvas{display:block}</style>
|
|
9
|
+
<div id="box"><span id="a">A</span><span id="b">B</span></div><input id="field" value="hello"><canvas id="paint" width="300" height="100"></canvas>
|
|
10
|
+
<script>
|
|
11
|
+
globalThis.paint=document.getElementById("paint");globalThis.a=document.getElementById("a");globalThis.field=document.getElementById("field");
|
|
12
|
+
globalThis.events=[];globalThis.cancelDown=false;globalThis.cancelPointer=false;
|
|
13
|
+
for(const type of ['pointerdown','mousedown','pointermove','mousemove','pointerup','mouseup','click','auxclick'])document.addEventListener(type,e=>{events.push([type,e.target.id,e.button,e.buttons,e.shiftKey]);if(type==='mousedown'&&cancelDown)e.preventDefault();if(type==='pointerdown'&&cancelPointer)e.preventDefault()});
|
|
14
|
+
let drawing=false;const ctx=paint.getContext('2d');paint.onmousedown=()=>drawing=true;document.addEventListener('mouseup',()=>drawing=false);paint.onmousemove=e=>{if(drawing&&(e.buttons&1)){ctx.fillStyle='red';ctx.fillRect(0,0,20,20)}};
|
|
15
|
+
</script>`;
|
|
16
|
+
async function check(c){
|
|
17
|
+
await c.send('Page.navigate',{url:'data:text/html,'+encodeURIComponent(source)});
|
|
18
|
+
for(let i=0;i<200;i++){if(await c.evaluate('!!document.getElementById("paint")'))break;await Bun.sleep(20)}
|
|
19
|
+
const send=(type,x=20,y=20,extra={})=>c.send('Input.dispatchMouseEvent',{type,x,y,button:'left',buttons:type==='mouseReleased'?0:1,clickCount:1,...extra});
|
|
20
|
+
await send('mouseMoved',20,20,{buttons:0,button:'none'});await c.evaluate('events=[]');
|
|
21
|
+
await send('mousePressed',20,20,{modifiers:8});
|
|
22
|
+
expect(await c.evaluate('events')).toEqual([['pointerdown','a',0,1,true],['mousedown','a',0,1,true]]);
|
|
23
|
+
await send('mouseMoved',40,40,{modifiers:8});
|
|
24
|
+
expect((await c.evaluate('events')).slice(-2)).toEqual([['pointermove','a',-1,1,true],['mousemove','a',0,1,true]]);
|
|
25
|
+
await send('mouseReleased',40,40,{modifiers:8});
|
|
26
|
+
expect((await c.evaluate('events')).slice(-3)).toEqual([['pointerup','a',0,0,true],['mouseup','a',0,0,true],['click','a',0,0,true]]);
|
|
27
|
+
// Sibling press/release activates the common ancestor, never the release sibling.
|
|
28
|
+
await c.evaluate('events=[]');await send('mousePressed');await send('mouseReleased',140,20);
|
|
29
|
+
expect((await c.evaluate('events')).at(-1)).toEqual(['click','box',0,0,false]);
|
|
30
|
+
// A disconnected press target cannot activate another element on release.
|
|
31
|
+
await send('mousePressed');await c.evaluate('a.remove();events=[]');await send('mouseReleased');
|
|
32
|
+
expect((await c.evaluate('events')).some(e=>e[0]==='click')).toBe(false);
|
|
33
|
+
// Canceled mousedown must not focus the editor; normal focus happens on press.
|
|
34
|
+
const rect=await c.evaluate('field.getBoundingClientRect().toJSON()');
|
|
35
|
+
await c.evaluate('cancelDown=true');await send('mousePressed',rect.x+10,rect.y+8);
|
|
36
|
+
expect(await c.evaluate('document.activeElement.id')).not.toBe('field');await send('mouseReleased',rect.x+10,rect.y+8);
|
|
37
|
+
await c.evaluate('cancelDown=false');await send('mousePressed',rect.x+10,rect.y+8);
|
|
38
|
+
expect(await c.evaluate('document.activeElement.id')).toBe('field');await send('mouseReleased',rect.x+10,rect.y+8);
|
|
39
|
+
// pointerdown cancellation suppresses compatibility mouse events, not click.
|
|
40
|
+
await c.evaluate('cancelPointer=true;events=[]');await send('mousePressed',20,20);await send('mouseMoved',30,30);await send('mouseReleased',30,30);
|
|
41
|
+
expect((await c.evaluate('events')).map(e=>e[0])).toEqual(['pointerdown','pointermove','pointerup','click']);
|
|
42
|
+
await c.evaluate('cancelPointer=false;events=[]');
|
|
43
|
+
// The same canvas drawing contract as the manual test, checked before release.
|
|
44
|
+
const paint=await c.evaluate('paint.getBoundingClientRect().toJSON()');
|
|
45
|
+
await send('mousePressed',paint.x+20,paint.y+20);await send('mouseMoved',paint.x+40,paint.y+40);
|
|
46
|
+
expect(await c.evaluate('Array.from(paint.getContext("2d").getImageData(5,5,1,1).data)')).toEqual([255,0,0,255]);
|
|
47
|
+
await send('mouseReleased',paint.x+40,paint.y+40);await send('mouseMoved',20,20,{buttons:0,button:'none'});
|
|
48
|
+
expect((await c.evaluate('events')).at(-1)[3]).toBe(0);
|
|
49
|
+
}
|
|
50
|
+
test('CDP real pointer phases preserve dragging, focus and click targets',async()=>{
|
|
51
|
+
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`);
|
|
52
|
+
try{await check(c)}finally{c.close();server.stop(true);b.close()}
|
|
53
|
+
},30000);
|
|
54
|
+
test.skipIf(process.env.BUNINU_TEST_CHROMIUM!=='1')('Chromium reference uses the same pointer event contract',async()=>{
|
|
55
|
+
const dir=mkdtempSync('/tmp/pointer-chromium-');let app;
|
|
56
|
+
try{app=await launch('chromium',dir);await check(app.cdp)}finally{await app?.stop();rmSync(dir,{recursive:true,force:true})}
|
|
57
|
+
},30000);
|
|
58
|
+
test('Wayland toolbar sends real press and motion before release, including over chrome',async()=>{
|
|
59
|
+
const b=await createBrowser({spareRenderer:false});const bar=new WaylandUrlBar(b.context,{history:{list:()=>[],add(){}}});
|
|
60
|
+
try{
|
|
61
|
+
await b.context.navigate('data:text/html,'+encodeURIComponent(source));
|
|
62
|
+
await bar.pointerButton(20,bar.height+20,272,1,{clickCount:1});
|
|
63
|
+
expect((await b.context.evaluate('events')).map(e=>e[0])).toEqual(['pointerdown','mousedown']);
|
|
64
|
+
await bar.move(40,bar.height+40);
|
|
65
|
+
expect((await b.context.evaluate('events')).at(-1)[3]).toBe(1);
|
|
66
|
+
await bar.pointerButton(40,0,272,0,{clickCount:1});
|
|
67
|
+
await bar.move(40,bar.height+40);
|
|
68
|
+
expect((await b.context.evaluate('events')).at(-1)[3]).toBe(0);
|
|
69
|
+
expect(bar.editor.editing).toBe(false);
|
|
70
|
+
}finally{bar.dispose();b.close()}
|
|
71
|
+
},30000);
|
|
72
|
+
|
|
73
|
+
test.skipIf(process.env.BUNINU_TEST_WAYLAND!=='1')('real Wayland pointer draws on the unchanged manual test page',async()=>{
|
|
74
|
+
const {start}=await import('../../buninu-land/compositor.js');
|
|
75
|
+
const {launch:wayland}=await import('../frontends/wayland/index.js');
|
|
76
|
+
const dir=mkdtempSync('/tmp/pointer-wayland-'),saved=process.env.XDG_RUNTIME_DIR;
|
|
77
|
+
process.env.XDG_RUNTIME_DIR=dir;let land,app;
|
|
78
|
+
const until=async fn=>{for(let n=0;n<200;n++){if(await fn())return;await Bun.sleep(20)}throw Error('Wayland pointer timed out')};
|
|
79
|
+
try{
|
|
80
|
+
land=await start(['--remote-debugging-port=0','--window-size=800,700']);
|
|
81
|
+
app=await wayland({socket:dir+'/wayland-buninu',url:Bun.pathToFileURL(new URL('./manual-pointer-scroll.html',import.meta.url).pathname).href});
|
|
82
|
+
const c=app.browser.context;
|
|
83
|
+
await until(()=>land.compositor.wm.renderSurfaces().some(s=>s.pixels));
|
|
84
|
+
const top=await c.evaluate('document.getElementById("paint").getBoundingClientRect().top');
|
|
85
|
+
await c.scroll(0,top-100);
|
|
86
|
+
const rect=await c.evaluate('document.getElementById("paint").getBoundingClientRect().toJSON()');
|
|
87
|
+
const surface=land.compositor.wm.renderSurfaces().find(s=>s.pixels),origin=land.compositor.wm.origin(surface);
|
|
88
|
+
const x=origin.x+rect.x+40,y=origin.y+app.toolbar.height+rect.y+40;
|
|
89
|
+
land.context.dispatchMouseEvent({type:'mouseMoved',x,y});
|
|
90
|
+
land.context.dispatchMouseEvent({type:'mousePressed',x,y,button:'left',buttons:1});
|
|
91
|
+
await until(async()=>(await c.evaluate('document.getElementById("drag").textContent'))==='按住左鍵');
|
|
92
|
+
land.context.dispatchMouseEvent({type:'mouseMoved',x:x+80,y:y+40,button:'left',buttons:1});
|
|
93
|
+
await until(async()=>await c.evaluate('(()=>{const c=document.getElementById("paint");return c.getContext("2d").getImageData(0,0,c.width,c.height).data.some((v,i)=>i%4===3&&v>0)})()'));
|
|
94
|
+
land.context.dispatchMouseEvent({type:'mouseReleased',x:x+80,y:y+40,button:'left',buttons:0});
|
|
95
|
+
await until(async()=>(await c.evaluate('document.getElementById("drag").textContent'))==='左鍵已放開');
|
|
96
|
+
expect(await c.evaluate('document.getElementById("log").textContent')).toContain('mousedown');
|
|
97
|
+
}finally{app?.close();land?.close();if(saved===undefined)delete process.env.XDG_RUNTIME_DIR;else process.env.XDG_RUNTIME_DIR=saved;rmSync(dir,{recursive:true,force:true})}
|
|
98
|
+
},30000);
|
|
99
|
+
|
|
100
|
+
test('pointer bursts preserve drawing points while batching raster work',async()=>{
|
|
101
|
+
const child=Bun.spawn([process.execPath,new URL('./fixtures/pointer-paint-batching.js',import.meta.url).pathname],{stdout:'pipe',stderr:'pipe'});
|
|
102
|
+
const [exit,stderr]=await Promise.all([child.exited,new Response(child.stderr).text()]);
|
|
103
|
+
expect(stderr).toBe('');expect(exit).toBe(0);
|
|
104
|
+
},15000);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {RendererHost} from '../lib/renderer/host.js';
|
|
3
|
+
test('QML Canvas paints into HTML canvas and repaints after resize and property changes',async()=>{
|
|
4
|
+
const host=new RendererHost({timeout:20000});
|
|
5
|
+
try{
|
|
6
|
+
await host.start();
|
|
7
|
+
await host.call('loadDocument',{url:'data:text/x-qml,canvas',contentType:'text/x-qml',source:`import QtQuick
|
|
8
|
+
Canvas {id: c; width:16;height:16;property color ink:"red"
|
|
9
|
+
onInkChanged:requestPaint()
|
|
10
|
+
onPaint:{var ctx=getContext("2d");ctx.reset();ctx.fillStyle=ink;ctx.fillRect(0,0,width,height);globalThis.paints=(globalThis.paints||0)+1;}
|
|
11
|
+
Component.onCompleted:globalThis.canvasObject=c
|
|
12
|
+
}`});
|
|
13
|
+
await host.call('whenLoaded');
|
|
14
|
+
for(let i=0;i<100;i++){if(await host.call('evaluate','globalThis.paints>0'))break;await Bun.sleep(10);}
|
|
15
|
+
expect(await host.call('evaluate','Array.from(document.querySelector("canvas").getContext("2d").getImageData(8,8,1,1).data)')).toEqual([255,0,0,255]);
|
|
16
|
+
await host.call('evaluate','canvasObject.ink="blue";canvasObject.width=32;canvasObject.requestPaint();canvasObject.requestPaint()');
|
|
17
|
+
for(let i=0;i<100;i++){if(await host.call('evaluate','document.querySelector("canvas").width===32 && globalThis.paints>1'))break;await Bun.sleep(10);}
|
|
18
|
+
expect(await host.call('evaluate','Array.from(document.querySelector("canvas").getContext("2d").getImageData(24,8,1,1).data)')).toEqual([0,0,255,255]);
|
|
19
|
+
expect(await host.call('evaluate','canvasObject.available')).toBe(true);
|
|
20
|
+
}finally{host.close();}
|
|
21
|
+
},20000);
|
|
@@ -71,3 +71,25 @@ Rectangle {
|
|
|
71
71
|
expect(await host.call('evaluate',"Number(document.getElementById('status').textContent)")).toBe(0);
|
|
72
72
|
}finally{host.close()}
|
|
73
73
|
},30000);
|
|
74
|
+
|
|
75
|
+
test('property Changed.connect synchronizes menu state before same-handler validation',async()=>{
|
|
76
|
+
const host=new RendererHost({timeout:20000});
|
|
77
|
+
try{
|
|
78
|
+
await host.start();
|
|
79
|
+
await host.call('loadDocument',{url:'data:text/x-qml,menu-validation',contentType:'text/x-qml',source:`import QtQuick
|
|
80
|
+
Rectangle {
|
|
81
|
+
id:root;width:300;height:200;property int result:-1;property int changes:0
|
|
82
|
+
property bool preparing:false
|
|
83
|
+
readonly property bool live:root.preparing || menu.opened
|
|
84
|
+
Item {id:menu;property bool preparing:false;property bool opened:false;property int count:root.live ? 1 : 0}
|
|
85
|
+
Component.onCompleted:menu.preparingChanged.connect(function(){root.preparing=menu.preparing;root.changes++})
|
|
86
|
+
Text {id:status;text:root.result+":"+root.changes+":"+menu.count}
|
|
87
|
+
onClicked:{menu.preparing=true;root.result=menu.count;menu.preparing=false}
|
|
88
|
+
}`});
|
|
89
|
+
await host.call('whenLoaded');
|
|
90
|
+
for(const changes of [2,4,6]){
|
|
91
|
+
await host.call('evaluate',"document.getElementById('root').click()");
|
|
92
|
+
expect(await host.call('evaluate',"document.getElementById('status').textContent")).toBe('1:'+changes+':0');
|
|
93
|
+
}
|
|
94
|
+
}finally{host.close()}
|
|
95
|
+
},30000);
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {RendererHost} from '../lib/renderer/host.js';
|
|
3
|
+
import {qmlWindowConstraints} from '../lib/qml/window-constraints.js';
|
|
4
|
+
test('window constraints normalize and update Wayland size hints',async()=>{
|
|
5
|
+
const calls=[];let hints=['[360,240]','[200,0]'];
|
|
6
|
+
const sync=qmlWindowConstraints({request:(...args)=>calls.push(args)},{toplevel:8,surface:6},{url:'file:///test.qml',evaluate:async()=>hints});
|
|
7
|
+
await sync();await sync();
|
|
8
|
+
expect(calls).toEqual([[8,'set_min_size',360,240],[8,'set_max_size',360,0],[6,'commit']]);
|
|
9
|
+
hints=null;await sync();expect(calls.slice(3)).toEqual([[8,'set_min_size',0,0],[8,'set_max_size',0,0],[6,'commit']]);
|
|
10
|
+
});
|
|
11
|
+
test('FloatingWindow minimum size, attached window notifications and Button slots',async()=>{
|
|
12
|
+
const host=new RendererHost({timeout:20000});
|
|
13
|
+
try{
|
|
14
|
+
await host.start();
|
|
15
|
+
await host.call('loadDocument',{url:'data:text/x-qml,controls',contentType:'text/x-qml',source:`import QtQuick
|
|
16
|
+
import Quickshell
|
|
17
|
+
ShellRoot {
|
|
18
|
+
FloatingWindow {id:a;minimumSize:Qt.size(1500,1200)
|
|
19
|
+
Button {id:b;width:100;height:40;padding:5;focusPolicy:Qt.NoFocus
|
|
20
|
+
contentItem:Text {id:label;text:"hello"}
|
|
21
|
+
background:Rectangle {id:bg;color:"red"}
|
|
22
|
+
Window.onWindowChanged:globalThis.changes=(globalThis.changes||0)+1
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
FloatingWindow{id:c}
|
|
26
|
+
Component.onCompleted:{globalThis.a=a;globalThis.b=b;globalThis.c=c;globalThis.label=label;globalThis.bg=bg;}
|
|
27
|
+
}`});
|
|
28
|
+
await host.call('whenLoaded');
|
|
29
|
+
expect(await host.call('evaluate','[a.width,a.height]')).toEqual([1500,1200]);
|
|
30
|
+
expect(await host.call('evaluate','[label.x,label.y,label.width,label.height,bg.width,bg.height,b.activeFocusOnTab]')).toEqual([5,5,90,30,100,40,false]);
|
|
31
|
+
expect(await host.call('evaluate','b.Window.window===a')).toBe(true);
|
|
32
|
+
const before=await host.call('evaluate','globalThis.changes');
|
|
33
|
+
await host.call('evaluate','b.parent=c');await Bun.sleep(20);
|
|
34
|
+
expect(await host.call('evaluate','b.Window.window===c')).toBe(true);
|
|
35
|
+
expect(await host.call('evaluate','globalThis.changes')).toBe(before+1);
|
|
36
|
+
}finally{host.close();}
|
|
37
|
+
},20000);
|
|
38
|
+
|
|
39
|
+
test('ListModel roles populate required Repeater properties and remain reactive',async()=>{
|
|
40
|
+
const host=new RendererHost({timeout:20000});
|
|
41
|
+
try{
|
|
42
|
+
await host.start();await host.call('loadDocument',{url:'data:text/x-qml,roles',contentType:'text/x-qml',source:`import QtQuick
|
|
43
|
+
Item {
|
|
44
|
+
ListModel{id:model}
|
|
45
|
+
Repeater{id:rep;model:model;delegate:Text {required property string key;required property int amount;text:key+amount}}
|
|
46
|
+
Component.onCompleted:{globalThis.rep=rep;globalThis.rows=model;model.append({key:"a",amount:2});}
|
|
47
|
+
}`});await host.call('whenLoaded');
|
|
48
|
+
expect(await host.call('evaluate','[rep.count,rep.itemAt(0).text]')).toEqual([1,'a2']);
|
|
49
|
+
await host.call('evaluate','rows.setProperty(0,"amount",3)');await Bun.sleep(20);
|
|
50
|
+
expect(await host.call('evaluate','rep.itemAt(0).text')).toBe('a3');
|
|
51
|
+
}finally{host.close();}
|
|
52
|
+
},20000);
|
|
53
|
+
|
|
54
|
+
test('nested Loader startup preserves outer property notifications',async()=>{
|
|
55
|
+
const host=new RendererHost({timeout:20000});
|
|
56
|
+
try{
|
|
57
|
+
await host.start();await host.call('loadDocument',{url:'data:text/x-qml,nested-notifications',contentType:'text/x-qml',source:`import QtQuick
|
|
58
|
+
Item {id:root
|
|
59
|
+
property int counter:0
|
|
60
|
+
onCounterChanged:globalThis.changes=(globalThis.changes||[]).concat([counter])
|
|
61
|
+
Loader {sourceComponent:Item {property size dimensions;property real side:dimensions.width;Component.onCompleted:globalThis.initialSide=side}}
|
|
62
|
+
Component.onCompleted:{globalThis.root=root;counter=1;}
|
|
63
|
+
}`});await host.call('whenLoaded');
|
|
64
|
+
expect(await host.call('evaluate','globalThis.initialSide')).toBe(0);
|
|
65
|
+
expect(await host.call('evaluate','globalThis.changes')).toEqual([1]);
|
|
66
|
+
await host.call('evaluate','root.counter=2');await Bun.sleep(20);
|
|
67
|
+
expect(await host.call('evaluate','globalThis.changes')).toEqual([1,2]);
|
|
68
|
+
}finally{host.close();}
|
|
69
|
+
},20000);
|
|
70
|
+
|
|
71
|
+
import {createQt} from '../lib/qml/builtins.js';
|
|
72
|
+
test('Qt date/time formatting uses local date fields and locale format order',()=>{
|
|
73
|
+
const qt=createQt(),date=new Date(2024,10,23,17,45,36);
|
|
74
|
+
expect(qt.formatTime(date,'HH:mm')).toBe('17:45');
|
|
75
|
+
expect(qt.formatTime(date,'h:mm AP')).toBe('5:45 PM');
|
|
76
|
+
expect(qt.formatDateTime(date,"yyyy-MM-dd 'at' HH:mm")).toBe('2024-11-23 at 17:45');
|
|
77
|
+
expect(qt.locale('en_US').measurementSystem).toBe(1);
|
|
78
|
+
expect(qt.locale('zh_TW').measurementSystem).toBe(0);
|
|
79
|
+
expect(qt.locale('en_US').dateFormat(1).indexOf('M')).toBeLessThan(qt.locale('en_US').dateFormat(1).indexOf('d'));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('createObject does not make the owning binding depend on new child state',async()=>{
|
|
83
|
+
const host=new RendererHost({timeout:20000});
|
|
84
|
+
try{
|
|
85
|
+
await host.start();await host.call('loadDocument',{url:'data:text/x-qml,factory-tracking',contentType:'text/x-qml',source:`import QtQuick
|
|
86
|
+
Item {id:root
|
|
87
|
+
property var child:factory.createObject(root)
|
|
88
|
+
property Component factory:Component {Item {property int counter:0;property int derived:counter+1;Component.onCompleted:globalThis.created=(globalThis.created||0)+1}}
|
|
89
|
+
Component.onCompleted:{globalThis.root=root;globalThis.first=child;}
|
|
90
|
+
}`});await host.call('whenLoaded');
|
|
91
|
+
expect(await host.call('evaluate','globalThis.created')).toBe(1);
|
|
92
|
+
await host.call('evaluate','first.counter=9');await Bun.sleep(20);
|
|
93
|
+
expect(await host.call('evaluate','[root.child===first,root.child.derived,globalThis.created]')).toEqual([true,10,1]);
|
|
94
|
+
}finally{host.close();}
|
|
95
|
+
},20000);
|
|
96
|
+
|
|
97
|
+
test('Button custom content sizes its RowLayout slot and reacts without a width cycle',async()=>{
|
|
98
|
+
const host=new RendererHost({timeout:20000});
|
|
99
|
+
try {
|
|
100
|
+
await host.start();
|
|
101
|
+
await host.call('loadDocument',{url:'data:text/x-qml,button-size',contentType:'text/x-qml',source:`import QtQuick
|
|
102
|
+
import QtQuick.Layouts
|
|
103
|
+
import Quickshell
|
|
104
|
+
FloatingWindow {
|
|
105
|
+
RowLayout { width:500; height:60; spacing:8
|
|
106
|
+
Button {id:button;padding:4
|
|
107
|
+
contentItem: RowLayout {spacing:6
|
|
108
|
+
Rectangle {id:first;implicitWidth:80;implicitHeight:20}
|
|
109
|
+
Rectangle {implicitWidth:100;implicitHeight:24}
|
|
110
|
+
}
|
|
111
|
+
background: Rectangle {implicitWidth:120;implicitHeight:30}
|
|
112
|
+
}
|
|
113
|
+
Rectangle {id:next;implicitWidth:30;implicitHeight:30}
|
|
114
|
+
Item {Layout.fillWidth:true}
|
|
115
|
+
}
|
|
116
|
+
Component.onCompleted:{globalThis.button=button;globalThis.first=first;globalThis.next=next;}
|
|
117
|
+
}`});
|
|
118
|
+
await host.call('whenLoaded');
|
|
119
|
+
expect(await host.call('evaluate','[button.implicitWidth,button.implicitHeight,button.width,next.x]')).toEqual([194,32,194,202]);
|
|
120
|
+
await host.call('evaluate','first.implicitWidth=120');await Bun.sleep(20);
|
|
121
|
+
expect(await host.call('evaluate','[button.implicitWidth,button.width,next.x]')).toEqual([234,234,242]);
|
|
122
|
+
}finally{host.close();}
|
|
123
|
+
},20000);
|
|
124
|
+
|
|
125
|
+
test('Layouts retain explicit item dimensions for status dots and palette strips',async()=>{
|
|
126
|
+
const host=new RendererHost({timeout:20000});
|
|
127
|
+
try {
|
|
128
|
+
await host.start();
|
|
129
|
+
await host.call('loadDocument',{url:'data:text/x-qml,layout-fallback',contentType:'text/x-qml',source:`import QtQuick
|
|
130
|
+
import QtQuick.Layouts
|
|
131
|
+
import Quickshell
|
|
132
|
+
FloatingWindow {
|
|
133
|
+
RowLayout {id:row;width:300;height:30
|
|
134
|
+
Rectangle {id:dot;width:8;height:8;radius:4;color:"green"}
|
|
135
|
+
Item {Layout.fillWidth:true}
|
|
136
|
+
}
|
|
137
|
+
ColumnLayout {id:column;y:40;width:300;height:22;spacing:4
|
|
138
|
+
Rectangle {id:strip;Layout.fillWidth:true;height:6;color:"red"}
|
|
139
|
+
Item {implicitHeight:12;Layout.fillWidth:true}
|
|
140
|
+
}
|
|
141
|
+
GridLayout {y:80;columns:1
|
|
142
|
+
Rectangle {id:cell;width:11;height:7}
|
|
143
|
+
}
|
|
144
|
+
Component.onCompleted:{globalThis.dot=dot;globalThis.strip=strip;globalThis.cell=cell;globalThis.row=row;globalThis.column=column;}
|
|
145
|
+
}`});
|
|
146
|
+
await host.call('whenLoaded');
|
|
147
|
+
expect(await host.call('evaluate','[dot.width,dot.height,strip.width,strip.height,cell.width,cell.height]')).toEqual([8,8,300,6,11,7]);
|
|
148
|
+
await host.call('evaluate','row.width=500;column.width=500');await Bun.sleep(20);
|
|
149
|
+
expect(await host.call('evaluate','[dot.width,dot.height,strip.width,strip.height]')).toEqual([8,8,500,6]);
|
|
150
|
+
await host.call('evaluate','row.width=300;column.width=300');await Bun.sleep(20);
|
|
151
|
+
expect(await host.call('evaluate','[dot.width,dot.height,strip.width,strip.height]')).toEqual([8,8,300,6]);
|
|
152
|
+
}finally{host.close();}
|
|
153
|
+
},20000);
|
|
154
|
+
|
|
155
|
+
test('Nested Layout receives explicit size bindings even when the dot starts hidden',async()=>{
|
|
156
|
+
const host=new RendererHost({timeout:20000});
|
|
157
|
+
try {
|
|
158
|
+
await host.start();
|
|
159
|
+
await host.call('loadDocument',{url:'data:text/x-qml,late-dot',contentType:'text/x-qml',source:`import QtQuick
|
|
160
|
+
import QtQuick.Layouts
|
|
161
|
+
import Quickshell
|
|
162
|
+
FloatingWindow {
|
|
163
|
+
property bool ready:false
|
|
164
|
+
id:root
|
|
165
|
+
ColumnLayout {anchors.fill:parent
|
|
166
|
+
RowLayout {Layout.fillWidth:true
|
|
167
|
+
Text {text:"TITLE"}
|
|
168
|
+
Item {Layout.fillWidth:true}
|
|
169
|
+
RowLayout {
|
|
170
|
+
spacing:8
|
|
171
|
+
Rectangle {id:dot;width:8;height:8;radius:4;visible:root.ready;Layout.alignment:Qt.AlignVCenter;color:"blue"}
|
|
172
|
+
Text {text:"LIVE"}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
Component.onCompleted:{globalThis.root=root;globalThis.dot=dot;}
|
|
177
|
+
}`});
|
|
178
|
+
await host.call('whenLoaded');
|
|
179
|
+
await host.call('evaluate','root.ready=true');await Bun.sleep(20);
|
|
180
|
+
expect(await host.call('evaluate','[dot.width,dot.height,dot.visible]')).toEqual([8,8,true]);
|
|
181
|
+
}finally{host.close();}
|
|
182
|
+
},20000);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {mkdtemp,mkdir,copyFile,symlink,rm} from 'node:fs/promises';
|
|
3
|
+
import {tmpdir} from 'node:os';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
import {loadGraphics} from '../lib/renderer/page-renderer.js';
|
|
6
|
+
import {qmlFontFiles,loadQmlFonts} from '../lib/qml/fonts.js';
|
|
7
|
+
|
|
8
|
+
test('Qt font directory: recursive, symlink-safe, cached, isolated and invalid-font tolerant',async()=>{
|
|
9
|
+
const root=await mkdtemp(join(tmpdir(),'qml-fonts-'));
|
|
10
|
+
try {
|
|
11
|
+
await mkdir(join(root,'nested'));
|
|
12
|
+
await copyFile(new URL('../usr/share/fonts/DejaVuSansMono.ttf',import.meta.url),join(root,'nested','Regular.ttf'));
|
|
13
|
+
await symlink(root,join(root,'nested','cycle'));
|
|
14
|
+
await symlink(join(root,'nested','Regular.ttf'),join(root,'duplicate.ttf'));
|
|
15
|
+
await Bun.write(join(root,'invalid.otf'),'not a font');
|
|
16
|
+
await Bun.write(join(root,'ignored.txt'),'ignore');
|
|
17
|
+
expect((await qmlFontFiles(root)).length).toBe(2);
|
|
18
|
+
const base=await loadGraphics(),families=[...base.fontFamilies],warn=[];
|
|
19
|
+
const qml=await loadQmlFonts(base,m=>warn.push(m),root);
|
|
20
|
+
expect(qml).not.toBe(base);
|
|
21
|
+
expect(qml.fontFamilies.length).toBe(families.length+1);
|
|
22
|
+
expect(base.fontFamilies).toEqual(families);
|
|
23
|
+
const face=qml.builtinTypefaces.at(-1);
|
|
24
|
+
expect(face.typeface.getGlyphIDs('A')[0]).toBeGreaterThan(0);
|
|
25
|
+
expect(face.typeface.getGlyphIDs('\u{f040a}')[0]).toBe(0);
|
|
26
|
+
expect(warn.length).toBe(1);
|
|
27
|
+
expect(await loadQmlFonts(base,()=>{},root)).toBe(qml);
|
|
28
|
+
expect(await loadQmlFonts(base,()=>{},'')).toBe(base);
|
|
29
|
+
expect(await loadQmlFonts(base,()=>{},join(root,'missing'))).toBe(base);
|
|
30
|
+
}finally{await rm(root,{recursive:true,force:true});}
|
|
31
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {RendererHost} from '../lib/renderer/host.js';
|
|
3
|
+
test('GridLayout fills columns and reflows; PathMultiline creates independent SVG subpaths',async()=>{
|
|
4
|
+
const host=new RendererHost({timeout:20000});
|
|
5
|
+
try{
|
|
6
|
+
await host.start();
|
|
7
|
+
await host.call('loadDocument',{url:'data:text/x-qml,grid',contentType:'text/x-qml',source:`import QtQuick
|
|
8
|
+
import QtQuick.Layouts
|
|
9
|
+
import QtQuick.Shapes
|
|
10
|
+
Item {width:200;height:100
|
|
11
|
+
GridLayout {id:g;width:200;columns:2;columnSpacing:20;rowSpacing:5
|
|
12
|
+
Rectangle {id:a;implicitWidth:30;implicitHeight:10;Layout.fillWidth:true}
|
|
13
|
+
Rectangle {id:b;implicitWidth:30;implicitHeight:20;Layout.fillWidth:true}
|
|
14
|
+
Rectangle {id:c;implicitWidth:30;implicitHeight:10;Layout.fillWidth:true}
|
|
15
|
+
}
|
|
16
|
+
Shape {ShapePath {strokeColor:"red";fillColor:"transparent";PathMultiline {id:p;paths:[[Qt.point(1,2),Qt.point(3,4)],[Qt.point(5,6),Qt.point(7,8)]]}}}
|
|
17
|
+
Component.onCompleted:{globalThis.g=g;globalThis.cells=[a,b,c];globalThis.pathObject=p;}
|
|
18
|
+
}`});
|
|
19
|
+
await host.call('whenLoaded');
|
|
20
|
+
expect(await host.call('evaluate','cells.map(c=>[c.x,c.y,c.width,c.height])')).toEqual([[0,0,90,10],[110,0,90,20],[0,25,90,10]]);
|
|
21
|
+
expect(await host.call('evaluate','document.querySelector("path").getAttribute("d")')).toBe('M1 2 L3 4 M5 6 L7 8');
|
|
22
|
+
await host.call('evaluate','g.width=60');
|
|
23
|
+
await Bun.sleep(20);
|
|
24
|
+
expect(await host.call('evaluate','cells.map(c=>c.width)')).toEqual([20,20,20]);
|
|
25
|
+
await host.call('evaluate','g.width=200;g.columns=1;pathObject.paths=[[{x:9,y:10},{x:11,y:12}]]');
|
|
26
|
+
await Bun.sleep(20);
|
|
27
|
+
expect(await host.call('evaluate','cells.map(c=>[c.x,c.y,c.width])')).toEqual([[0,0,200],[0,15,200],[0,40,200]]);
|
|
28
|
+
expect(await host.call('evaluate','document.querySelector("path").getAttribute("d")')).toBe('M9 10 L11 12');
|
|
29
|
+
}finally{host.close();}
|
|
30
|
+
},20000);
|
|
31
|
+
|
|
32
|
+
test('nested fill layouts retain intrinsic widths when a parent resizes',async()=>{
|
|
33
|
+
const host=new RendererHost({timeout:20000});
|
|
34
|
+
try{
|
|
35
|
+
await host.start();
|
|
36
|
+
await host.call('loadDocument',{url:'data:text/x-qml,nested-grid',contentType:'text/x-qml',source:`import QtQuick
|
|
37
|
+
import QtQuick.Layouts
|
|
38
|
+
GridLayout {id:g;width:200;columns:2;columnSpacing:20
|
|
39
|
+
ColumnLayout {id:a;Layout.fillWidth:true;Rectangle {implicitWidth:30;implicitHeight:10;Layout.fillWidth:true}}
|
|
40
|
+
ColumnLayout {id:b;Layout.fillWidth:true;Rectangle {implicitWidth:30;implicitHeight:10;Layout.fillWidth:true}}
|
|
41
|
+
Component.onCompleted:{globalThis.grid=g;globalThis.columns=[a,b]}
|
|
42
|
+
}`});
|
|
43
|
+
await host.call('whenLoaded');
|
|
44
|
+
expect(await host.call('evaluate','columns.map(c=>[c.width,c.implicitWidth])')).toEqual([[90,30],[90,30]]);
|
|
45
|
+
await host.call('evaluate','grid.width=400');await Bun.sleep(20);
|
|
46
|
+
expect(await host.call('evaluate','columns.map(c=>[c.width,c.implicitWidth])')).toEqual([[190,30],[190,30]]);
|
|
47
|
+
}finally{host.close();}
|
|
48
|
+
},20000);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {createQt} from '../lib/qml/builtins.js';
|
|
3
|
+
|
|
4
|
+
test('C/POSIX locale is metric and uses Qt date order and 24-hour time',()=>{
|
|
5
|
+
for(const name of ['C','C.UTF-8','POSIX']) {
|
|
6
|
+
const qt=createQt({LC_ALL:name,LANG:'en_US.UTF-8',LC_TIME:'en_US.UTF-8',LC_MEASUREMENT:'en_US.UTF-8'});
|
|
7
|
+
const l=qt.locale();
|
|
8
|
+
expect(l.name).toBe('C');expect(l.measurementSystem).toBe(0);
|
|
9
|
+
expect(l.dateFormat(1)).toBe('d MM yyyy');expect(l.timeFormat(1)).toBe('HH:mm');
|
|
10
|
+
expect(qt.locale('en_US.UTF-8').measurementSystem).toBe(1);
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
test('locale categories override LANG but explicit locale ignores environment',()=>{
|
|
14
|
+
const qt=createQt({LANG:'en_US.UTF-8',LC_TIME:'en_GB.UTF-8',LC_MEASUREMENT:'zh_TW.UTF-8'});
|
|
15
|
+
expect(qt.locale().name).toBe('en_US');expect(qt.locale().measurementSystem).toBe(0);
|
|
16
|
+
expect(qt.locale().timeFormat(1)).not.toContain('AP');
|
|
17
|
+
expect(qt.locale().dateFormat(1).indexOf('d')).toBeLessThan(qt.locale().dateFormat(1).indexOf('M'));
|
|
18
|
+
expect(qt.locale('en_US').timeFormat(1)).toContain('AP');
|
|
19
|
+
expect(qt.locale('en_GB').measurementSystem).toBe(2);
|
|
20
|
+
expect(qt.locale('C').timeFormat(1)).toBe('HH:mm');
|
|
21
|
+
expect(qt.locale('not_a_valid_locale').name).toBe('C');
|
|
22
|
+
});
|
|
23
|
+
test('empty overrides inherit LANG; unset system defaults to C',()=>{
|
|
24
|
+
const qt=createQt({LC_ALL:'',LC_TIME:'',LANG:'zh_TW.UTF-8'});
|
|
25
|
+
expect(qt.locale().name).toBe('zh_TW');expect(qt.locale().measurementSystem).toBe(0);
|
|
26
|
+
expect(qt.locale().dateFormat(1).indexOf('y')).toBeLessThan(qt.locale().dateFormat(1).indexOf('M'));
|
|
27
|
+
expect(createQt({}).locale().name).toBe('C');
|
|
28
|
+
const d=new Date(2024,10,23,17,45,36);
|
|
29
|
+
expect(createQt({LANG:'C.UTF-8'}).formatTime(d,'HH:mm')).toBe('17:45');
|
|
30
|
+
expect(createQt({LANG:'C.UTF-8'}).formatDate(d,'MMMM')).toBe('November');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('local QML host receives locale categories through its host capability',async()=>{
|
|
34
|
+
for(const [env,expected] of [
|
|
35
|
+
[{LC_ALL:'C.UTF-8',LANG:'en_US.UTF-8'},{name:'C',measurement:0,date:'d MM yyyy',time:'HH:mm'}],
|
|
36
|
+
[{LC_ALL:'',LANG:'en_US.UTF-8',LC_MESSAGES:'',LC_TIME:'en_GB.UTF-8',LC_MEASUREMENT:'zh_TW.UTF-8'},null],
|
|
37
|
+
]) {
|
|
38
|
+
const child=Bun.spawn([process.execPath,new URL('./fixtures/qml-locale/check.mjs',import.meta.url).pathname],{env:{...process.env,...env},stdout:'pipe',stderr:'pipe'});
|
|
39
|
+
const [output,error,code]=await Promise.all([new Response(child.stdout).text(),new Response(child.stderr).text(),child.exited]);
|
|
40
|
+
expect({code,error}).toEqual({code:0,error:''});
|
|
41
|
+
const result=JSON.parse(output.trim());
|
|
42
|
+
if(expected)expect(result).toEqual(expected);
|
|
43
|
+
else {expect(result.name).toBe('en_US');expect(result.measurement).toBe(0);expect(result.time).toBe('HH:mm');}
|
|
44
|
+
}
|
|
45
|
+
},15000);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {mkdtempSync,rmSync} from 'node:fs';
|
|
3
|
+
import {tmpdir} from 'node:os';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
import {createBrowser} from '../lib/headless-shell.js';
|
|
6
|
+
|
|
7
|
+
test('dynamic QtObject readonly visible does not abort singleton initialization',async()=>{
|
|
8
|
+
const dir=mkdtempSync(join(tmpdir(),'qml-nonvisual-'));let browser;
|
|
9
|
+
try{
|
|
10
|
+
await Bun.write(join(dir,'qmldir'),'singleton Sound 1.0 Sound.qml\n');
|
|
11
|
+
await Bun.write(join(dir,'Sound.qml'),'pragma Singleton\nimport QtQuick\nQtObject { property int held: -1; readonly property real gain: held < 0 ? 1 : 0 }');
|
|
12
|
+
await Bun.write(join(dir,'Panel.qml'),'import QtQuick\nimport "."\nItem { QtObject { id:meta; readonly property bool visible: true; readonly property real gain: Sound.gain } Text { text: "gain="+meta.gain+" visible="+meta.visible } }');
|
|
13
|
+
await Bun.write(join(dir,'main.qml'),'import QtQuick\nItem { width:200;height:80; Loader { id:loader;source:"Panel.qml" } }');
|
|
14
|
+
browser=await createBrowser({spareRenderer:false});
|
|
15
|
+
await browser.context.navigate(Bun.pathToFileURL(join(dir,'main.qml')).href);
|
|
16
|
+
expect(await browser.context.evaluate('Array.from(document.querySelectorAll("[data-qml-type=Text]")).map(e=>e.textContent).join(" ")')).toContain('gain=1 visible=true');
|
|
17
|
+
expect(await browser.context.evaluate('Array.from(document.querySelectorAll("[data-qml-type=QtObject]")).every(e=>e.style.display==="none")')).toBe(true);
|
|
18
|
+
}finally{browser?.close();rmSync(dir,{recursive:true,force:true});}
|
|
19
|
+
},30000);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
for(const mode of ['shader','grab','texture'])test('local QML software canvas: '+mode,async()=>{
|
|
3
|
+
const child=Bun.spawn([process.execPath,new URL('./fixtures/qml-shader-check.mjs',import.meta.url).pathname,mode],{stdout:'pipe',stderr:'pipe'});
|
|
4
|
+
const [exit,stdout,stderr]=await Promise.all([child.exited,new Response(child.stdout).text(),new Response(child.stderr).text()]);
|
|
5
|
+
expect({exit,stderr}).toEqual({exit:0,stderr:''});
|
|
6
|
+
},30000);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {RendererHost} from '../lib/renderer/host.js';
|
|
3
|
+
test('Instantiator owns Shortcut delegates and deactivation removes keyboard handlers',async()=>{
|
|
4
|
+
const host=new RendererHost({timeout:20000});
|
|
5
|
+
try{
|
|
6
|
+
await host.start();
|
|
7
|
+
await host.call('loadDocument',{url:'data:text/x-qml,shortcuts',contentType:'text/x-qml',source:`import QtQuick
|
|
8
|
+
import QtQml.Models
|
|
9
|
+
Item {
|
|
10
|
+
property int hits:0
|
|
11
|
+
Instantiator {id:inst;model:["Ctrl+L", "Shift+N", "?", "Ctrl+K, Ctrl+C"]
|
|
12
|
+
delegate:Shortcut {autoRepeat:false;sequence:modelData;onActivated:globalThis.pressed.push(index)}
|
|
13
|
+
}
|
|
14
|
+
Shortcut {sequence:"X";onActivated:globalThis.bad=true;onActivatedAmbiguously:globalThis.amb=(globalThis.amb||0)+1}
|
|
15
|
+
Shortcut {sequence:"X";onActivated:globalThis.bad=true;onActivatedAmbiguously:globalThis.amb=(globalThis.amb||0)+1}
|
|
16
|
+
Shortcut {id:probe;enabled:false;sequence:"ctrl+l"}
|
|
17
|
+
Component.onCompleted:{globalThis.pressed=[];globalThis.inst=inst;globalThis.probe=probe;}
|
|
18
|
+
}`});
|
|
19
|
+
await host.call('whenLoaded');
|
|
20
|
+
const key=async(key,mods={})=>host.call('evaluate',`document.body.dispatchEvent(new KeyboardEvent('keydown',{key:${JSON.stringify(key)},bubbles:true,cancelable:true,...${JSON.stringify(mods)}}))`);
|
|
21
|
+
expect(await host.call('evaluate','inst.count')).toBe(4);
|
|
22
|
+
expect(await host.call('evaluate','probe.portableText')).toBe('Ctrl+L');
|
|
23
|
+
expect(await host.call('evaluate','inst.objectAt(0).parent===inst')).toBe(true);
|
|
24
|
+
await key('l',{ctrlKey:true,repeat:true});
|
|
25
|
+
expect(await host.call('evaluate','pressed')).toEqual([]);
|
|
26
|
+
await key('x');
|
|
27
|
+
expect(await host.call('evaluate','[globalThis.amb,!!globalThis.bad]')).toEqual([2,false]);
|
|
28
|
+
await key('l',{ctrlKey:true});await key('N',{shiftKey:true});await key('?',{shiftKey:true});
|
|
29
|
+
await key('k',{ctrlKey:true});await key('c',{ctrlKey:true});
|
|
30
|
+
expect(await host.call('evaluate','pressed')).toEqual([0,1,2,3]);
|
|
31
|
+
await host.call('evaluate','inst.active=false');
|
|
32
|
+
await Bun.sleep(20);await key('l',{ctrlKey:true});
|
|
33
|
+
expect(await host.call('evaluate','inst.count')).toBe(0);
|
|
34
|
+
expect(await host.call('evaluate','inst.object')).toBe(null);
|
|
35
|
+
expect(await host.call('evaluate','pressed')).toEqual([0,1,2,3]);
|
|
36
|
+
await host.call('evaluate','inst.active=true');await Bun.sleep(20);await key('l',{ctrlKey:true});
|
|
37
|
+
expect(await host.call('evaluate','pressed')).toEqual([0,1,2,3,0]);
|
|
38
|
+
}finally{host.close();}
|
|
39
|
+
},20000);
|