buninu-browser 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/happy-dom/inert-link.js +26 -0
- package/lib/happy-dom/node-iterator-removal.js +36 -0
- package/lib/happy-dom/node-replacement.js +60 -0
- package/lib/happy-dom/parser.js +4 -1
- package/lib/headless-shell.js +7 -4
- package/lib/layout/text-layout.js +4 -2
- 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 +110 -4
- 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 +283 -39
- 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/compare-node-iterator.js +42 -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/fractional-text-scale.js +38 -0
- package/test/fixtures/node-iterator-cases.js +59 -0
- package/test/fixtures/node-iterator-chromium.json +315 -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/fractional-text-scale.test.js +6 -0
- package/test/inert-link.test.js +37 -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/node-iterator.test.js +81 -0
- package/test/page-scripts.test.js +116 -1
- 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/text-layout.test.js +15 -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
- package/test/window-resize.test.js +29 -0
|
@@ -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);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {mkdtemp,rm} from 'node:fs/promises';
|
|
3
|
+
import {createServer} from 'node:net';
|
|
4
|
+
import {createBrowser} from '../lib/headless-shell.js';
|
|
5
|
+
|
|
6
|
+
test('local QML Socket connects, splits UTF-8 messages, writes and disconnects',async()=>{
|
|
7
|
+
const dir=await mkdtemp('/tmp/qml-socket-');
|
|
8
|
+
const peers=new Set(); let received='';
|
|
9
|
+
const server=createServer(socket=>{
|
|
10
|
+
peers.add(socket);socket.on('close',()=>peers.delete(socket));
|
|
11
|
+
socket.on('data',data=>{received+=data;});
|
|
12
|
+
const bytes=Buffer.from('你好\nsecond\n');
|
|
13
|
+
socket.write(bytes.subarray(0,2));setTimeout(()=>socket.write(bytes.subarray(2)),20);
|
|
14
|
+
});
|
|
15
|
+
await new Promise(r=>server.listen(dir+'/engine.sock',r));
|
|
16
|
+
const browser=await createBrowser({spareRenderer:false});
|
|
17
|
+
try{
|
|
18
|
+
await Bun.write(dir+'/main.qml',`import QtQuick\nimport QtQuick.Window\nimport Quickshell.Io
|
|
19
|
+
Item { Socket { id: socket; path: ${JSON.stringify(dir+'/engine.sock')}; connected: true
|
|
20
|
+
parser: SplitParser { onRead: data => { globalThis.messages.push(data); socket.write(data + "!"); socket.flush(); } }
|
|
21
|
+
onConnectedChanged: (globalThis.states ??= []).push(connected)
|
|
22
|
+
Component.onCompleted: { globalThis.messages=[]; globalThis.states=[]; globalThis.sock=socket; }
|
|
23
|
+
} }`);
|
|
24
|
+
await browser.context.navigate('file://'+dir+'/main.qml');
|
|
25
|
+
for(let i=0;i<100;i++){if(await browser.context.evaluate('globalThis.messages?.length===2'))break;await Bun.sleep(20);}
|
|
26
|
+
expect(await browser.context.evaluate('messages')).toEqual(['你好','second']);
|
|
27
|
+
expect(await browser.context.evaluate('sock.connected')).toBe(true);
|
|
28
|
+
for(let i=0;i<100&&!received.includes('second!');i++)await Bun.sleep(10);
|
|
29
|
+
expect(received).toBe('你好!second!');
|
|
30
|
+
await browser.context.evaluate('sock.connected=false');
|
|
31
|
+
for(let i=0;i<100&&peers.size;i++)await Bun.sleep(10);
|
|
32
|
+
expect(peers.size).toBe(0);
|
|
33
|
+
expect(await browser.context.evaluate('sock.connected')).toBe(false);
|
|
34
|
+
}finally{browser.close();for(const p of peers)p.destroy();await new Promise(r=>server.close(r));await rm(dir,{recursive:true,force:true});}
|
|
35
|
+
},20000);
|
|
36
|
+
|
|
37
|
+
test('Socket failure can reconnect and navigation closes the live connection',async()=>{
|
|
38
|
+
const dir=await mkdtemp('/tmp/qml-socket-retry-');
|
|
39
|
+
const peers=new Set();
|
|
40
|
+
const server=createServer(s=>{peers.add(s);s.on('close',()=>peers.delete(s));});
|
|
41
|
+
const browser=await createBrowser({spareRenderer:false});
|
|
42
|
+
try{
|
|
43
|
+
await Bun.write(dir+'/main.qml',`import QtQuick\nimport Quickshell.Io
|
|
44
|
+
Item { Socket {id:s; path:${JSON.stringify(dir+'/retry.sock')};connected:true
|
|
45
|
+
onError:error=>{globalThis.socketError=error;}
|
|
46
|
+
Component.onCompleted:globalThis.socketObject=s
|
|
47
|
+
} }`);
|
|
48
|
+
await browser.context.navigate('file://'+dir+'/main.qml');
|
|
49
|
+
for(let i=0;i<100;i++){if(await browser.context.evaluate('!!globalThis.socketError'))break;await Bun.sleep(10);}
|
|
50
|
+
expect(await browser.context.evaluate('socketObject.connected')).toBe(false);
|
|
51
|
+
expect(await browser.context.evaluate('socketError')).toBe('ENOENT');
|
|
52
|
+
await new Promise(r=>server.listen(dir+'/retry.sock',r));
|
|
53
|
+
await browser.context.evaluate('socketObject.connected=true');
|
|
54
|
+
for(let i=0;i<100;i++){if(await browser.context.evaluate('socketObject.connected'))break;await Bun.sleep(10);}
|
|
55
|
+
expect(await browser.context.evaluate('socketObject.connected')).toBe(true);
|
|
56
|
+
await browser.context.navigate('data:text/html,<p>ordinary page</p>');
|
|
57
|
+
for(let i=0;i<100&&peers.size;i++)await Bun.sleep(10);
|
|
58
|
+
expect(peers.size).toBe(0);
|
|
59
|
+
}finally{browser.close();for(const p of peers)p.destroy();await new Promise(r=>server.close(r));await rm(dir,{recursive:true,force:true});}
|
|
60
|
+
},20000);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {test,expect,beforeAll} from 'bun:test';
|
|
2
|
+
import init from '../usr/lib/canvaskit/canvaskit.js';
|
|
3
|
+
import {compileFragment,makeFragmentShader,translateFragment} from '../lib/qml/shader/fragment.js';
|
|
4
|
+
let kit;
|
|
5
|
+
beforeAll(async()=>{kit=await init({wasmBinary:await Bun.file(new URL('../usr/lib/canvaskit/canvaskit.wasm',import.meta.url)).arrayBuffer()});});
|
|
6
|
+
const header=`#version 440
|
|
7
|
+
layout(location=0) in vec2 qt_TexCoord0;
|
|
8
|
+
layout(location=0) out vec4 fragColor;
|
|
9
|
+
layout(std140,binding=0) uniform buf {mat4 qt_Matrix;float qt_Opacity;};`;
|
|
10
|
+
function pixel(compiled,options,x=0,y=0){
|
|
11
|
+
const surface=kit.MakeSurface(options.width,options.height),paint=new kit.Paint();
|
|
12
|
+
const shader=makeFragmentShader(compiled,options);
|
|
13
|
+
try{
|
|
14
|
+
paint.setShader(shader);surface.getCanvas().drawRect(kit.XYWHRect(0,0,options.width,options.height),paint);surface.flush();
|
|
15
|
+
const image=surface.makeImageSnapshot();
|
|
16
|
+
try{return Array.from(image.readPixels(x,y,{width:1,height:1,colorType:kit.ColorType.RGBA_8888,alphaType:kit.AlphaType.Unpremul,colorSpace:kit.ColorSpace.SRGB}));}finally{image.delete();}
|
|
17
|
+
}finally{paint.delete();shader.delete();surface.delete();}
|
|
18
|
+
}
|
|
19
|
+
test('Qt fragment uniform and coordinate mapping executes on CPU raster surface',()=>{
|
|
20
|
+
const c=compileFragment(kit,header+'uniform int mode; void main(){fragColor=mode==2?vec4(qt_TexCoord0,0,1):vec4(0);fragColor*=qt_Opacity;}');
|
|
21
|
+
try{const p=pixel(c,{width:16,height:16,uniforms:{mode:2}},8,8);expect(p).toEqual([135,135,0,255]);}finally{c.effect.delete();}
|
|
22
|
+
});
|
|
23
|
+
test('CPU child texture sampling and textureSize preserve source pixel dimensions',()=>{
|
|
24
|
+
const image=kit.MakeImage({width:2,height:1,colorType:kit.ColorType.RGBA_8888,alphaType:kit.AlphaType.Premul,colorSpace:kit.ColorSpace.SRGB},new Uint8Array([255,0,0,255,0,255,0,255]),8);
|
|
25
|
+
const child=image.makeShaderOptions(kit.TileMode.Clamp,kit.TileMode.Clamp,kit.FilterMode.Nearest,kit.MipmapMode.None);
|
|
26
|
+
const c=compileFragment(kit,header+'layout(binding=1) uniform sampler2D source;void main(){if(textureSize(source,0)!=ivec2(2,1)){fragColor=vec4(0,0,1,1);return;}fragColor=texture(source,qt_TexCoord0);}');
|
|
27
|
+
try{
|
|
28
|
+
const options={width:4,height:2,textures:{source:{shader:child,width:2,height:1}}};
|
|
29
|
+
expect(pixel(c,options,0,0)).toEqual([255,0,0,255]);
|
|
30
|
+
expect(pixel(c,options,3,0)).toEqual([0,255,0,255]);
|
|
31
|
+
expect(()=>makeFragmentShader(c,{width:1,height:1})).toThrow('Missing shader texture');
|
|
32
|
+
}finally{c.effect.delete();child.delete();image.delete();}
|
|
33
|
+
});
|
|
34
|
+
test('unsupported shader interfaces fail rather than rendering a success placeholder',()=>{
|
|
35
|
+
expect(()=>translateFragment('#include "missing"')).toThrow('preprocessor');
|
|
36
|
+
expect(()=>translateFragment(header+'void main(){discard;}')).toThrow('Unsupported');
|
|
37
|
+
expect(()=>compileFragment(kit,header+'void main(){fragColor=unknown();}')).toThrow('compilation failed');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
import {deflateSync} from 'node:zlib';
|
|
41
|
+
import {fragmentSource} from '../lib/qml/shader/qsb.js';
|
|
42
|
+
function qsb(source){
|
|
43
|
+
const text=Buffer.from(source),head=Buffer.alloc(28),tail=Buffer.from([0,0,0,4,109,97,105,110]);
|
|
44
|
+
[9,4,1,150,0,0,text.length].forEach((n,i)=>head.writeUInt32BE(n,i*4));
|
|
45
|
+
const raw=Buffer.concat([head,text,tail]),size=Buffer.alloc(4);size.writeUInt32BE(raw.length);
|
|
46
|
+
return Buffer.concat([size,deflateSync(raw)]);
|
|
47
|
+
}
|
|
48
|
+
test('QSB source records are validated and generated struct uniforms compile',()=>{
|
|
49
|
+
const source='#version 150\nstruct buf {float gain;};uniform buf _12;out vec4 fragColor;void main(){fragColor=vec4(_12.gain,0,0,1);}';
|
|
50
|
+
expect(fragmentSource(qsb(source))).toBe(source);
|
|
51
|
+
const c=compileFragment(kit,fragmentSource(qsb(source)));
|
|
52
|
+
try{expect(pixel(c,{width:1,height:1,uniforms:{gain:1}})).toEqual([255,0,0,255]);}finally{c.effect.delete();}
|
|
53
|
+
expect(()=>fragmentSource(qsb(source).subarray(0,10))).toThrow();
|
|
54
|
+
expect(()=>fragmentSource(qsb(source.replace('#version 150','#version 100')))).toThrow('no supported');
|
|
55
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {RendererHost} from '../lib/renderer/host.js';
|
|
3
|
+
|
|
4
|
+
test('Window.update coalesces requests and emits frame signals, including reentrant requests',async()=>{
|
|
5
|
+
const host=new RendererHost({timeout:20000});
|
|
6
|
+
try {
|
|
7
|
+
await host.start();
|
|
8
|
+
await host.call('loadDocument',{url:'data:text/x-qml,window-update',contentType:'text/x-qml',source:`import QtQuick
|
|
9
|
+
import Quickshell
|
|
10
|
+
FloatingWindow {width:200;height:100
|
|
11
|
+
Item {id:child;Component.onCompleted:globalThis.testWindow=child.Window.window}
|
|
12
|
+
}`});
|
|
13
|
+
await host.call('whenLoaded');
|
|
14
|
+
const result=await host.call('evaluate',`(()=>{
|
|
15
|
+
const original=requestAnimationFrame, cancel=cancelAnimationFrame;
|
|
16
|
+
const queue=new Map();let next=0,animated=0,synced=0;
|
|
17
|
+
globalThis.requestAnimationFrame=fn=>{queue.set(++next,fn);return next};
|
|
18
|
+
globalThis.cancelAnimationFrame=id=>queue.delete(id);
|
|
19
|
+
const w=testWindow;
|
|
20
|
+
const animate=()=>{animated++;if(animated===1)w.update()};
|
|
21
|
+
const sync=()=>synced++;
|
|
22
|
+
const step=()=>{const [id,fn]=queue.entries().next().value;queue.delete(id);fn(0)};
|
|
23
|
+
try {
|
|
24
|
+
w.update();w.update();const coalesced=queue.size;
|
|
25
|
+
w.afterAnimating.connect(animate);w.afterSynchronizing.connect(sync);
|
|
26
|
+
step();const reentrant=queue.size;
|
|
27
|
+
step();
|
|
28
|
+
w.afterAnimating.disconnect(animate);w.afterSynchronizing.disconnect(sync);
|
|
29
|
+
step();const idle=queue.size;
|
|
30
|
+
w.update();step();step();const idleAfterUnobserved=queue.size;
|
|
31
|
+
return {coalesced,reentrant,animated,synced,idle,idleAfterUnobserved};
|
|
32
|
+
}finally{globalThis.requestAnimationFrame=original;globalThis.cancelAnimationFrame=cancel}
|
|
33
|
+
})()`);
|
|
34
|
+
expect(result).toEqual({coalesced:1,reentrant:1,animated:2,synced:2,idle:0,idleAfterUnobserved:0});
|
|
35
|
+
}finally{host.close()}
|
|
36
|
+
},30000);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {test, expect} from "bun:test";
|
|
2
|
+
import {renderMinInterval, RENDER_INTERVAL_FLAG} from "../lib/renderer/render-interval.js";
|
|
3
|
+
import {RendererHost} from "../lib/renderer/host.js";
|
|
4
|
+
|
|
5
|
+
test("render interval validates boundaries without silently accepting malformed values", () => {
|
|
6
|
+
for (const [value, expected] of [[undefined,16],["0",0],["8",8],["16",16],["60000",60000]]) {
|
|
7
|
+
const warnings=[];expect(renderMinInterval(value,s=>warnings.push(s))).toBe(expected);expect(warnings).toEqual([]);
|
|
8
|
+
}
|
|
9
|
+
for (const value of ["","-1","60001","1.5","8ms","Infinity","NaN"," 8 "]) {
|
|
10
|
+
const warnings=[];expect(renderMinInterval(value,s=>warnings.push(s))).toBe(16);expect(warnings.length).toBe(1);
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("renderer receives configured interval on launch without inheriting host environment", async () => {
|
|
15
|
+
const old=process.env.BUNINU_RENDER_MIN_INTERVAL, secret=process.env.BUNINU_TEST_PRIVATE_ENV;
|
|
16
|
+
let host;
|
|
17
|
+
try {
|
|
18
|
+
process.env.BUNINU_TEST_PRIVATE_ENV="must-not-leak";
|
|
19
|
+
for (const interval of ["8","0","60000"]) {
|
|
20
|
+
process.env.BUNINU_RENDER_MIN_INTERVAL=interval;
|
|
21
|
+
host=new RendererHost({entry:new URL("./fixtures/fake-renderer.js",import.meta.url).pathname});
|
|
22
|
+
await host.start();
|
|
23
|
+
const result=await host.call("configuration");
|
|
24
|
+
expect(result.args).toContain(RENDER_INTERVAL_FLAG+interval);
|
|
25
|
+
expect(result.secret).toBeUndefined();
|
|
26
|
+
host.close();
|
|
27
|
+
}
|
|
28
|
+
} finally {
|
|
29
|
+
host?.close();
|
|
30
|
+
if(old===undefined)delete process.env.BUNINU_RENDER_MIN_INTERVAL;else process.env.BUNINU_RENDER_MIN_INTERVAL=old;
|
|
31
|
+
if(secret===undefined)delete process.env.BUNINU_TEST_PRIVATE_ENV;else process.env.BUNINU_TEST_PRIVATE_ENV=secret;
|
|
32
|
+
}
|
|
33
|
+
});
|
package/test/text-layout.test.js
CHANGED
|
@@ -675,3 +675,18 @@ test("an out-of-flow box keeps its static position when the block around its pla
|
|
|
675
675
|
(document) => { document.getElementById("edit").textContent = "a longer paragraph that wraps onto two lines"; });
|
|
676
676
|
expect(rankedLayout(retained)).toEqual(rankedLayout(clean));
|
|
677
677
|
});
|
|
678
|
+
|
|
679
|
+
test("anonymous flex text items do not reuse their parent's intrinsic width", () => {
|
|
680
|
+
const { document, window } = parseHTMLDocument(`<body style="margin:0"><div style="display:flex">
|
|
681
|
+
<span style="display:flex"><a>hash</a>·<time>Today</time>!</span><b>History</b>
|
|
682
|
+
</div></body>`);
|
|
683
|
+
const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
|
|
684
|
+
const engine = new LayoutEngine({ measureText: text => text.length * 10 });
|
|
685
|
+
for (const width of [500, 180, 500]) {
|
|
686
|
+
const layout = engine.layout(tree, { x:0, y:0, width });
|
|
687
|
+
expect(layout.fragments.map(f => [f.text, f.x, f.width])).toEqual([
|
|
688
|
+
["hash", 0, 40], ["·", 40, 10], ["Today", 50, 50], ["!", 100, 10], ["History", 110, 70],
|
|
689
|
+
]);
|
|
690
|
+
}
|
|
691
|
+
window.happyDOM.abort();
|
|
692
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
test('live title reads do not flush pending layout and paint, including empty titles',async()=>{
|
|
3
|
+
const child=Bun.spawn([process.execPath,new URL('./fixtures/title-no-paint.js',import.meta.url).pathname],{stdout:'pipe',stderr:'pipe'});
|
|
4
|
+
const [exit,stderr]=await Promise.all([child.exited,new Response(child.stderr).text()]);
|
|
5
|
+
expect(stderr).toBe('');expect(exit).toBe(0);
|
|
6
|
+
},15000);
|