buninu-browser 0.2.4 → 0.4.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 (249) hide show
  1. package/LICENSES/jsmdcui.txt +22 -0
  2. package/README.md +102 -2
  3. package/frontends/wayland/LICENSE-land +21 -0
  4. package/frontends/wayland/README.md +123 -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 +116 -0
  20. package/frontends/wayland/input.js +56 -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 +158 -0
  26. package/index.js +28 -1
  27. package/lib/clipboard-command.js +10 -0
  28. package/lib/clipboard-powershell.js +22 -0
  29. package/lib/clipboard.js +57 -0
  30. package/lib/document-types.js +11 -0
  31. package/lib/headless-shell.js +78 -13
  32. package/lib/layout/text-layout.js +17 -9
  33. package/lib/paint/display-list.js +5 -1
  34. package/lib/paint/frame.js +35 -3
  35. package/lib/paint/raw-frames.js +47 -0
  36. package/lib/qml/README.md +971 -0
  37. package/lib/qml/builtins.js +700 -0
  38. package/lib/qml/canvas-dom.js +115 -0
  39. package/lib/qml/document.js +1176 -0
  40. package/lib/qml/format.js +9 -0
  41. package/lib/qml/host.js +324 -0
  42. package/lib/qml/images.js +199 -0
  43. package/lib/qml/launcher.js +132 -0
  44. package/lib/qml/loader.js +517 -0
  45. package/lib/qml/media-renderer.js +49 -0
  46. package/lib/qml/media.js +86 -0
  47. package/lib/qml/models.js +622 -0
  48. package/lib/qml/parser.js +381 -0
  49. package/lib/qml/qs +5 -0
  50. package/lib/qml/qscasty +3 -0
  51. package/lib/qml/qsheadless +3 -0
  52. package/lib/qml/qswayland +3 -0
  53. package/lib/qml/reactive.js +159 -0
  54. package/lib/qml/runtime.js +3381 -0
  55. package/lib/qml/shapes.js +243 -0
  56. package/lib/qml/text.js +109 -0
  57. package/lib/qml/textures.js +123 -0
  58. package/lib/qml/wayland-clipboard.js +32 -0
  59. package/lib/qml/worker-thread.js +70 -0
  60. package/lib/render-tree/index.js +16 -1
  61. package/lib/renderer/bindings.js +11 -0
  62. package/lib/renderer/canvas-2d.js +13 -0
  63. package/lib/renderer/compositor-channel.js +103 -0
  64. package/lib/renderer/compositor.js +25 -33
  65. package/lib/renderer/flags.js +1 -1
  66. package/lib/renderer/host.js +128 -92
  67. package/lib/renderer/main.js +38 -4
  68. package/lib/renderer/page-realm.js +32 -1
  69. package/lib/renderer/page-renderer.js +250 -21
  70. package/lib/renderer/text-input-paint.js +54 -0
  71. package/lib/renderer/text-input.js +57 -0
  72. package/lib/style/computed-style.js +2 -2
  73. package/package.json +1 -1
  74. package/test/clipboard.test.js +54 -0
  75. package/test/fixtures/browser-raw-demo.html +36 -0
  76. package/test/fixtures/flea-helpers/Dockerfile +36 -0
  77. package/test/fixtures/flea-helpers/bat.mjs +43 -0
  78. package/test/fixtures/flea-helpers/bin/bat +3 -0
  79. package/test/fixtures/flea-helpers/bin/bwrap +10 -0
  80. package/test/fixtures/flea-helpers/bin/bwrap-real-filter +9 -0
  81. package/test/fixtures/flea-helpers/bin/glow +3 -0
  82. package/test/fixtures/flea-helpers/bin/qs +3 -0
  83. package/test/fixtures/flea-helpers/bin/unsupported +3 -0
  84. package/test/fixtures/flea-helpers/check-highlighting.mjs +47 -0
  85. package/test/fixtures/flea-helpers/check-media.mjs +29 -0
  86. package/test/fixtures/flea-helpers/check-preview.mjs +27 -0
  87. package/test/fixtures/flea-helpers/check.mjs +36 -0
  88. package/test/fixtures/flea-helpers/highlight-cases.json +227 -0
  89. package/test/fixtures/flea-helpers/source-mime.xml +28 -0
  90. package/test/fixtures/flea-helpers/syntax-compat.mjs +23 -0
  91. package/test/fixtures/flea-thumbnail-stub/flea-placeholder.thumbnailer +4 -0
  92. package/test/fixtures/flea-thumbnail-stub/flea-thumbnail-stub +3 -0
  93. package/test/fixtures/qml/flea/EscapeHint.qml +11 -0
  94. package/test/fixtures/qml/flea/LICENSE +21 -0
  95. package/test/fixtures/qml/flea/README.md +25 -0
  96. package/test/fixtures/qml/flea/Theme.qml +8 -0
  97. package/test/fixtures/qml/flea/demo.qml +24 -0
  98. package/test/fixtures/qml/flea/qmldir +3 -0
  99. package/test/fixtures/qml/flea-theme/LICENSE-Flea +21 -0
  100. package/test/fixtures/qml/flea-theme/LICENSE-Omarchy +20 -0
  101. package/test/fixtures/qml/flea-theme/README.md +70 -0
  102. package/test/fixtures/qml/flea-theme/button.qml +17 -0
  103. package/test/fixtures/qml/flea-theme/demo.qml +27 -0
  104. package/test/fixtures/qml/flea-theme/field.qml +21 -0
  105. package/test/fixtures/qml/flea-theme/imports/qs/Commons/BorderGeometry.js +373 -0
  106. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Color.qml +258 -0
  107. package/test/fixtures/qml/flea-theme/imports/qs/Commons/I18n.qml +118 -0
  108. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Numbers.js +19 -0
  109. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Plural.js +91 -0
  110. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Style.qml +561 -0
  111. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Util.qml +188 -0
  112. package/test/fixtures/qml/flea-theme/imports/qs/Commons/qmldir +13 -0
  113. package/test/fixtures/qml/flea-theme/segment.qml +17 -0
  114. package/test/fixtures/qml/flea-theme/settings.qml +58 -0
  115. package/test/fixtures/qml/flea-theme/text-metrics.qml +10 -0
  116. package/test/fixtures/qml/flea-theme/ui/DialogButton.qml +150 -0
  117. package/test/fixtures/qml/flea-theme/ui/DialogField.qml +147 -0
  118. package/test/fixtures/qml/flea-theme/ui/EscapeHint.qml +11 -0
  119. package/test/fixtures/qml/flea-theme/ui/Glass.qml +114 -0
  120. package/test/fixtures/qml/flea-theme/ui/Glyph.qml +54 -0
  121. package/test/fixtures/qml/flea-theme/ui/GlyphSolid.qml +22 -0
  122. package/test/fixtures/qml/flea-theme/ui/Reveal.qml +54 -0
  123. package/test/fixtures/qml/flea-theme/ui/SettingsRuler.qml +85 -0
  124. package/test/fixtures/qml/flea-theme/ui/SettingsSegment.qml +85 -0
  125. package/test/fixtures/qml/flea-theme/ui/StableModel.qml +89 -0
  126. package/test/fixtures/qml/flea-theme/ui/Theme.qml +460 -0
  127. package/test/fixtures/qml/flea-theme/ui/ThemeColors.qml +43 -0
  128. package/test/fixtures/qml/flea-theme/ui/ViewState.qml +565 -0
  129. package/test/fixtures/qml/flea-theme/ui/js/Anchor.js +379 -0
  130. package/test/fixtures/qml/flea-theme/ui/js/AnchorHold.js +162 -0
  131. package/test/fixtures/qml/flea-theme/ui/js/Archive.js +157 -0
  132. package/test/fixtures/qml/flea-theme/ui/js/BackReturn.js +110 -0
  133. package/test/fixtures/qml/flea-theme/ui/js/Buttons.js +54 -0
  134. package/test/fixtures/qml/flea-theme/ui/js/ColumnMenu.js +111 -0
  135. package/test/fixtures/qml/flea-theme/ui/js/Columns.js +298 -0
  136. package/test/fixtures/qml/flea-theme/ui/js/Contrast.js +157 -0
  137. package/test/fixtures/qml/flea-theme/ui/js/Density.js +44 -0
  138. package/test/fixtures/qml/flea-theme/ui/js/DirSizes.js +129 -0
  139. package/test/fixtures/qml/flea-theme/ui/js/ExtThumbs.js +115 -0
  140. package/test/fixtures/qml/flea-theme/ui/js/Filter.js +284 -0
  141. package/test/fixtures/qml/flea-theme/ui/js/FolderSorts.js +108 -0
  142. package/test/fixtures/qml/flea-theme/ui/js/Format.js +305 -0
  143. package/test/fixtures/qml/flea-theme/ui/js/Icons.js +206 -0
  144. package/test/fixtures/qml/flea-theme/ui/js/Input.js +39 -0
  145. package/test/fixtures/qml/flea-theme/ui/js/KeyName.js +64 -0
  146. package/test/fixtures/qml/flea-theme/ui/js/Keymap.js +370 -0
  147. package/test/fixtures/qml/flea-theme/ui/js/Kinds.js +145 -0
  148. package/test/fixtures/qml/flea-theme/ui/js/Lists.js +11 -0
  149. package/test/fixtures/qml/flea-theme/ui/js/MakeDefault.js +186 -0
  150. package/test/fixtures/qml/flea-theme/ui/js/Match.js +27 -0
  151. package/test/fixtures/qml/flea-theme/ui/js/Material.js +109 -0
  152. package/test/fixtures/qml/flea-theme/ui/js/Menu.js +513 -0
  153. package/test/fixtures/qml/flea-theme/ui/js/Motion.js +16 -0
  154. package/test/fixtures/qml/flea-theme/ui/js/Mounts.js +320 -0
  155. package/test/fixtures/qml/flea-theme/ui/js/MouseNav.js +13 -0
  156. package/test/fixtures/qml/flea-theme/ui/js/Nav.js +328 -0
  157. package/test/fixtures/qml/flea-theme/ui/js/Palette.js +33 -0
  158. package/test/fixtures/qml/flea-theme/ui/js/Places.js +236 -0
  159. package/test/fixtures/qml/flea-theme/ui/js/Protocols.js +296 -0
  160. package/test/fixtures/qml/flea-theme/ui/js/RecentMode.js +154 -0
  161. package/test/fixtures/qml/flea-theme/ui/js/Search.js +396 -0
  162. package/test/fixtures/qml/flea-theme/ui/js/Settings.js +476 -0
  163. package/test/fixtures/qml/flea-theme/ui/js/SettingsAbout.js +99 -0
  164. package/test/fixtures/qml/flea-theme/ui/js/SettingsShelf.js +52 -0
  165. package/test/fixtures/qml/flea-theme/ui/js/Sort.js +127 -0
  166. package/test/fixtures/qml/flea-theme/ui/js/StateWords.js +42 -0
  167. package/test/fixtures/qml/flea-theme/ui/js/Swap.js +166 -0
  168. package/test/fixtures/qml/flea-theme/ui/js/TextSize.js +107 -0
  169. package/test/fixtures/qml/flea-theme/ui/js/Thumbs.js +158 -0
  170. package/test/fixtures/qml/flea-theme/ui/js/UiState.js +438 -0
  171. package/test/fixtures/qml/flea-theme/ui/js/Update.js +132 -0
  172. package/test/fixtures/qml/flea-theme/ui/js/UserDirs.js +56 -0
  173. package/test/fixtures/qml/flea-theme/ui/qmldir +227 -0
  174. package/test/fixtures/qml/hello.qml +26 -0
  175. package/test/fixtures/qml/imports/Demo/Toolkit/VersionLabel.qml +2 -0
  176. package/test/fixtures/qml/imports/Demo/Toolkit/VersionLabelNew.qml +2 -0
  177. package/test/fixtures/qml/imports/Demo/Toolkit/qmldir +3 -0
  178. package/test/fixtures/qml/imports/components/Badge.qml +15 -0
  179. package/test/fixtures/qml/imports/logic/base.js +2 -0
  180. package/test/fixtures/qml/imports/logic/counter.js +4 -0
  181. package/test/fixtures/qml/imports/logic/math.js +3 -0
  182. package/test/fixtures/qml/imports/logic/shared.js +3 -0
  183. package/test/fixtures/qml/imports/main.qml +21 -0
  184. package/test/fixtures/qml/imports/theme/Theme.qml +8 -0
  185. package/test/fixtures/qml/imports/theme/qmldir +2 -0
  186. package/test/fixtures/qml/multiply.qml +61 -0
  187. package/test/fixtures/qml-canvas-dom.js +90 -0
  188. package/test/fixtures/qml-media-audio.js +39 -0
  189. package/test/fixtures/raw-capture-benchmark.json +76 -0
  190. package/test/helpers/flea-sandbox.js +48 -0
  191. package/test/helpers/quickshell-fixture.js +125 -0
  192. package/test/html-clipboard-wayland.test.js +47 -0
  193. package/test/index.test.js +13 -0
  194. package/test/manual-casty-without-qml.js +87 -0
  195. package/test/manual-flea-application.js +49 -0
  196. package/test/manual-flea-binary.js +62 -0
  197. package/test/manual-flea-qml.js +9 -0
  198. package/test/manual-flea-settings.js +40 -0
  199. package/test/manual-flea-theme.js +40 -0
  200. package/test/manual-qml-removal.js +46 -0
  201. package/test/manual-without-qml.js +45 -0
  202. package/test/qml-canvas-dom.test.js +37 -0
  203. package/test/qml-clipboard-bridge.test.js +25 -0
  204. package/test/qml-clipboard-wayland.test.js +40 -0
  205. package/test/qml-connections-timing.test.js +73 -0
  206. package/test/qml-dynamic.test.js +401 -0
  207. package/test/qml-flea-app.test.js +146 -0
  208. package/test/qml-flea-backend.test.js +78 -0
  209. package/test/qml-flea-columns-backend.test.js +38 -0
  210. package/test/qml-flea-compile.test.js +78 -0
  211. package/test/qml-flea-launcher.test.js +88 -0
  212. package/test/qml-flea-markdown-backend.test.js +31 -0
  213. package/test/qml-flea-media.test.js +28 -0
  214. package/test/qml-flea-navigation-backend.test.js +35 -0
  215. package/test/qml-flea-navigation-timing.test.js +147 -0
  216. package/test/qml-flea-preview-backend.test.js +53 -0
  217. package/test/qml-flea-previews.test.js +163 -0
  218. package/test/qml-flea-rows.test.js +101 -0
  219. package/test/qml-flea-textures.test.js +59 -0
  220. package/test/qml-flea-view-switch.test.js +33 -0
  221. package/test/qml-flea-wayland.test.js +68 -0
  222. package/test/qml-flea.test.js +34 -0
  223. package/test/qml-host.test.js +238 -0
  224. package/test/qml-imports.test.js +102 -0
  225. package/test/qml-inherited-handlers.test.js +51 -0
  226. package/test/qml-input-selection.test.js +66 -0
  227. package/test/qml-interactions.test.js +309 -0
  228. package/test/qml-language.test.js +167 -0
  229. package/test/qml-launcher.test.js +54 -0
  230. package/test/qml-layout-text.test.js +205 -0
  231. package/test/qml-media.test.js +63 -0
  232. package/test/qml-persisted-delegates.test.js +45 -0
  233. package/test/qml-quickshell-controls.test.js +65 -0
  234. package/test/qml-quickshell-settings.test.js +62 -0
  235. package/test/qml-repeater.test.js +72 -0
  236. package/test/qml-style-updates.test.js +124 -0
  237. package/test/qml-textures.test.js +66 -0
  238. package/test/qml-views.test.js +332 -0
  239. package/test/qml-worker.test.js +69 -0
  240. package/test/qml.test.js +83 -0
  241. package/test/raw-capture.test.js +152 -0
  242. package/test/renderer-host.test.js +11 -0
  243. package/test/text-input-pointer.test.js +31 -0
  244. package/test/text-input-scroll.test.js +29 -0
  245. package/test/text-input.test.js +47 -0
  246. package/test/wayland-frontend.test.js +83 -0
  247. package/test/wayland-urlbar.test.js +38 -0
  248. package/test/window-resize.test.js +36 -0
  249. package/tools/bench-raw-capture.js +32 -0
@@ -0,0 +1,309 @@
1
+ import { test, expect } from 'bun:test';
2
+ import { createBrowser } from '../lib/headless-shell.js';
3
+ import { RendererHost } from '../lib/renderer/host.js';
4
+ import { mkdtempSync, rmSync } from 'node:fs';
5
+ const fixture = new URL('./fixtures/qml/flea-theme/', import.meta.url);
6
+ const settle = async (read, predicate) => {
7
+ let value;
8
+ for (let n = 0; n < 80; n++) {
9
+ value = await read();
10
+ if (predicate(value)) return value;
11
+ await Bun.sleep(15);
12
+ }
13
+ throw Error('Did not settle: ' + JSON.stringify(value));
14
+ };
15
+
16
+ test('upstream DialogButton handles click, key repeat, disabled state and animated press', async () => {
17
+ const previous = process.env.QML_IMPORT_PATH;
18
+ process.env.QML_IMPORT_PATH = Bun.fileURLToPath(new URL('imports', fixture));
19
+ const browser = await createBrowser({ spareRenderer: false });
20
+ try {
21
+ await browser.context.navigate(new URL('button.qml', fixture).href);
22
+ const evaluate = (code) => browser.context.evaluate(code);
23
+ const status = () =>
24
+ evaluate('document.getElementById("status").textContent');
25
+ await evaluate('document.getElementById("action").click()');
26
+ await settle(status, (v) => v === 'Activated: 1');
27
+ await evaluate(
28
+ `document.getElementById('action').dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',bubbles:true,cancelable:true}))`,
29
+ );
30
+ await settle(status, (v) => v === 'Activated: 2');
31
+ await evaluate(
32
+ `document.getElementById('action').dispatchEvent(new KeyboardEvent('keydown',{key:' ',code:'Space',repeat:true,bubbles:true}))`,
33
+ );
34
+ await Bun.sleep(40);
35
+ expect(await status()).toBe('Activated: 2');
36
+ await evaluate(
37
+ `document.querySelectorAll('[data-qml-type=Column] > [data-qml-type=Item]')[1].click()`,
38
+ );
39
+ expect(await status()).toBe('Activated: 2');
40
+ const inset = () =>
41
+ evaluate(
42
+ `parseFloat(document.getElementById('qml-1-chrome').style.left)`,
43
+ );
44
+ await evaluate(
45
+ `(()=>{const e=document.getElementById('action'),r=e.getBoundingClientRect();e.dispatchEvent(new MouseEvent('mousedown',{clientX:r.x+5,clientY:r.y+5,button:0,bubbles:true}));})()`,
46
+ );
47
+ await settle(inset, (v) => v > 0);
48
+ expect(await inset()).toBeGreaterThan(0);
49
+ await evaluate(
50
+ `document.dispatchEvent(new MouseEvent('mouseup',{button:0,bubbles:true}))`,
51
+ );
52
+ await settle(inset, (v) => v === 0);
53
+ } finally {
54
+ browser.close();
55
+ if (previous === undefined) delete process.env.QML_IMPORT_PATH;
56
+ else process.env.QML_IMPORT_PATH = previous;
57
+ }
58
+ }, 30000);
59
+
60
+ test('inline components preserve lexical IDs and default aliases route inserted content', async () => {
61
+ const dir = mkdtempSync('/tmp/qml-slots-');
62
+ await Bun.write(
63
+ dir + '/Frame.qml',
64
+ `import QtQuick
65
+ Item {id:root;default property alias content:holder.data;Item {id:holder;x:20;y:30}}`,
66
+ );
67
+ await Bun.write(
68
+ dir + '/main.qml',
69
+ `import QtQuick
70
+ Item {id:root;property string label:"lexical";width:300;height:200
71
+ component Cell: Text {text:root.label}
72
+ Frame {id:frame;Cell {objectName:"inside"}}
73
+ Cell {id:outside}
74
+ Button {id:change;onClicked:root.label="changed"}
75
+ }`,
76
+ );
77
+ const b = await createBrowser({ spareRenderer: false });
78
+ try {
79
+ await b.context.navigate(Bun.pathToFileURL(dir + '/main.qml').href);
80
+ const text = () => b.context.evaluate('document.body.innerText');
81
+ expect(await text()).toContain('lexical');
82
+ expect(
83
+ await b.context.evaluate(
84
+ `document.querySelector('[data-qml-type=Text]').parentElement.id.endsWith('holder')`,
85
+ ),
86
+ ).toBe(true);
87
+ await b.context.evaluate(`document.getElementById('change').click()`);
88
+ await settle(text, (v) => (v.match(/changed/g) || []).length === 2);
89
+ } finally {
90
+ b.close();
91
+ rmSync(dir, { recursive: true, force: true });
92
+ }
93
+ }, 30000);
94
+
95
+ test('SplitParser streams partial UTF-8 lines and Process writes stdin before exit', async () => {
96
+ const dir = mkdtempSync('/tmp/qml-stream-');
97
+ const script = `process.stdout.write('ready\\n');process.stdin.once('data',chunk=>{process.stdout.write(Buffer.from([0xe4,0xbd]));setTimeout(()=>{process.stdout.write(Buffer.concat([Buffer.from([0xa0]),Buffer.from(':'+chunk.toString().trim()+'\\n')]));process.exit(0);},30);});`;
98
+ await Bun.write(
99
+ dir + '/main.qml',
100
+ `import QtQuick
101
+ import Quickshell.Io
102
+ Text {id:root;property var lines:[];property int code:-1;text:JSON.stringify(lines)+":"+code
103
+ Process {id:child;command:${JSON.stringify([process.execPath, '-e', script])};running:true;stdinEnabled:true
104
+ stdout:SplitParser {onRead:function(data){root.lines=root.lines.concat([data]);if(data==="ready")child.write("reply\\n")}}
105
+ onExited:function(code,status){root.code=code}
106
+ }
107
+ }`,
108
+ );
109
+ const b = await createBrowser({ spareRenderer: false });
110
+ try {
111
+ await b.context.navigate(Bun.pathToFileURL(dir + '/main.qml').href);
112
+ const text = () => b.context.evaluate('document.body.innerText');
113
+ await settle(text, (v) => v === '["ready","你:reply"]:0');
114
+ expect(await text()).toBe('["ready","你:reply"]:0');
115
+ } finally {
116
+ b.close();
117
+ rmSync(dir, { recursive: true, force: true });
118
+ }
119
+ }, 30000);
120
+
121
+ test('upstream DialogField forwards writable aliases, text edits, Enter and Tab', async () => {
122
+ const previous = process.env.QML_IMPORT_PATH;
123
+ process.env.QML_IMPORT_PATH = Bun.fileURLToPath(new URL('imports', fixture));
124
+ const browser = await createBrowser({ spareRenderer: false });
125
+ try {
126
+ await browser.context.navigate(new URL('field.qml', fixture).href);
127
+ const status = () =>
128
+ browser.context.evaluate('document.getElementById("status").textContent');
129
+ expect(await status()).toBe('first|0:0:0');
130
+ await browser.context.evaluate('document.getElementById("change").click()');
131
+ await settle(status, (v) => v === 'second|0:0:0');
132
+ await browser.context.press('a', { modifiers: 2 });
133
+ await browser.context.type('hello');
134
+ await settle(status, (v) => v.startsWith('hello|'));
135
+ await browser.context.press('Enter');
136
+ await browser.context.press('Tab');
137
+ await settle(status, (v) => v.endsWith(':1:1'));
138
+ await browser.context.press('Tab', { modifiers: 8 });
139
+ await settle(status, (v) => v.endsWith(':1:0'));
140
+ expect(
141
+ await browser.context.evaluate('document.activeElement.tagName'),
142
+ ).toBe('INPUT');
143
+ } finally {
144
+ browser.close();
145
+ if (previous === undefined) delete process.env.QML_IMPORT_PATH;
146
+ else process.env.QML_IMPORT_PATH = previous;
147
+ }
148
+ }, 30000);
149
+
150
+ test('Keys.forwardTo handles suggestions before the input and accepted keys suppress TextInput.accepted', async () => {
151
+ const host = new RendererHost({ timeout: 20000 });
152
+ try {
153
+ await host.start();
154
+ await host.call('loadDocument', {
155
+ url: 'data:text/x-qml,keys',
156
+ contentType: 'text/x-qml',
157
+ source: `
158
+ import QtQuick
159
+ Item {id:root;width:300;height:200;property int picks:0;property int accepts:0;property int forwards:0
160
+ Item {id:suggestions;Keys.forwardTo:[input];Keys.onReturnPressed:root.picks++}
161
+ TextInput {id:input;width:200;height:40;focus:true;Keys.forwardTo:[suggestions];onAccepted:root.accepts++
162
+ Keys.onEscapePressed:root.forwards++
163
+ }
164
+ Text {id:status;y:50;text:root.picks+":"+root.accepts+":"+root.forwards}
165
+ }`,
166
+ });
167
+ await host.call('whenLoaded');
168
+ await host.call('press', 'Enter');
169
+ await host.call('press', 'Escape');
170
+ await settle(
171
+ () =>
172
+ host.call('evaluate', 'document.getElementById("status").textContent'),
173
+ (v) => v === '1:0:1',
174
+ );
175
+ } finally {
176
+ host.close();
177
+ }
178
+ }, 30000);
179
+
180
+ test('text editing consumes handled keys before ancestor shortcuts and keeps Escape bubbling', async () => {
181
+ const host = new RendererHost({ timeout: 20000 });
182
+ try {
183
+ await host.start();
184
+ await host.call('loadDocument', {
185
+ url: 'data:text/x-qml,input',
186
+ contentType: 'text/x-qml',
187
+ source: `
188
+ import QtQuick
189
+ Item {id:root;width:300;height:200;property int parentKeys:0;property int accepts:0
190
+ Keys.onPressed:root.parentKeys++
191
+ TextInput {id:input;width:200;height:40;focus:true;onAccepted:root.accepts++}
192
+ Text {id:status;y:50;text:input.text+":"+root.parentKeys+":"+root.accepts}
193
+ }`,
194
+ });
195
+ await host.call('whenLoaded');
196
+ await host.call('press', 'j');
197
+ await host.call('press', 'Enter');
198
+ await host.call('press', 'Escape');
199
+ await settle(
200
+ () =>
201
+ host.call('evaluate', 'document.getElementById("status").textContent'),
202
+ (v) => v === 'j:1:1',
203
+ );
204
+ } finally {
205
+ host.close();
206
+ }
207
+ }, 30000);
208
+
209
+ test('FileView atomic writes signal saved only after replacement and waitForJob flushes pending data', async () => {
210
+ const dir = mkdtempSync('/tmp/qml-file-save-'),
211
+ target = dir + '/reply';
212
+ await Bun.write(target, 'old');
213
+ await Bun.write(
214
+ dir + '/main.qml',
215
+ `import QtQuick
216
+ import Quickshell.Io
217
+ Item {id:root;property int saves:0;property string failure:""
218
+ FileView {id:file;path:${JSON.stringify(target)};atomicWrites:true;preload:false;onSaved:root.saves++;onSaveFailed:function(error){root.failure=error}}
219
+ Text {id:status;text:root.saves+":"+root.failure}
220
+ Button {id:save;onClicked:{file.setText("first");file.waitForJob();file.setData(new Uint8Array([110,101,120,116]).buffer)}}
221
+ }`,
222
+ );
223
+ const b = await createBrowser({ spareRenderer: false });
224
+ try {
225
+ await b.context.navigate(Bun.pathToFileURL(dir + '/main.qml').href);
226
+ await b.context.evaluate('document.getElementById("save").click()');
227
+ await settle(
228
+ () => b.context.evaluate('document.getElementById("status").textContent'),
229
+ (v) => v === '2:',
230
+ );
231
+ expect(await Bun.file(target).text()).toBe('next');
232
+ } finally {
233
+ b.close();
234
+ rmSync(dir, { recursive: true, force: true });
235
+ }
236
+ }, 30000);
237
+
238
+ test('FocusScope ancestors track active focus and restore their last focused child', async () => {
239
+ const host = new RendererHost({ timeout: 20000 });
240
+ try {
241
+ await host.start();
242
+ await host.call('loadDocument', {
243
+ url: 'data:text/x-qml,focus',
244
+ contentType: 'text/x-qml',
245
+ source: `import QtQuick
246
+ Item {width:400;height:200
247
+ FocusScope {id:scope;width:200;height:60
248
+ TextInput {id:input;width:200;height:30;focus:true;text:"input"}
249
+ }
250
+ Button {id:outside;y:70;text:"Outside"}
251
+ Button {id:back;x:100;y:70;text:"Back";onClicked:scope.forceActiveFocus()}
252
+ Text {id:status;y:120;text:scope.activeFocus+":"+input.activeFocus+":"+outside.activeFocus}
253
+ }`,
254
+ });
255
+ await host.call('whenLoaded');
256
+ expect(
257
+ await host.call(
258
+ 'evaluate',
259
+ 'document.getElementById("status").textContent',
260
+ ),
261
+ ).toBe('true:true:false');
262
+ await host.call('evaluate', 'document.getElementById("outside").focus()');
263
+ expect(
264
+ await host.call(
265
+ 'evaluate',
266
+ 'document.getElementById("status").textContent',
267
+ ),
268
+ ).toBe('false:false:true');
269
+ await host.call('evaluate', 'document.getElementById("back").click()');
270
+ expect(
271
+ await host.call(
272
+ 'evaluate',
273
+ 'document.getElementById("status").textContent',
274
+ ),
275
+ ).toBe('true:true:false');
276
+ } finally {
277
+ host.close();
278
+ }
279
+ }, 30000);
280
+
281
+ test('QML key and pointer adapters preserve menu, AltGr and mouse back/forward distinctions', async () => {
282
+ const host = new RendererHost({ timeout: 20000 });
283
+ try {
284
+ await host.start();
285
+ await host.call('loadDocument', {
286
+ url: 'data:text/x-qml,extended-keys',
287
+ contentType: 'text/x-qml',
288
+ source: `import QtQuick
289
+ Item {id:root;width:300;height:150;focus:true;property string result:""
290
+ Keys.onPressed:function(event){result+=event.key+":"+event.modifiers+",";event.accepted=true}
291
+ MouseArea {id:area;width:100;height:50;acceptedButtons:Qt.BackButton|Qt.ForwardButton;onClicked:function(mouse){root.result+="mouse:"+mouse.button+","}}
292
+ Text {id:status;y:80;text:root.result}
293
+ }`,
294
+ });
295
+ await host.call('whenLoaded');
296
+ await host.call(
297
+ 'evaluate',
298
+ `(()=>{const root=document.getElementById('root');for(const key of ['ContextMenu','AltGraph','CapsLock'])root.dispatchEvent(new KeyboardEvent('keydown',{key,bubbles:true}));root.dispatchEvent(new KeyboardEvent('keydown',{key:'x',ctrlKey:true,altKey:true,shiftKey:true,metaKey:true,bubbles:true}));const area=document.getElementById('area');for(const button of [0,3,4])area.dispatchEvent(new MouseEvent('click',{button,bubbles:true}));})()`,
299
+ );
300
+ expect(
301
+ await host.call(
302
+ 'evaluate',
303
+ 'document.getElementById("status").textContent',
304
+ ),
305
+ ).toBe('16777301:0,16781571:0,16777252:0,88:503316480,mouse:8,mouse:16,');
306
+ } finally {
307
+ host.close();
308
+ }
309
+ }, 30000);
@@ -0,0 +1,167 @@
1
+ import { test, expect } from 'bun:test';
2
+ import { parseQml } from '../lib/qml/parser.js';
3
+ import { RendererHost } from '../lib/renderer/host.js';
4
+
5
+ test('QML syntax includes typed functions, optional signal parentheses, default slots and inline component declarations', () => {
6
+ const root = parseQml(`Item {
7
+ default property alias content: body.data
8
+ property point point
9
+ property var unset
10
+ signal ready
11
+ function twice(n: int): int {return n*2}
12
+ component Cell: Rectangle {color:"red"}
13
+ Item {id:body}
14
+ Behavior on opacity {NumberAnimation {property:"opacity";duration:50}}
15
+ }`).root;
16
+ expect(root.properties.find((p) => p.key === 'content').defaultProperty).toBe(
17
+ true,
18
+ );
19
+ expect(root.properties.find((p) => p.key === 'point').code).toBe(
20
+ '({x:0,y:0})',
21
+ );
22
+ expect(root.properties.find((p) => p.key === 'unset').code).toBe('undefined');
23
+ expect(
24
+ root.properties.find((p) => p.key === 'twice').code.replace(/\s+/g, ''),
25
+ ).toContain('twice(n)');
26
+ expect(root.components[0].name).toBe('Cell');
27
+ expect(root.children[1].bindingTarget).toBe('opacity');
28
+ });
29
+
30
+ test('statement handlers, object literals, property types and QML casts execute as page JavaScript', async () => {
31
+ const host = new RendererHost({ timeout: 20000 });
32
+ try {
33
+ await host.start();
34
+ await host.call('loadDocument', {
35
+ url: 'data:text/x-qml,test',
36
+ contentType: 'text/x-qml',
37
+ source: `
38
+ Rectangle {
39
+ id: root; width: 300; height: 200
40
+ property int counter: 0
41
+ property point pos
42
+ property var unset
43
+ property QtObject plain: QtObject {}
44
+ property var settings: {"tnum": 1}
45
+ signal ready
46
+ function twice(n: int): int {return n*2}
47
+ onReady: if (settings.tnum) counter=twice(3); else counter=-1
48
+ Component.onCompleted: root.ready()
49
+ Text {id:result;text:counter+":"+pos.x+":"+typeof unset+":"+((root as Item)===root)+":"+((plain as Item)===null)+":"+(("tnum" in settings))}
50
+ }`,
51
+ });
52
+ await host.call('whenLoaded');
53
+ expect(
54
+ await host.call(
55
+ 'evaluate',
56
+ 'document.getElementById("result").textContent',
57
+ ),
58
+ ).toBe('6:0:undefined:true:true:true');
59
+ } finally {
60
+ host.close();
61
+ }
62
+ }, 30000);
63
+
64
+ test('anchors expose geometry and Row positions only visible children without painting Item colors', async () => {
65
+ const host = new RendererHost({ timeout: 20000 });
66
+ try {
67
+ await host.start();
68
+ await host.call('loadDocument', {
69
+ url: 'data:text/x-qml,geometry',
70
+ contentType: 'text/x-qml',
71
+ source: `
72
+ Rectangle {
73
+ width:300;height:200
74
+ Rectangle {id:box;anchors.left:parent.left;anchors.right:parent.right;anchors.margins:10;height:40;y:10}
75
+ Row {id:row;x:20;y:70;spacing:5
76
+ Item {id:first;width:30;height:20;property color tint:"red";property color color:tint}
77
+ Item {id:invisible;width:100;height:50;visible:false}
78
+ Rectangle {id:last;width:40;height:30}
79
+ }
80
+ Text {id:out;text:box.width+":"+row.implicitWidth+":"+row.implicitHeight+":"+last.x}
81
+ Rectangle {id:centered;width:20;height:20;anchors.horizontalCenter:box.horizontalCenter;anchors.top:box.bottom;anchors.topMargin:5}
82
+ }`,
83
+ });
84
+ await host.call('whenLoaded');
85
+ expect(
86
+ await host.call('evaluate', 'document.getElementById("out").textContent'),
87
+ ).toBe('280:75:30:35');
88
+ expect(
89
+ await host.call(
90
+ 'evaluate',
91
+ 'document.getElementById("first").style.backgroundColor',
92
+ ),
93
+ ).toBe('transparent');
94
+ expect(
95
+ JSON.parse(
96
+ await host.call(
97
+ 'evaluate',
98
+ 'JSON.stringify([document.getElementById("centered").style.left,document.getElementById("centered").style.top])',
99
+ ),
100
+ ),
101
+ ).toEqual(['140px', '55px']);
102
+ } finally {
103
+ host.close();
104
+ }
105
+ }, 30000);
106
+
107
+ test('QML String.arg replaces the lowest numbered placeholders and does not leak into HTML', async () => {
108
+ const { createBrowser } = await import('../lib/headless-shell.js');
109
+ const browser = await createBrowser({ spareRenderer: false });
110
+ try {
111
+ await browser.context.navigate(
112
+ 'data:text/x-qml,' +
113
+ encodeURIComponent(
114
+ 'import QtQuick\nText {id:message;text:"%2 / %1 / %1".arg("$&").arg(42)}',
115
+ ),
116
+ );
117
+ let text = '';
118
+ for (let i = 0; i < 100; i++) {
119
+ text = await browser.context.evaluate(
120
+ 'document.getElementById("message")?.textContent',
121
+ );
122
+ if (text === '42 / $& / $&') break;
123
+ await Bun.sleep(20);
124
+ }
125
+ expect(text).toBe('42 / $& / $&');
126
+ await browser.context.navigate('data:text/html,<p>ordinary</p>');
127
+ expect(await browser.context.evaluate('typeof "ordinary".arg')).toBe(
128
+ 'undefined',
129
+ );
130
+ } finally {
131
+ browser.close();
132
+ }
133
+ }, 30000);
134
+
135
+ test('loop, catch and destructured JS locals shadow QML names without capturing default expressions', async () => {
136
+ const host = new RendererHost({ timeout: 20000 });
137
+ try {
138
+ await host.start();
139
+ await host.call('loadDocument', {
140
+ url: 'data:text/x-qml,scope',
141
+ contentType: 'text/x-qml',
142
+ source: `import QtQuick
143
+ Item {id:root;width:300;height:200
144
+ property int i:99
145
+ property int error:88
146
+ property int value:7
147
+ function run({n=value}={}) {
148
+ var sum=n
149
+ for(var i=0;i<3;i++)sum+=i
150
+ try {throw 4} catch(error){sum+=error}
151
+ for(let value of [1,2])sum+=value
152
+ return sum+":"+i+":"+root.i+":"+root.error+":"+root.value
153
+ }
154
+ Text {id:status;text:root.run()}
155
+ }`,
156
+ });
157
+ await host.call('whenLoaded');
158
+ expect(
159
+ await host.call(
160
+ 'evaluate',
161
+ 'document.getElementById("status").textContent',
162
+ ),
163
+ ).toBe('17:3:99:88:7');
164
+ } finally {
165
+ host.close();
166
+ }
167
+ }, 30000);
@@ -0,0 +1,54 @@
1
+ import { test, expect } from 'bun:test';
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { backendArgs, parseArgs } from '../lib/qml/launcher.js';
5
+ test('qs accepts official -p invocation, paths with spaces and named config, rejecting unknown options', () => {
6
+ const dir = mkdtempSync('/tmp/qml-launcher-');
7
+ try {
8
+ const path = join(dir, 'with spaces');
9
+ mkdirSync(path);
10
+ writeFileSync(join(path, 'shell.qml'), 'import QtQuick\nItem {}');
11
+ expect(parseArgs(['-p', path], {}).path).toBe(join(path, 'shell.qml'));
12
+ expect(parseArgs(['--path=' + join(path, 'shell.qml')], {}).path).toBe(
13
+ join(path, 'shell.qml'),
14
+ );
15
+ expect(parseArgs([], { QS_CONFIG_PATH: path }).path).toBe(
16
+ join(path, 'shell.qml'),
17
+ );
18
+ const config = join(dir, 'quickshell/demo');
19
+ mkdirSync(config, { recursive: true });
20
+ writeFileSync(join(config, 'shell.qml'), '');
21
+ expect(parseArgs(['-c', 'demo'], { XDG_CONFIG_HOME: dir }).path).toBe(
22
+ join(config, 'shell.qml'),
23
+ );
24
+ expect(parseArgs(['--help'], {})).toEqual({ help: true });
25
+ expect(() => parseArgs(['ipc', 'call'], {})).toThrow('Unsupported');
26
+ expect(() => parseArgs(['-p'], {})).toThrow('needs a value');
27
+ } finally {
28
+ rmSync(dir, { recursive: true, force: true });
29
+ }
30
+ });
31
+
32
+ test('QS backend arguments preserve file URLs and backend separators', () => {
33
+ const file = new URL('./fixtures/qml/hello.qml', import.meta.url);
34
+ expect(backendArgs(['-p', decodeURIComponent(file.pathname), '--', '--', '--mobile'], {}))
35
+ .toEqual({ args: [file.href, '--', '--mobile'] });
36
+ expect(backendArgs(['--help'], {})).toEqual({ help: true });
37
+ expect(() => backendArgs(['--unknown'], {})).toThrow('Unsupported');
38
+ });
39
+
40
+ test('standalone QS entries show help and reject invalid QS options without starting a backend', async () => {
41
+ for (const backend of ['wayland', 'headless', 'casty']) {
42
+ const entry = new URL('../lib/qml/qs' + backend, import.meta.url).pathname;
43
+ for (const help of [true, false]) {
44
+ const child = Bun.spawn([process.execPath, entry, help ? '--help' : '--unknown'], {
45
+ stdout: 'pipe', stderr: 'pipe',
46
+ });
47
+ const [stdout, stderr, code] = await Promise.all([
48
+ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
49
+ ]);
50
+ expect(code).toBe(help ? 0 : 1);
51
+ expect(help ? stdout : stderr).toContain(help ? 'qs' + backend : 'Unsupported Quickshell option');
52
+ }
53
+ }
54
+ });