buninu-browser 0.3.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.
Files changed (296) hide show
  1. package/LICENSES/jsmdcui.txt +22 -0
  2. package/README.md +40 -4
  3. package/frontends/wayland/LICENSE-land +21 -0
  4. package/frontends/wayland/README.md +152 -0
  5. package/frontends/wayland/casty/LICENSE +21 -0
  6. package/frontends/wayland/casty/bookmarks.js +32 -0
  7. package/frontends/wayland/casty/click.js +96 -0
  8. package/frontends/wayland/casty/commands.js +61 -0
  9. package/frontends/wayland/casty/config.js +45 -0
  10. package/frontends/wayland/casty/download-directory.js +28 -0
  11. package/frontends/wayland/casty/download.js +49 -0
  12. package/frontends/wayland/casty/hints.js +334 -0
  13. package/frontends/wayland/casty/keys.js +56 -0
  14. package/frontends/wayland/casty/page-commands.js +80 -0
  15. package/frontends/wayland/casty/prompt-width.js +148 -0
  16. package/frontends/wayland/casty/urlbar.js +606 -0
  17. package/frontends/wayland/client.js +88 -0
  18. package/frontends/wayland/clipboard.js +80 -0
  19. package/frontends/wayland/index.js +130 -0
  20. package/frontends/wayland/input.js +75 -0
  21. package/frontends/wayland/protocol.js +55 -0
  22. package/frontends/wayland/protocols/wayland.xml +3248 -0
  23. package/frontends/wayland/protocols/xdg-shell.xml +1415 -0
  24. package/frontends/wayland/transport.js +120 -0
  25. package/frontends/wayland/urlbar.js +162 -0
  26. package/index.js +28 -1
  27. package/lib/audio/shared-connection.js +31 -0
  28. package/lib/cdp-server.js +15 -2
  29. package/lib/clipboard-command.js +10 -0
  30. package/lib/clipboard-powershell.js +22 -0
  31. package/lib/clipboard.js +37 -2
  32. package/lib/document-types.js +11 -0
  33. package/lib/headless-shell.js +54 -12
  34. package/lib/layout/text-layout.js +17 -9
  35. package/lib/paint/display-list.js +6 -1
  36. package/lib/qml/README.md +1427 -0
  37. package/lib/qml/builtins.js +771 -0
  38. package/lib/qml/canvas-dom.js +115 -0
  39. package/lib/qml/canvas.js +76 -0
  40. package/lib/qml/document.js +1199 -0
  41. package/lib/qml/fonts.js +69 -0
  42. package/lib/qml/format.js +9 -0
  43. package/lib/qml/host.js +352 -0
  44. package/lib/qml/images.js +199 -0
  45. package/lib/qml/launcher.js +132 -0
  46. package/lib/qml/loader.js +521 -0
  47. package/lib/qml/media-renderer.js +49 -0
  48. package/lib/qml/media.js +86 -0
  49. package/lib/qml/models.js +622 -0
  50. package/lib/qml/parser.js +381 -0
  51. package/lib/qml/qs +5 -0
  52. package/lib/qml/qscasty +3 -0
  53. package/lib/qml/qsheadless +3 -0
  54. package/lib/qml/qswayland +3 -0
  55. package/lib/qml/reactive.js +182 -0
  56. package/lib/qml/runtime.js +3603 -0
  57. package/lib/qml/shader/fragment.js +83 -0
  58. package/lib/qml/shader/qsb.js +27 -0
  59. package/lib/qml/shader/renderer.js +39 -0
  60. package/lib/qml/shader/runtime.js +61 -0
  61. package/lib/qml/shapes.js +251 -0
  62. package/lib/qml/shortcuts.js +65 -0
  63. package/lib/qml/text.js +109 -0
  64. package/lib/qml/textures.js +126 -0
  65. package/lib/qml/wayland-clipboard.js +32 -0
  66. package/lib/qml/window-constraints.js +16 -0
  67. package/lib/qml/worker-thread.js +70 -0
  68. package/lib/render-tree/index.js +16 -1
  69. package/lib/renderer/bindings.js +16 -1
  70. package/lib/renderer/canvas-2d.js +29 -0
  71. package/lib/renderer/document-selection.js +19 -0
  72. package/lib/renderer/host.js +45 -17
  73. package/lib/renderer/main.js +21 -6
  74. package/lib/renderer/page-realm.js +20 -10
  75. package/lib/renderer/page-renderer.js +497 -42
  76. package/lib/renderer/pointer-move.js +5 -3
  77. package/lib/renderer/render-interval.js +9 -0
  78. package/lib/renderer/text-input-paint.js +54 -0
  79. package/lib/renderer/text-input.js +57 -0
  80. package/lib/style/computed-style.js +10 -4
  81. package/package.json +1 -1
  82. package/test/audio-connection-lifetime.test.js +46 -0
  83. package/test/canvas-paint.test.js +8 -0
  84. package/test/clipboard.test.js +19 -0
  85. package/test/computed-style.test.js +12 -0
  86. package/test/document-selection.test.js +27 -0
  87. package/test/double-click.test.js +85 -0
  88. package/test/fixtures/canvas-damage.js +40 -0
  89. package/test/fixtures/fake-renderer.js +1 -0
  90. package/test/fixtures/flea-helpers/Dockerfile +36 -0
  91. package/test/fixtures/flea-helpers/bat.mjs +43 -0
  92. package/test/fixtures/flea-helpers/bin/bat +3 -0
  93. package/test/fixtures/flea-helpers/bin/bwrap +10 -0
  94. package/test/fixtures/flea-helpers/bin/bwrap-real-filter +9 -0
  95. package/test/fixtures/flea-helpers/bin/glow +3 -0
  96. package/test/fixtures/flea-helpers/bin/qs +3 -0
  97. package/test/fixtures/flea-helpers/bin/unsupported +3 -0
  98. package/test/fixtures/flea-helpers/check-highlighting.mjs +47 -0
  99. package/test/fixtures/flea-helpers/check-media.mjs +29 -0
  100. package/test/fixtures/flea-helpers/check-preview.mjs +27 -0
  101. package/test/fixtures/flea-helpers/check.mjs +36 -0
  102. package/test/fixtures/flea-helpers/highlight-cases.json +227 -0
  103. package/test/fixtures/flea-helpers/source-mime.xml +28 -0
  104. package/test/fixtures/flea-helpers/syntax-compat.mjs +23 -0
  105. package/test/fixtures/flea-thumbnail-stub/flea-placeholder.thumbnailer +4 -0
  106. package/test/fixtures/flea-thumbnail-stub/flea-thumbnail-stub +3 -0
  107. package/test/fixtures/omastorm-helpers/Dockerfile +35 -0
  108. package/test/fixtures/omastorm-helpers/check.mjs +56 -0
  109. package/test/fixtures/omastorm-helpers/launch.mjs +24 -0
  110. package/test/fixtures/omastorm-helpers/prepare.mjs +17 -0
  111. package/test/fixtures/omastorm-helpers/runtime.mjs +32 -0
  112. package/test/fixtures/pointer-paint-batching.js +23 -0
  113. package/test/fixtures/qml/flea/EscapeHint.qml +11 -0
  114. package/test/fixtures/qml/flea/LICENSE +21 -0
  115. package/test/fixtures/qml/flea/README.md +25 -0
  116. package/test/fixtures/qml/flea/Theme.qml +8 -0
  117. package/test/fixtures/qml/flea/demo.qml +24 -0
  118. package/test/fixtures/qml/flea/qmldir +3 -0
  119. package/test/fixtures/qml/flea-theme/LICENSE-Flea +21 -0
  120. package/test/fixtures/qml/flea-theme/LICENSE-Omarchy +20 -0
  121. package/test/fixtures/qml/flea-theme/README.md +70 -0
  122. package/test/fixtures/qml/flea-theme/button.qml +17 -0
  123. package/test/fixtures/qml/flea-theme/demo.qml +27 -0
  124. package/test/fixtures/qml/flea-theme/field.qml +21 -0
  125. package/test/fixtures/qml/flea-theme/imports/qs/Commons/BorderGeometry.js +373 -0
  126. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Color.qml +258 -0
  127. package/test/fixtures/qml/flea-theme/imports/qs/Commons/I18n.qml +118 -0
  128. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Numbers.js +19 -0
  129. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Plural.js +91 -0
  130. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Style.qml +561 -0
  131. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Util.qml +188 -0
  132. package/test/fixtures/qml/flea-theme/imports/qs/Commons/qmldir +13 -0
  133. package/test/fixtures/qml/flea-theme/segment.qml +17 -0
  134. package/test/fixtures/qml/flea-theme/settings.qml +58 -0
  135. package/test/fixtures/qml/flea-theme/text-metrics.qml +10 -0
  136. package/test/fixtures/qml/flea-theme/ui/DialogButton.qml +150 -0
  137. package/test/fixtures/qml/flea-theme/ui/DialogField.qml +147 -0
  138. package/test/fixtures/qml/flea-theme/ui/EscapeHint.qml +11 -0
  139. package/test/fixtures/qml/flea-theme/ui/Glass.qml +114 -0
  140. package/test/fixtures/qml/flea-theme/ui/Glyph.qml +54 -0
  141. package/test/fixtures/qml/flea-theme/ui/GlyphSolid.qml +22 -0
  142. package/test/fixtures/qml/flea-theme/ui/Reveal.qml +54 -0
  143. package/test/fixtures/qml/flea-theme/ui/SettingsRuler.qml +85 -0
  144. package/test/fixtures/qml/flea-theme/ui/SettingsSegment.qml +85 -0
  145. package/test/fixtures/qml/flea-theme/ui/StableModel.qml +89 -0
  146. package/test/fixtures/qml/flea-theme/ui/Theme.qml +460 -0
  147. package/test/fixtures/qml/flea-theme/ui/ThemeColors.qml +43 -0
  148. package/test/fixtures/qml/flea-theme/ui/ViewState.qml +565 -0
  149. package/test/fixtures/qml/flea-theme/ui/js/Anchor.js +379 -0
  150. package/test/fixtures/qml/flea-theme/ui/js/AnchorHold.js +162 -0
  151. package/test/fixtures/qml/flea-theme/ui/js/Archive.js +157 -0
  152. package/test/fixtures/qml/flea-theme/ui/js/BackReturn.js +110 -0
  153. package/test/fixtures/qml/flea-theme/ui/js/Buttons.js +54 -0
  154. package/test/fixtures/qml/flea-theme/ui/js/ColumnMenu.js +111 -0
  155. package/test/fixtures/qml/flea-theme/ui/js/Columns.js +298 -0
  156. package/test/fixtures/qml/flea-theme/ui/js/Contrast.js +157 -0
  157. package/test/fixtures/qml/flea-theme/ui/js/Density.js +44 -0
  158. package/test/fixtures/qml/flea-theme/ui/js/DirSizes.js +129 -0
  159. package/test/fixtures/qml/flea-theme/ui/js/ExtThumbs.js +115 -0
  160. package/test/fixtures/qml/flea-theme/ui/js/Filter.js +284 -0
  161. package/test/fixtures/qml/flea-theme/ui/js/FolderSorts.js +108 -0
  162. package/test/fixtures/qml/flea-theme/ui/js/Format.js +305 -0
  163. package/test/fixtures/qml/flea-theme/ui/js/Icons.js +206 -0
  164. package/test/fixtures/qml/flea-theme/ui/js/Input.js +39 -0
  165. package/test/fixtures/qml/flea-theme/ui/js/KeyName.js +64 -0
  166. package/test/fixtures/qml/flea-theme/ui/js/Keymap.js +370 -0
  167. package/test/fixtures/qml/flea-theme/ui/js/Kinds.js +145 -0
  168. package/test/fixtures/qml/flea-theme/ui/js/Lists.js +11 -0
  169. package/test/fixtures/qml/flea-theme/ui/js/MakeDefault.js +186 -0
  170. package/test/fixtures/qml/flea-theme/ui/js/Match.js +27 -0
  171. package/test/fixtures/qml/flea-theme/ui/js/Material.js +109 -0
  172. package/test/fixtures/qml/flea-theme/ui/js/Menu.js +513 -0
  173. package/test/fixtures/qml/flea-theme/ui/js/Motion.js +16 -0
  174. package/test/fixtures/qml/flea-theme/ui/js/Mounts.js +320 -0
  175. package/test/fixtures/qml/flea-theme/ui/js/MouseNav.js +13 -0
  176. package/test/fixtures/qml/flea-theme/ui/js/Nav.js +328 -0
  177. package/test/fixtures/qml/flea-theme/ui/js/Palette.js +33 -0
  178. package/test/fixtures/qml/flea-theme/ui/js/Places.js +236 -0
  179. package/test/fixtures/qml/flea-theme/ui/js/Protocols.js +296 -0
  180. package/test/fixtures/qml/flea-theme/ui/js/RecentMode.js +154 -0
  181. package/test/fixtures/qml/flea-theme/ui/js/Search.js +396 -0
  182. package/test/fixtures/qml/flea-theme/ui/js/Settings.js +476 -0
  183. package/test/fixtures/qml/flea-theme/ui/js/SettingsAbout.js +99 -0
  184. package/test/fixtures/qml/flea-theme/ui/js/SettingsShelf.js +52 -0
  185. package/test/fixtures/qml/flea-theme/ui/js/Sort.js +127 -0
  186. package/test/fixtures/qml/flea-theme/ui/js/StateWords.js +42 -0
  187. package/test/fixtures/qml/flea-theme/ui/js/Swap.js +166 -0
  188. package/test/fixtures/qml/flea-theme/ui/js/TextSize.js +107 -0
  189. package/test/fixtures/qml/flea-theme/ui/js/Thumbs.js +158 -0
  190. package/test/fixtures/qml/flea-theme/ui/js/UiState.js +438 -0
  191. package/test/fixtures/qml/flea-theme/ui/js/Update.js +132 -0
  192. package/test/fixtures/qml/flea-theme/ui/js/UserDirs.js +56 -0
  193. package/test/fixtures/qml/flea-theme/ui/qmldir +227 -0
  194. package/test/fixtures/qml/hello.qml +26 -0
  195. package/test/fixtures/qml/imports/Demo/Toolkit/VersionLabel.qml +2 -0
  196. package/test/fixtures/qml/imports/Demo/Toolkit/VersionLabelNew.qml +2 -0
  197. package/test/fixtures/qml/imports/Demo/Toolkit/qmldir +3 -0
  198. package/test/fixtures/qml/imports/components/Badge.qml +15 -0
  199. package/test/fixtures/qml/imports/logic/base.js +2 -0
  200. package/test/fixtures/qml/imports/logic/counter.js +4 -0
  201. package/test/fixtures/qml/imports/logic/math.js +3 -0
  202. package/test/fixtures/qml/imports/logic/shared.js +3 -0
  203. package/test/fixtures/qml/imports/main.qml +21 -0
  204. package/test/fixtures/qml/imports/theme/Theme.qml +8 -0
  205. package/test/fixtures/qml/imports/theme/qmldir +2 -0
  206. package/test/fixtures/qml/multiply.qml +61 -0
  207. package/test/fixtures/qml-canvas-dom.js +90 -0
  208. package/test/fixtures/qml-fonts/main.qml +10 -0
  209. package/test/fixtures/qml-locale/check.mjs +6 -0
  210. package/test/fixtures/qml-locale/main.qml +8 -0
  211. package/test/fixtures/qml-media-audio.js +39 -0
  212. package/test/fixtures/qml-shader/grab.qml +14 -0
  213. package/test/fixtures/qml-shader/gradient.frag +5 -0
  214. package/test/fixtures/qml-shader/main.qml +14 -0
  215. package/test/fixtures/qml-shader/texture-grab.qml +25 -0
  216. package/test/fixtures/qml-shader/texture-size.frag +5 -0
  217. package/test/fixtures/qml-shader-check.mjs +24 -0
  218. package/test/fixtures/title-no-paint.js +20 -0
  219. package/test/helpers/flea-sandbox.js +48 -0
  220. package/test/helpers/quickshell-fixture.js +125 -0
  221. package/test/html-clipboard-wayland.test.js +47 -0
  222. package/test/index.test.js +13 -0
  223. package/test/manual-casty-without-qml.js +87 -0
  224. package/test/manual-double-click.html +21 -0
  225. package/test/manual-flea-application.js +49 -0
  226. package/test/manual-flea-binary.js +62 -0
  227. package/test/manual-flea-qml.js +9 -0
  228. package/test/manual-flea-settings.js +40 -0
  229. package/test/manual-flea-theme.js +40 -0
  230. package/test/manual-mouse-buttons.html +18 -0
  231. package/test/manual-omastorm-engine.mjs +19 -0
  232. package/test/manual-omastorm-shaders.mjs +28 -0
  233. package/test/manual-omastorm-software.mjs +25 -0
  234. package/test/manual-pointer-scroll.html +43 -0
  235. package/test/manual-qml-removal.js +46 -0
  236. package/test/manual-without-qml.js +45 -0
  237. package/test/pointer-drag.test.js +104 -0
  238. package/test/qml-canvas-dom.test.js +37 -0
  239. package/test/qml-canvas.test.js +21 -0
  240. package/test/qml-clipboard-bridge.test.js +25 -0
  241. package/test/qml-clipboard-wayland.test.js +40 -0
  242. package/test/qml-connections-timing.test.js +95 -0
  243. package/test/qml-controls-window.test.js +182 -0
  244. package/test/qml-dynamic.test.js +401 -0
  245. package/test/qml-flea-app.test.js +146 -0
  246. package/test/qml-flea-backend.test.js +78 -0
  247. package/test/qml-flea-columns-backend.test.js +38 -0
  248. package/test/qml-flea-compile.test.js +78 -0
  249. package/test/qml-flea-launcher.test.js +88 -0
  250. package/test/qml-flea-markdown-backend.test.js +31 -0
  251. package/test/qml-flea-media.test.js +28 -0
  252. package/test/qml-flea-navigation-backend.test.js +35 -0
  253. package/test/qml-flea-navigation-timing.test.js +147 -0
  254. package/test/qml-flea-preview-backend.test.js +53 -0
  255. package/test/qml-flea-previews.test.js +163 -0
  256. package/test/qml-flea-rows.test.js +101 -0
  257. package/test/qml-flea-textures.test.js +59 -0
  258. package/test/qml-flea-view-switch.test.js +33 -0
  259. package/test/qml-flea-wayland.test.js +68 -0
  260. package/test/qml-flea.test.js +34 -0
  261. package/test/qml-fonts.test.js +31 -0
  262. package/test/qml-grid-multiline.test.js +48 -0
  263. package/test/qml-host.test.js +238 -0
  264. package/test/qml-imports.test.js +102 -0
  265. package/test/qml-inherited-handlers.test.js +51 -0
  266. package/test/qml-input-selection.test.js +66 -0
  267. package/test/qml-interactions.test.js +309 -0
  268. package/test/qml-language.test.js +167 -0
  269. package/test/qml-launcher.test.js +54 -0
  270. package/test/qml-layout-text.test.js +205 -0
  271. package/test/qml-locale.test.js +45 -0
  272. package/test/qml-media.test.js +63 -0
  273. package/test/qml-nonvisual-visible.test.js +19 -0
  274. package/test/qml-persisted-delegates.test.js +45 -0
  275. package/test/qml-quickshell-controls.test.js +65 -0
  276. package/test/qml-quickshell-settings.test.js +62 -0
  277. package/test/qml-repeater.test.js +72 -0
  278. package/test/qml-shader-runtime.test.js +6 -0
  279. package/test/qml-shortcuts.test.js +39 -0
  280. package/test/qml-socket.test.js +60 -0
  281. package/test/qml-software-shader.test.js +55 -0
  282. package/test/qml-style-updates.test.js +124 -0
  283. package/test/qml-textures.test.js +66 -0
  284. package/test/qml-views.test.js +332 -0
  285. package/test/qml-window-update.test.js +36 -0
  286. package/test/qml-worker.test.js +69 -0
  287. package/test/qml.test.js +83 -0
  288. package/test/render-interval.test.js +33 -0
  289. package/test/renderer-host.test.js +11 -0
  290. package/test/text-input-pointer.test.js +31 -0
  291. package/test/text-input-scroll.test.js +29 -0
  292. package/test/text-input.test.js +47 -0
  293. package/test/title-no-paint.test.js +6 -0
  294. package/test/wayland-frontend.test.js +83 -0
  295. package/test/wayland-scroll.test.js +40 -0
  296. package/test/wayland-urlbar.test.js +61 -0
@@ -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,124 @@
1
+ import { test, expect } from 'bun:test';
2
+ import { RendererHost } from '../lib/renderer/host.js';
3
+
4
+ test('QML style updates preserve final overrides and clear conditional styles', async () => {
5
+ const host = new RendererHost({ timeout: 20000 });
6
+ try {
7
+ await host.start();
8
+ await host.call('loadDocument', {
9
+ url: 'data:text/x-qml,styles',
10
+ contentType: 'text/x-qml',
11
+ source: `import QtQuick
12
+ Rectangle {
13
+ id: root; width: 320; height: 160; property bool changed: false
14
+ onClicked: { changed = !changed; }
15
+ Rectangle { id: box; width: root.changed ? 120 : 80; height: 30; x: 10
16
+ opacity: root.changed ? 0.5 : 1; color: root.changed ? "blue" : "red"
17
+ gradient: root.changed ? null : ramp
18
+ Gradient { id: ramp; GradientStop { position: 0; color: "red" } GradientStop { position: 1; color: "blue" } }
19
+ }
20
+ Text { id: label; y:40; width:80; height:30; text:"long long text"
21
+ elide: root.changed ? Text.ElideNone : Text.ElideRight
22
+ verticalAlignment: Text.AlignVCenter
23
+ }
24
+ }`,
25
+ });
26
+ await host.call('whenLoaded');
27
+ const styles = () =>
28
+ host.call(
29
+ 'evaluate',
30
+ `JSON.stringify({
31
+ width:box.style.width,opacity:box.style.opacity,
32
+ gradient:box.style.backgroundImage,overflow:label.style.overflow,
33
+ whiteSpace:label.style.whiteSpace,lineHeight:label.style.lineHeight,
34
+ elide:label.style.textOverflow
35
+ })`
36
+ .replaceAll('box.style', 'document.getElementById("box").style')
37
+ .replaceAll('label.style', 'document.getElementById("label").style'),
38
+ );
39
+ const before = JSON.parse(await styles());
40
+ expect(before.width).toBe('80px');
41
+ expect(before.gradient).toContain('linear-gradient');
42
+ expect(before.elide).toBe('ellipsis');
43
+ expect(before.whiteSpace).toBe('nowrap');
44
+ expect(before.lineHeight).toBe('30px');
45
+ await host.call(
46
+ 'evaluate',
47
+ 'document.getElementById("box").style.zIndex="99";document.getElementById("root").click()',
48
+ );
49
+ expect(
50
+ await host.call(
51
+ 'evaluate',
52
+ 'document.getElementById("box").style.zIndex',
53
+ ),
54
+ ).toBe('0');
55
+ const after = JSON.parse(await styles());
56
+ expect(after.width).toBe('120px');
57
+ expect(after.opacity).toBe('0.5');
58
+ expect(after.gradient).toBe('none');
59
+ expect(after.elide).toBe('clip');
60
+ expect(after.whiteSpace).toBe('pre');
61
+ expect(after.overflow).toBe('visible');
62
+ expect(after.lineHeight).toBe('30px');
63
+ await host.call('evaluate', 'document.getElementById("root").click()');
64
+ expect(JSON.parse(await styles())).toEqual(before);
65
+ } finally {
66
+ host.close();
67
+ }
68
+ }, 30000);
69
+
70
+ // Qt retains the long Wrap enum spelling used by styled code viewers.
71
+ test('StyledText can toggle WrapAtWordBoundaryOrAnywhere and grow its line count', async () => {
72
+ const host = new RendererHost({ timeout: 20000 });
73
+ try {
74
+ await host.start();
75
+ await host.call('loadDocument', {
76
+ url: 'data:text/x-qml,wrap-alias', contentType: 'text/x-qml',
77
+ source: `import QtQuick
78
+ Rectangle {
79
+ id: root; width: 320; height: 200; property bool wrapped: false
80
+ onClicked: wrapped = !wrapped
81
+ Text { id: code; width: 90; textFormat: Text.StyledText
82
+ text: "<font color='red'>const</font> averylongidentifierwithoutspaces = 123456789;"
83
+ wrapMode: root.wrapped ? Text.WrapAtWordBoundaryOrAnywhere : Text.NoWrap
84
+ }
85
+ Text { id: count; y: 180; text: code.lineCount }
86
+ }`,
87
+ });
88
+ await host.call('whenLoaded');
89
+ const read = () => host.call('evaluate', `({
90
+ lines: Number(document.getElementById('count').textContent),
91
+ whiteSpace: document.getElementById('code').style.whiteSpace,
92
+ overflowWrap: document.getElementById('code').style.overflowWrap
93
+ })`);
94
+ expect((await read()).lines).toBe(1);
95
+ await host.call('evaluate', "document.getElementById('root').click()");
96
+ const wrapped = await read();
97
+ expect(wrapped.lines).toBeGreaterThan(1);
98
+ expect(wrapped.whiteSpace).toBe('pre-wrap');
99
+ expect(wrapped.overflowWrap).toBe('anywhere');
100
+ await host.call('evaluate', "document.getElementById('root').click()");
101
+ expect((await read()).lines).toBe(1);
102
+ } finally { host.close(); }
103
+ }, 30000);
104
+
105
+ test('Qt.binding attaches to a default property and a plain write detaches it', async () => {
106
+ const host = new RendererHost({timeout:20000});
107
+ try {
108
+ await host.start();
109
+ await host.call('loadDocument',{url:'data:text/x-qml,default-binding',contentType:'text/x-qml',source:`import QtQuick
110
+ Item {id:root;width:100;height:100;property bool shown:true
111
+ Rectangle {id:box;width:40;height:40}
112
+ Component.onCompleted:box.visible=Qt.binding(function(){return root.shown})
113
+ Button {id:toggle;onClicked:root.shown=!root.shown}
114
+ Button {id:detach;onClicked:box.visible=true}
115
+ }`});
116
+ await host.call('whenLoaded');
117
+ const display=()=>host.call('evaluate','document.getElementById("box").style.display');
118
+ const click=id=>host.call('evaluate',`document.getElementById('${id}').click()`);
119
+ expect(await display()).toBe('block');
120
+ await click('toggle');expect(await display()).toBe('none');
121
+ await click('toggle');expect(await display()).toBe('block');
122
+ await click('detach');await click('toggle');expect(await display()).toBe('block');
123
+ }finally{host.close();}
124
+ },30000);
@@ -0,0 +1,66 @@
1
+ import { test, expect } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { createBrowser } from '../lib/headless-shell.js';
4
+ import { loadGraphics } from '../lib/renderer/page-renderer.js';
5
+
6
+ test('ShaderEffectSource freezes, updates, hides sources and releases Loader references', async () => {
7
+ const dir = mkdtempSync('/tmp/qml-textures-');
8
+ const browser = await createBrowser({ spareRenderer: false });
9
+ const { CanvasKit: kit } = await loadGraphics();
10
+ async function pixel(x,y) {
11
+ const image=kit.MakeImageFromEncoded(Buffer.from(await browser.context.screenshot({format:'png'}),'base64'));
12
+ try { return [...image.readPixels(x,y,{width:1,height:1,colorType:kit.ColorType.RGBA_8888,alphaType:kit.AlphaType.Unpremul,colorSpace:kit.ColorSpace.SRGB})]; }
13
+ finally { image.delete(); }
14
+ }
15
+ const run = code => browser.context.evaluate(code);
16
+ async function until(fn) { for(let i=0;i<50;i++){if(await fn())return;await Bun.sleep(20);}throw Error(await run('document.body.innerText+JSON.stringify(errors)')); }
17
+ try {
18
+ await Bun.write(dir+'/main.qml', `import QtQuick
19
+ Rectangle {width:240;height:160;color:"white"
20
+ property int frames:0
21
+ Rectangle {id:source;width:60;height:40;color:"red"}
22
+ Loader {id:layer;x:80;width:60;height:40;active:true
23
+ sourceComponent:ShaderEffectSource {sourceItem:source;hideSource:true;live:false;onScheduledUpdateCompleted:frames+=1}}
24
+ Loader {id:mirror;x:160;width:60;height:40;active:false;sourceComponent:ShaderEffectSource {sourceItem:source;hideSource:true;live:false}}
25
+ Text {id:status;y:120;text:frames+":"+layer.active}
26
+ Button {id:toggle;x:210;y:80;text:"hide";onClicked:layer.item.hideSource=!layer.item.hideSource}
27
+ Button {id:blue;y:80;text:"blue";onClicked:source.color=source.color==="blue"?"red":"blue"}
28
+ Button {id:update;x:70;y:80;text:"update";onClicked:layer.item.scheduleUpdate()}
29
+ Button {id:live;x:140;y:80;text:"live";onClicked:layer.item.live=true}
30
+ Button {id:mirrorOn;y:140;text:"mirror";onClicked:mirror.active=true}
31
+ Button {id:clear;x:70;y:140;text:"clear";onClicked:mirror.item.sourceItem=null}
32
+ Button {id:release;x:140;y:120;text:"release";onClicked:layer.active=false}
33
+ }`);
34
+ await browser.context.addInitScript('globalThis.errors=[];addEventListener("error",e=>errors.push(String(e.error?.stack||e.message)))');
35
+ await browser.context.navigate(Bun.pathToFileURL(dir+'/main.qml').href);
36
+ await until(async()=> (await run('document.getElementById("status")?.textContent'))?.startsWith('1:'));
37
+ expect(await pixel(5,5)).toEqual([255,255,255,255]);
38
+ expect(await pixel(85,5)).toEqual([255,0,0,255]);
39
+ await run('document.getElementById("toggle").click()');await Bun.sleep(30);
40
+ expect(await pixel(5,5)).toEqual([255,0,0,255]);
41
+ await run('document.getElementById("toggle").click()');await Bun.sleep(30);
42
+ expect(await pixel(5,5)).toEqual([255,255,255,255]);
43
+ await run('document.getElementById("blue").click()');await Bun.sleep(50);
44
+ expect(await pixel(85,5)).toEqual([255,0,0,255]);
45
+ await run('document.getElementById("update").click()');
46
+ await until(async()=> (await run('document.getElementById("status").textContent')).startsWith('2:'));
47
+ expect(await pixel(85,5)).toEqual([0,0,255,255]);
48
+ await run('document.getElementById("live").click()');
49
+ await until(async()=> (await run('document.getElementById("status").textContent')).startsWith('3:'));
50
+ await run('document.getElementById("blue").click()');
51
+ await until(async()=> (await run('document.getElementById("status").textContent')).startsWith('4:'));
52
+ expect(await pixel(85,5)).toEqual([255,0,0,255]);
53
+ await Bun.sleep(120);
54
+ expect(await run('document.getElementById("status").textContent')).toBe('4:true');
55
+ await run('document.getElementById("mirrorOn").click()');
56
+ await until(async()=> (await pixel(165,5))[1]===0);
57
+ await run('document.getElementById("release").click()');await Bun.sleep(30);
58
+ expect(await pixel(5,5)).toEqual([255,255,255,255]);
59
+ expect(await pixel(165,5)).toEqual([255,0,0,255]);
60
+ await run('document.getElementById("clear").click()');await Bun.sleep(30);
61
+ expect(await pixel(165,5)).toEqual([255,255,255,255]);
62
+ expect(await pixel(5,5)).toEqual([255,0,0,255]);
63
+ expect(await pixel(85,5)).toEqual([255,255,255,255]);
64
+ expect(await run('errors')).toEqual([]);
65
+ } finally { browser.close();rmSync(dir,{recursive:true,force:true}); }
66
+ },30000);
@@ -0,0 +1,332 @@
1
+ import { test, expect } from 'bun:test';
2
+ import { RendererHost } from '../lib/renderer/host.js';
3
+ const wait = async (fn, predicate) => {
4
+ let value;
5
+ for (let i = 0; i < 80; i++) {
6
+ value = await fn();
7
+ if (predicate(value)) return value;
8
+ await Bun.sleep(15);
9
+ }
10
+ throw Error('View did not settle: ' + JSON.stringify(value));
11
+ };
12
+ test('ListView virtualizes a large DelegateModel, scrolls, updates count and reuses delegates', async () => {
13
+ const host = new RendererHost({ timeout: 20000 });
14
+ try {
15
+ await host.start();
16
+ await host.call('loadDocument', {
17
+ url: 'data:text/x-qml,view',
18
+ contentType: 'text/x-qml',
19
+ source: `
20
+ import QtQuick
21
+ import QtQml.Models
22
+ Item {
23
+ id:root;width:300;height:200
24
+ ListView {id:view;width:200;height:100;reuseItems:true
25
+ model:DelegateModel {id:rows;model:100000000;filterOnGroup:"shown";groups:DelegateModelGroup {name:"shown";includeByDefault:false}
26
+ Component.onCompleted:items.addGroups(0,2000,"shown")
27
+ }
28
+ delegate:Text {required property int index;text:"row "+index;height:20;width:200}
29
+ }
30
+ Text {id:count;y:120;text:view.count}
31
+ Button {id:scroll;y:150;onClicked:view.positionViewAtIndex(1000,ListView.Beginning)}
32
+ Button {id:shrink;x:100;y:150;onClicked:{rows.groups.removeGroups(5,1995,"shown");view.positionViewAtBeginning()}}
33
+ }`,
34
+ });
35
+ await host.call('whenLoaded');
36
+ const read = async () =>
37
+ JSON.parse(
38
+ await host.call(
39
+ 'evaluate',
40
+ 'JSON.stringify({count:document.getElementById("count").textContent,text:document.body.innerText,n:document.querySelectorAll("[data-qml-type=Text]").length})',
41
+ ),
42
+ );
43
+ await wait(read, (v) => v.count === '2000');
44
+ let result = await read();
45
+ expect(result.n).toBeLessThan(20);
46
+ expect(result.text).toContain('row 0');
47
+ await host.call('evaluate', 'document.getElementById("scroll").click()');
48
+ result = await wait(read, (v) => v.text.includes('row 1000'));
49
+ expect(result.n).toBeLessThan(25);
50
+ await host.call('evaluate', 'document.getElementById("shrink").click()');
51
+ result = await wait(
52
+ read,
53
+ (v) => v.count === '5' && v.text.includes('row 0'),
54
+ );
55
+ expect(result.n).toBeLessThan(25);
56
+ } finally {
57
+ host.close();
58
+ }
59
+ }, 30000);
60
+
61
+ test('Flickable routes default children to contentItem and responds to wheel without moving explicit overlays', async () => {
62
+ const host = new RendererHost({ timeout: 20000 });
63
+ try {
64
+ await host.start();
65
+ await host.call('loadDocument', {
66
+ url: 'data:text/x-qml,flick',
67
+ contentType: 'text/x-qml',
68
+ source: `
69
+ import QtQuick
70
+ Flickable {id:root;width:200;height:100;contentWidth:200;contentHeight:500;clip:true
71
+ Rectangle {id:body;width:parent.width;height:500;color:"red"}
72
+ Text {id:overlay;parent:root;text:"fixed"}
73
+ Text {id:status;parent:root;y:20;text:root.contentY+":"+body.parent.height}
74
+ }`,
75
+ });
76
+ await host.call('whenLoaded');
77
+ expect(
78
+ await host.call(
79
+ 'evaluate',
80
+ 'document.getElementById("body").parentElement.hasAttribute("data-qml-content")',
81
+ ),
82
+ ).toBe(true);
83
+ expect(
84
+ await host.call(
85
+ 'evaluate',
86
+ 'document.getElementById("overlay").parentElement.id',
87
+ ),
88
+ ).toBe('root');
89
+ await host.call(
90
+ 'evaluate',
91
+ 'document.getElementById("root").dispatchEvent(new WheelEvent("wheel",{deltaY:50,bubbles:true,cancelable:true}))',
92
+ );
93
+ await wait(
94
+ () =>
95
+ host.call('evaluate', 'document.getElementById("status").textContent'),
96
+ (v) => v === '50:500',
97
+ );
98
+ expect(
99
+ await host.call(
100
+ 'evaluate',
101
+ 'document.getElementById("body").parentElement.style.top',
102
+ ),
103
+ ).toBe('-50px');
104
+ } finally {
105
+ host.close();
106
+ }
107
+ }, 30000);
108
+
109
+ test('empty Loaders, decoration Items and DropAreas do not swallow pointer activation', async () => {
110
+ const host = new RendererHost({ timeout: 20000 });
111
+ try {
112
+ await host.start();
113
+ await host.call('loadDocument', {
114
+ url: 'data:text/x-qml,pointer',
115
+ contentType: 'text/x-qml',
116
+ source: `
117
+ import QtQuick
118
+ Item {width:200;height:100;property int clicks:0
119
+ Rectangle {width:80;height:40;color:"red";TapHandler {onTapped:clicks++}}
120
+ DropArea {anchors.fill:parent}
121
+ Loader {anchors.fill:parent;active:false;z:3}
122
+ Item {anchors.fill:parent;z:4}
123
+ Text {id:status;y:50;text:clicks}
124
+ }`,
125
+ });
126
+ await host.call('whenLoaded');
127
+ await host.call('click', 20, 20);
128
+ await wait(
129
+ () =>
130
+ host.call('evaluate', 'document.getElementById("status").textContent'),
131
+ (v) => v === '1',
132
+ );
133
+ } finally {
134
+ host.close();
135
+ }
136
+ }, 30000);
137
+
138
+ test('XmlListModel reads XBEL attributes through Qt role indices and reports unsupported queries', async () => {
139
+ const host = new RendererHost({ timeout: 20000 });
140
+ try {
141
+ await host.start();
142
+ await host.call('loadDocument', {
143
+ url: 'data:text/x-qml,xml',
144
+ contentType: 'text/x-qml',
145
+ source: `
146
+ import QtQuick
147
+ import QtQml.XmlListModel
148
+ Item {
149
+ XmlListModel {id:model;query:"/xbel/bookmark";xml:"<xbel><bookmark href='file:///tmp/a' visited='2026-10-22'/><bookmark href='file:///tmp/b'/></xbel>"
150
+ XmlListModelRole {name:"href";attributeName:"href"}
151
+ XmlListModelRole {name:"visited";attributeName:"visited"}
152
+ }
153
+ Text {id:status;text:model.status+":"+model.count+":"+model.data(model.index(1,0),Qt.UserRole)}
154
+ Button {id:bad;onClicked:model.query="//bookmark[@bad]"}
155
+ }`,
156
+ });
157
+ await host.call('whenLoaded');
158
+ await wait(
159
+ () =>
160
+ host.call('evaluate', 'document.getElementById("status").textContent'),
161
+ (v) => v === '1:2:file:///tmp/b',
162
+ );
163
+ await host.call('evaluate', 'document.getElementById("bad").click()');
164
+ await wait(
165
+ () =>
166
+ host.call('evaluate', 'document.getElementById("status").textContent'),
167
+ (v) => v.startsWith('3:0:'),
168
+ );
169
+ } finally {
170
+ host.close();
171
+ }
172
+ }, 30000);
173
+
174
+ test('unchanged Flea StableModel resizes a virtualized view synchronously and warms reusable rows', async () => {
175
+ const { mkdtempSync, rmSync } = await import('node:fs');
176
+ const dir = mkdtempSync('/tmp/qml-stable-model-');
177
+ const ui = Bun.fileURLToPath(
178
+ new URL('./fixtures/qml/flea-theme/ui/', import.meta.url),
179
+ );
180
+ await Bun.write(
181
+ dir + '/main.qml',
182
+ `import QtQuick
183
+ import ${JSON.stringify(ui)} as Flea
184
+ Item {width:300;height:200;property int total:100
185
+ ListView {id:view;width:200;height:100;reuseItems:true
186
+ model:Flea.StableModel {id:stable;view:view;total:parent.parent.total;warm:true;warmRows:20}
187
+ delegate:Text {required property int index;width:200;height:20;text:"row "+index}
188
+ }
189
+ Text {id:status;y:120;text:view.count+":"+view.currentIndex}
190
+ Button {id:grow;y:150;onClicked:{total=105;stable.resize()}}
191
+ Button {id:scroll;x:80;y:150;onClicked:view.positionViewAtIndex(50,ListView.Beginning)}
192
+ }`,
193
+ );
194
+ const { createBrowser } = await import('../lib/headless-shell.js');
195
+ const browser = await createBrowser({ spareRenderer: false });
196
+ try {
197
+ await browser.context.navigate(Bun.pathToFileURL(dir + '/main.qml').href);
198
+ await wait(
199
+ () =>
200
+ browser.context.evaluate(
201
+ 'document.getElementById("status").textContent',
202
+ ),
203
+ (v) => v === '100:0',
204
+ );
205
+ await browser.context.evaluate('document.getElementById("scroll").click()');
206
+ await wait(
207
+ () => browser.context.evaluate('document.body.innerText'),
208
+ (v) => v.includes('row 50'),
209
+ );
210
+ await browser.context.evaluate('document.getElementById("grow").click()');
211
+ await wait(
212
+ () =>
213
+ browser.context.evaluate(
214
+ 'document.getElementById("status").textContent',
215
+ ),
216
+ (v) => v === '105:0',
217
+ );
218
+ expect(
219
+ await browser.context.evaluate(
220
+ 'document.querySelectorAll("[data-qml-type=Text]").length',
221
+ ),
222
+ ).toBeLessThan(30);
223
+ } finally {
224
+ browser.close();
225
+ rmSync(dir, { recursive: true, force: true });
226
+ }
227
+ }, 30000);
228
+
229
+ test('passive HoverHandler observes a decoration without intercepting the button beneath', async () => {
230
+ const host = new RendererHost({ timeout: 20000 });
231
+ try {
232
+ await host.start();
233
+ await host.call('loadDocument', {
234
+ url: 'data:text/x-qml,hover',
235
+ contentType: 'text/x-qml',
236
+ source: `
237
+ import QtQuick
238
+ Item {width:200;height:120;property int clicks:0
239
+ Rectangle {width:80;height:40;color:"red";TapHandler {onTapped:clicks++}}
240
+ Item {width:80;height:40;z:5;HoverHandler {id:hover}}
241
+ Text {id:status;y:60;text:hover.hovered+":"+clicks}
242
+ }`,
243
+ });
244
+ await host.call('whenLoaded');
245
+ await host.call('move', 20, 20);
246
+ await host.call('click', 20, 20);
247
+ await wait(
248
+ () =>
249
+ host.call('evaluate', 'document.getElementById("status").textContent'),
250
+ (v) => v === 'true:1',
251
+ );
252
+ await host.call('move', 110, 80);
253
+ await wait(
254
+ () =>
255
+ host.call('evaluate', 'document.getElementById("status").textContent'),
256
+ (v) => v === 'false:1',
257
+ );
258
+ } finally {
259
+ host.close();
260
+ }
261
+ }, 30000);
262
+
263
+ test('turning reuseItems off and back on in one handler immediately drains parked delegates', async () => {
264
+ const host = new RendererHost({ timeout: 20000 });
265
+ try {
266
+ await host.start();
267
+ await host.call('loadDocument', {
268
+ url: 'data:text/x-qml,pool',
269
+ contentType: 'text/x-qml',
270
+ source: `import QtQuick
271
+ Item {width:300;height:200
272
+ ListView {id:view;width:200;height:100;reuseItems:true;cacheBuffer:0;model:100
273
+ delegate:Text {required property int index;width:200;height:20;text:"row "+index}
274
+ }
275
+ Button {id:park;y:120;onClicked:{view.displayMarginEnd=300;view.forceLayout();view.displayMarginEnd=0;view.forceLayout()}}
276
+ Button {id:drain;x:80;y:120;onClicked:{view.reuseItems=false;view.reuseItems=true}}
277
+ }`,
278
+ });
279
+ await host.call('whenLoaded');
280
+ const count = () =>
281
+ host.call(
282
+ 'evaluate',
283
+ 'document.querySelectorAll("[data-qml-type=Text]").length',
284
+ );
285
+ await host.call('evaluate', 'document.getElementById("park").click()');
286
+ const parked = await count();
287
+ expect(parked).toBeGreaterThan(10);
288
+ await host.call('evaluate', 'document.getElementById("drain").click()');
289
+ expect(await count()).toBeLessThan(10);
290
+ } finally {
291
+ host.close();
292
+ }
293
+ }, 30000);
294
+
295
+ test('replacing a numeric ListView model resets its scroll before creating new delegates',async()=>{
296
+ const host=new RendererHost({timeout:20000});
297
+ try{
298
+ await host.start();await host.call('loadDocument',{url:'data:text/x-qml,reset',contentType:'text/x-qml',source:`import QtQuick
299
+ Item {width:300;height:200
300
+ ListView {id:view;width:200;height:100;model:100;delegate:Text {required property int index;text:"row "+index;width:200;height:20}}
301
+ Text {id:offset;y:110;text:String(view.contentY)+","+view.model+","+view.count}
302
+ Button {id:scroll;y:140;onClicked:view.positionViewAtIndex(50,ListView.Beginning)}
303
+ Button {id:replace;x:100;y:140;onClicked:view.model=80}
304
+ }`});await host.call('whenLoaded');
305
+ await host.call('evaluate','document.getElementById("scroll").click()');
306
+ await wait(()=>host.call('evaluate','document.getElementById("offset").textContent'),v=>Number(v.split(",")[0])>0);
307
+ await host.call('evaluate','document.getElementById("replace").click()');
308
+ await wait(()=>host.call('evaluate','document.getElementById("offset").textContent'),v=>Number(v.split(",")[0])===0);
309
+ expect(await host.call('evaluate','document.body.innerText')).toContain('row 0');
310
+ }finally{host.close();}
311
+ },30000);
312
+
313
+ test('ListView positions unequal delegates by measured extent and updates following rows when a later row grows',async()=>{
314
+ const host=new RendererHost({timeout:20000});
315
+ try{
316
+ await host.start();await host.call('loadDocument',{url:'data:text/x-qml,variable',contentType:'text/x-qml',source:`import QtQuick
317
+ Item {id:root;width:400;height:400;property int extra:0
318
+ ListView {id:view;width:200;height:300;spacing:5;model:[20,80,35,60]
319
+ delegate:Rectangle {required property int index;required property var modelData;width:200;height:modelData+(index===1?root.extra:0)}
320
+ }
321
+ Text {id:report;x:220}
322
+ Button {id:inspect;y:330;onClicked:report.text=JSON.stringify({rows:[0,1,2,3].map(i=>{var c=view.itemAtIndex(i);return c?[c.y,c.height]:null}),index:view.indexAt(10,110),gap:view.indexAt(10,22),content:view.contentHeight})}
323
+ Button {id:grow;x:100;y:330;onClicked:root.extra=50}
324
+ }`});await host.call('whenLoaded');
325
+ const read=async()=>{await host.call('evaluate','document.getElementById("inspect").click()');return JSON.parse(await host.call('evaluate','document.getElementById("report").textContent'));};
326
+ let state=await wait(read,s=>s.rows[3]?.[0]===150);
327
+ expect(state.rows).toEqual([[0,20],[25,80],[110,35],[150,60]]);expect(state.index).toBe(2);expect(state.gap).toBe(-1);
328
+ await host.call('evaluate','document.getElementById("grow").click()');
329
+ state=await wait(read,s=>s.rows[3]?.[0]===200);
330
+ expect(state.rows).toEqual([[0,20],[25,130],[160,35],[200,60]]);expect(state.content).toBe(265);
331
+ }finally{host.close();}
332
+ },30000);