buninu-browser 0.3.0 → 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 (239) hide show
  1. package/LICENSES/jsmdcui.txt +22 -0
  2. package/README.md +19 -4
  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 +37 -2
  30. package/lib/document-types.js +11 -0
  31. package/lib/headless-shell.js +47 -8
  32. package/lib/layout/text-layout.js +17 -9
  33. package/lib/paint/display-list.js +5 -1
  34. package/lib/qml/README.md +971 -0
  35. package/lib/qml/builtins.js +700 -0
  36. package/lib/qml/canvas-dom.js +115 -0
  37. package/lib/qml/document.js +1176 -0
  38. package/lib/qml/format.js +9 -0
  39. package/lib/qml/host.js +324 -0
  40. package/lib/qml/images.js +199 -0
  41. package/lib/qml/launcher.js +132 -0
  42. package/lib/qml/loader.js +517 -0
  43. package/lib/qml/media-renderer.js +49 -0
  44. package/lib/qml/media.js +86 -0
  45. package/lib/qml/models.js +622 -0
  46. package/lib/qml/parser.js +381 -0
  47. package/lib/qml/qs +5 -0
  48. package/lib/qml/qscasty +3 -0
  49. package/lib/qml/qsheadless +3 -0
  50. package/lib/qml/qswayland +3 -0
  51. package/lib/qml/reactive.js +159 -0
  52. package/lib/qml/runtime.js +3381 -0
  53. package/lib/qml/shapes.js +243 -0
  54. package/lib/qml/text.js +109 -0
  55. package/lib/qml/textures.js +123 -0
  56. package/lib/qml/wayland-clipboard.js +32 -0
  57. package/lib/qml/worker-thread.js +70 -0
  58. package/lib/render-tree/index.js +16 -1
  59. package/lib/renderer/bindings.js +11 -0
  60. package/lib/renderer/canvas-2d.js +13 -0
  61. package/lib/renderer/host.js +31 -6
  62. package/lib/renderer/main.js +19 -6
  63. package/lib/renderer/page-realm.js +16 -1
  64. package/lib/renderer/page-renderer.js +230 -13
  65. package/lib/renderer/text-input-paint.js +54 -0
  66. package/lib/renderer/text-input.js +57 -0
  67. package/lib/style/computed-style.js +2 -2
  68. package/package.json +1 -1
  69. package/test/clipboard.test.js +19 -0
  70. package/test/fixtures/flea-helpers/Dockerfile +36 -0
  71. package/test/fixtures/flea-helpers/bat.mjs +43 -0
  72. package/test/fixtures/flea-helpers/bin/bat +3 -0
  73. package/test/fixtures/flea-helpers/bin/bwrap +10 -0
  74. package/test/fixtures/flea-helpers/bin/bwrap-real-filter +9 -0
  75. package/test/fixtures/flea-helpers/bin/glow +3 -0
  76. package/test/fixtures/flea-helpers/bin/qs +3 -0
  77. package/test/fixtures/flea-helpers/bin/unsupported +3 -0
  78. package/test/fixtures/flea-helpers/check-highlighting.mjs +47 -0
  79. package/test/fixtures/flea-helpers/check-media.mjs +29 -0
  80. package/test/fixtures/flea-helpers/check-preview.mjs +27 -0
  81. package/test/fixtures/flea-helpers/check.mjs +36 -0
  82. package/test/fixtures/flea-helpers/highlight-cases.json +227 -0
  83. package/test/fixtures/flea-helpers/source-mime.xml +28 -0
  84. package/test/fixtures/flea-helpers/syntax-compat.mjs +23 -0
  85. package/test/fixtures/flea-thumbnail-stub/flea-placeholder.thumbnailer +4 -0
  86. package/test/fixtures/flea-thumbnail-stub/flea-thumbnail-stub +3 -0
  87. package/test/fixtures/qml/flea/EscapeHint.qml +11 -0
  88. package/test/fixtures/qml/flea/LICENSE +21 -0
  89. package/test/fixtures/qml/flea/README.md +25 -0
  90. package/test/fixtures/qml/flea/Theme.qml +8 -0
  91. package/test/fixtures/qml/flea/demo.qml +24 -0
  92. package/test/fixtures/qml/flea/qmldir +3 -0
  93. package/test/fixtures/qml/flea-theme/LICENSE-Flea +21 -0
  94. package/test/fixtures/qml/flea-theme/LICENSE-Omarchy +20 -0
  95. package/test/fixtures/qml/flea-theme/README.md +70 -0
  96. package/test/fixtures/qml/flea-theme/button.qml +17 -0
  97. package/test/fixtures/qml/flea-theme/demo.qml +27 -0
  98. package/test/fixtures/qml/flea-theme/field.qml +21 -0
  99. package/test/fixtures/qml/flea-theme/imports/qs/Commons/BorderGeometry.js +373 -0
  100. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Color.qml +258 -0
  101. package/test/fixtures/qml/flea-theme/imports/qs/Commons/I18n.qml +118 -0
  102. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Numbers.js +19 -0
  103. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Plural.js +91 -0
  104. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Style.qml +561 -0
  105. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Util.qml +188 -0
  106. package/test/fixtures/qml/flea-theme/imports/qs/Commons/qmldir +13 -0
  107. package/test/fixtures/qml/flea-theme/segment.qml +17 -0
  108. package/test/fixtures/qml/flea-theme/settings.qml +58 -0
  109. package/test/fixtures/qml/flea-theme/text-metrics.qml +10 -0
  110. package/test/fixtures/qml/flea-theme/ui/DialogButton.qml +150 -0
  111. package/test/fixtures/qml/flea-theme/ui/DialogField.qml +147 -0
  112. package/test/fixtures/qml/flea-theme/ui/EscapeHint.qml +11 -0
  113. package/test/fixtures/qml/flea-theme/ui/Glass.qml +114 -0
  114. package/test/fixtures/qml/flea-theme/ui/Glyph.qml +54 -0
  115. package/test/fixtures/qml/flea-theme/ui/GlyphSolid.qml +22 -0
  116. package/test/fixtures/qml/flea-theme/ui/Reveal.qml +54 -0
  117. package/test/fixtures/qml/flea-theme/ui/SettingsRuler.qml +85 -0
  118. package/test/fixtures/qml/flea-theme/ui/SettingsSegment.qml +85 -0
  119. package/test/fixtures/qml/flea-theme/ui/StableModel.qml +89 -0
  120. package/test/fixtures/qml/flea-theme/ui/Theme.qml +460 -0
  121. package/test/fixtures/qml/flea-theme/ui/ThemeColors.qml +43 -0
  122. package/test/fixtures/qml/flea-theme/ui/ViewState.qml +565 -0
  123. package/test/fixtures/qml/flea-theme/ui/js/Anchor.js +379 -0
  124. package/test/fixtures/qml/flea-theme/ui/js/AnchorHold.js +162 -0
  125. package/test/fixtures/qml/flea-theme/ui/js/Archive.js +157 -0
  126. package/test/fixtures/qml/flea-theme/ui/js/BackReturn.js +110 -0
  127. package/test/fixtures/qml/flea-theme/ui/js/Buttons.js +54 -0
  128. package/test/fixtures/qml/flea-theme/ui/js/ColumnMenu.js +111 -0
  129. package/test/fixtures/qml/flea-theme/ui/js/Columns.js +298 -0
  130. package/test/fixtures/qml/flea-theme/ui/js/Contrast.js +157 -0
  131. package/test/fixtures/qml/flea-theme/ui/js/Density.js +44 -0
  132. package/test/fixtures/qml/flea-theme/ui/js/DirSizes.js +129 -0
  133. package/test/fixtures/qml/flea-theme/ui/js/ExtThumbs.js +115 -0
  134. package/test/fixtures/qml/flea-theme/ui/js/Filter.js +284 -0
  135. package/test/fixtures/qml/flea-theme/ui/js/FolderSorts.js +108 -0
  136. package/test/fixtures/qml/flea-theme/ui/js/Format.js +305 -0
  137. package/test/fixtures/qml/flea-theme/ui/js/Icons.js +206 -0
  138. package/test/fixtures/qml/flea-theme/ui/js/Input.js +39 -0
  139. package/test/fixtures/qml/flea-theme/ui/js/KeyName.js +64 -0
  140. package/test/fixtures/qml/flea-theme/ui/js/Keymap.js +370 -0
  141. package/test/fixtures/qml/flea-theme/ui/js/Kinds.js +145 -0
  142. package/test/fixtures/qml/flea-theme/ui/js/Lists.js +11 -0
  143. package/test/fixtures/qml/flea-theme/ui/js/MakeDefault.js +186 -0
  144. package/test/fixtures/qml/flea-theme/ui/js/Match.js +27 -0
  145. package/test/fixtures/qml/flea-theme/ui/js/Material.js +109 -0
  146. package/test/fixtures/qml/flea-theme/ui/js/Menu.js +513 -0
  147. package/test/fixtures/qml/flea-theme/ui/js/Motion.js +16 -0
  148. package/test/fixtures/qml/flea-theme/ui/js/Mounts.js +320 -0
  149. package/test/fixtures/qml/flea-theme/ui/js/MouseNav.js +13 -0
  150. package/test/fixtures/qml/flea-theme/ui/js/Nav.js +328 -0
  151. package/test/fixtures/qml/flea-theme/ui/js/Palette.js +33 -0
  152. package/test/fixtures/qml/flea-theme/ui/js/Places.js +236 -0
  153. package/test/fixtures/qml/flea-theme/ui/js/Protocols.js +296 -0
  154. package/test/fixtures/qml/flea-theme/ui/js/RecentMode.js +154 -0
  155. package/test/fixtures/qml/flea-theme/ui/js/Search.js +396 -0
  156. package/test/fixtures/qml/flea-theme/ui/js/Settings.js +476 -0
  157. package/test/fixtures/qml/flea-theme/ui/js/SettingsAbout.js +99 -0
  158. package/test/fixtures/qml/flea-theme/ui/js/SettingsShelf.js +52 -0
  159. package/test/fixtures/qml/flea-theme/ui/js/Sort.js +127 -0
  160. package/test/fixtures/qml/flea-theme/ui/js/StateWords.js +42 -0
  161. package/test/fixtures/qml/flea-theme/ui/js/Swap.js +166 -0
  162. package/test/fixtures/qml/flea-theme/ui/js/TextSize.js +107 -0
  163. package/test/fixtures/qml/flea-theme/ui/js/Thumbs.js +158 -0
  164. package/test/fixtures/qml/flea-theme/ui/js/UiState.js +438 -0
  165. package/test/fixtures/qml/flea-theme/ui/js/Update.js +132 -0
  166. package/test/fixtures/qml/flea-theme/ui/js/UserDirs.js +56 -0
  167. package/test/fixtures/qml/flea-theme/ui/qmldir +227 -0
  168. package/test/fixtures/qml/hello.qml +26 -0
  169. package/test/fixtures/qml/imports/Demo/Toolkit/VersionLabel.qml +2 -0
  170. package/test/fixtures/qml/imports/Demo/Toolkit/VersionLabelNew.qml +2 -0
  171. package/test/fixtures/qml/imports/Demo/Toolkit/qmldir +3 -0
  172. package/test/fixtures/qml/imports/components/Badge.qml +15 -0
  173. package/test/fixtures/qml/imports/logic/base.js +2 -0
  174. package/test/fixtures/qml/imports/logic/counter.js +4 -0
  175. package/test/fixtures/qml/imports/logic/math.js +3 -0
  176. package/test/fixtures/qml/imports/logic/shared.js +3 -0
  177. package/test/fixtures/qml/imports/main.qml +21 -0
  178. package/test/fixtures/qml/imports/theme/Theme.qml +8 -0
  179. package/test/fixtures/qml/imports/theme/qmldir +2 -0
  180. package/test/fixtures/qml/multiply.qml +61 -0
  181. package/test/fixtures/qml-canvas-dom.js +90 -0
  182. package/test/fixtures/qml-media-audio.js +39 -0
  183. package/test/helpers/flea-sandbox.js +48 -0
  184. package/test/helpers/quickshell-fixture.js +125 -0
  185. package/test/html-clipboard-wayland.test.js +47 -0
  186. package/test/index.test.js +13 -0
  187. package/test/manual-casty-without-qml.js +87 -0
  188. package/test/manual-flea-application.js +49 -0
  189. package/test/manual-flea-binary.js +62 -0
  190. package/test/manual-flea-qml.js +9 -0
  191. package/test/manual-flea-settings.js +40 -0
  192. package/test/manual-flea-theme.js +40 -0
  193. package/test/manual-qml-removal.js +46 -0
  194. package/test/manual-without-qml.js +45 -0
  195. package/test/qml-canvas-dom.test.js +37 -0
  196. package/test/qml-clipboard-bridge.test.js +25 -0
  197. package/test/qml-clipboard-wayland.test.js +40 -0
  198. package/test/qml-connections-timing.test.js +73 -0
  199. package/test/qml-dynamic.test.js +401 -0
  200. package/test/qml-flea-app.test.js +146 -0
  201. package/test/qml-flea-backend.test.js +78 -0
  202. package/test/qml-flea-columns-backend.test.js +38 -0
  203. package/test/qml-flea-compile.test.js +78 -0
  204. package/test/qml-flea-launcher.test.js +88 -0
  205. package/test/qml-flea-markdown-backend.test.js +31 -0
  206. package/test/qml-flea-media.test.js +28 -0
  207. package/test/qml-flea-navigation-backend.test.js +35 -0
  208. package/test/qml-flea-navigation-timing.test.js +147 -0
  209. package/test/qml-flea-preview-backend.test.js +53 -0
  210. package/test/qml-flea-previews.test.js +163 -0
  211. package/test/qml-flea-rows.test.js +101 -0
  212. package/test/qml-flea-textures.test.js +59 -0
  213. package/test/qml-flea-view-switch.test.js +33 -0
  214. package/test/qml-flea-wayland.test.js +68 -0
  215. package/test/qml-flea.test.js +34 -0
  216. package/test/qml-host.test.js +238 -0
  217. package/test/qml-imports.test.js +102 -0
  218. package/test/qml-inherited-handlers.test.js +51 -0
  219. package/test/qml-input-selection.test.js +66 -0
  220. package/test/qml-interactions.test.js +309 -0
  221. package/test/qml-language.test.js +167 -0
  222. package/test/qml-launcher.test.js +54 -0
  223. package/test/qml-layout-text.test.js +205 -0
  224. package/test/qml-media.test.js +63 -0
  225. package/test/qml-persisted-delegates.test.js +45 -0
  226. package/test/qml-quickshell-controls.test.js +65 -0
  227. package/test/qml-quickshell-settings.test.js +62 -0
  228. package/test/qml-repeater.test.js +72 -0
  229. package/test/qml-style-updates.test.js +124 -0
  230. package/test/qml-textures.test.js +66 -0
  231. package/test/qml-views.test.js +332 -0
  232. package/test/qml-worker.test.js +69 -0
  233. package/test/qml.test.js +83 -0
  234. package/test/renderer-host.test.js +11 -0
  235. package/test/text-input-pointer.test.js +31 -0
  236. package/test/text-input-scroll.test.js +29 -0
  237. package/test/text-input.test.js +47 -0
  238. package/test/wayland-frontend.test.js +83 -0
  239. package/test/wayland-urlbar.test.js +38 -0
@@ -0,0 +1,238 @@
1
+ import { test, expect } from 'bun:test';
2
+ import { mkdtempSync, mkdirSync, 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
+ import { createQmlHost } from '../lib/qml/host.js';
7
+ const root = new URL('./fixtures/qml/flea-theme/', import.meta.url);
8
+ const delay = () => Bun.sleep(60);
9
+ async function until(fn) {
10
+ for (let i = 0; i < 40; i++) {
11
+ if (await fn()) return;
12
+ await delay();
13
+ }
14
+ throw Error('condition did not settle');
15
+ }
16
+ test('local QML has a fresh host process; same-directory HTML and HTTP QML stay isolated', async () => {
17
+ const dir = mkdtempSync(join(tmpdir(), 'qml-host-'));
18
+ await Bun.write(join(dir, 'main.qml'), 'import QtQuick\nText {text: "host"}');
19
+ await Bun.write(join(dir, 'plain.html'), '<title>plain</title>');
20
+ const b = await createBrowser({ spareRenderer: false });
21
+ try {
22
+ await b.context.navigate(Bun.pathToFileURL(join(dir, 'main.qml')).href);
23
+ const pid = await b.context.evaluate('process.pid');
24
+ expect(typeof pid).toBe('number');
25
+ expect(await b.context.evaluate('typeof __qmlHost.read')).toBe('function');
26
+ await b.context.reload();
27
+ expect(await b.context.evaluate('process.pid')).not.toBe(pid);
28
+ await b.context.navigate(Bun.pathToFileURL(join(dir, 'plain.html')).href);
29
+ expect(await b.context.evaluate('typeof Bun')).toBe('undefined');
30
+ expect(await b.context.evaluate('typeof __qmlHost')).toBe('undefined');
31
+ expect(await b.context.evaluate('document.title')).toBe('plain');
32
+ } finally {
33
+ b.close();
34
+ rmSync(dir, { recursive: true, force: true });
35
+ }
36
+ }, 30000);
37
+ test('real upstream Theme resolves palette, Commons typography and watches theme changes', async () => {
38
+ const dir = mkdtempSync(join(tmpdir(), 'qml-theme-')),
39
+ oldHome = process.env.HOME,
40
+ oldRoots = process.env.QML_IMPORT_PATH;
41
+ const current = join(dir, '.local/state/omarchy/current');
42
+ mkdirSync(join(current, 'theme'), { recursive: true });
43
+ await Bun.write(
44
+ join(current, 'theme/colors.toml'),
45
+ 'background = "#101315"\nforeground = "#eeeeee"\naccent = "#70e1e8"\nred = "#ff3333"\n',
46
+ );
47
+ await Bun.write(
48
+ join(current, 'theme/shell.toml'),
49
+ '[font]\nbase-size = 14\n',
50
+ );
51
+ await Bun.write(join(current, 'theme.name'), 'first');
52
+ process.env.HOME = dir;
53
+ process.env.QML_IMPORT_PATH = Bun.fileURLToPath(new URL('imports/', root));
54
+ const b = await createBrowser({ spareRenderer: false });
55
+ try {
56
+ await b.context.navigate(new URL('demo.qml', root).href);
57
+ const text = () => b.context.evaluate('document.body.innerText');
58
+ await until(async () =>
59
+ String(await text()).includes('Palette ready: true'),
60
+ );
61
+ expect(await text()).toContain('baseSize=14');
62
+ expect(await text()).not.toContain('NaN');
63
+ expect(await text()).not.toContain('undefined');
64
+ const background = () =>
65
+ b.context.evaluate(
66
+ 'document.querySelector("[data-qml-type=FloatingWindow]").style.backgroundColor',
67
+ );
68
+ const before = await background();
69
+ await b.context.evaluate('document.querySelector("button").click()');
70
+ expect(await background()).not.toBe(before);
71
+ await b.context.evaluate('document.querySelector("button").click()');
72
+ expect(await background()).toBe(before);
73
+ await Bun.write(
74
+ join(current, 'theme/shell.toml'),
75
+ '[font]\nbase-size = 18\n',
76
+ );
77
+ await Bun.write(join(current, 'theme.name'), 'second');
78
+ await until(async () => String(await text()).includes('baseSize=18'));
79
+ await Bun.write(
80
+ '/tmp/browser-real-flea-theme.png',
81
+ Buffer.from(await b.context.screenshot({ format: 'png' }), 'base64'),
82
+ );
83
+ } finally {
84
+ b.close();
85
+ if (oldHome === undefined) delete process.env.HOME;
86
+ else process.env.HOME = oldHome;
87
+ if (oldRoots === undefined) delete process.env.QML_IMPORT_PATH;
88
+ else process.env.QML_IMPORT_PATH = oldRoots;
89
+ rmSync(dir, { recursive: true, force: true });
90
+ }
91
+ }, 30000);
92
+ test('host disposal stops a running process and invalidates file access', async () => {
93
+ const host = createQmlHost('file:///tmp/test.qml');
94
+ let replied = false;
95
+ host.run([process.execPath, '-e', 'setTimeout(()=>{},10000)'], () => {
96
+ replied = true;
97
+ });
98
+ host.dispose();
99
+ expect(() => host.read('/tmp/test.qml')).toThrow('closed');
100
+ await delay();
101
+ expect(replied).toBe(false);
102
+ });
103
+ test('navigating away from a QML Process terminates its child, not just its callbacks', async () => {
104
+ const dir = mkdtempSync(join(tmpdir(), 'qml-process-')),
105
+ marker = join(dir, 'pid');
106
+ const command = [
107
+ process.execPath,
108
+ '-e',
109
+ `await Bun.write(${JSON.stringify(marker)},String(process.pid));setInterval(()=>{},1000)`,
110
+ ];
111
+ await Bun.write(
112
+ join(dir, 'process.qml'),
113
+ `import QtQuick\nimport Quickshell.Io\nItem {Process { running: true; command: ${JSON.stringify(command)} }} `,
114
+ );
115
+ await Bun.write(join(dir, 'plain.html'), '<p>plain</p>');
116
+ const b = await createBrowser({ spareRenderer: false });
117
+ let pid;
118
+ try {
119
+ await b.context.navigate(Bun.pathToFileURL(join(dir, 'process.qml')).href);
120
+ await until(() => Bun.file(marker).exists());
121
+ pid = Number(await Bun.file(marker).text());
122
+ process.kill(pid, 0);
123
+ await b.context.navigate(Bun.pathToFileURL(join(dir, 'plain.html')).href);
124
+ await until(() => {
125
+ try {
126
+ process.kill(pid, 0);
127
+ return false;
128
+ } catch {
129
+ return true;
130
+ }
131
+ });
132
+ expect(await b.context.evaluate('typeof __qmlHost')).toBe('undefined');
133
+ } finally {
134
+ b.close();
135
+ if (pid)
136
+ try {
137
+ process.kill(pid);
138
+ } catch {}
139
+ rmSync(dir, { recursive: true, force: true });
140
+ }
141
+ }, 30000);
142
+ test('QML Process stdout and exit signal reach reactive DOM', async () => {
143
+ const dir = mkdtempSync(join(tmpdir(), 'qml-stdout-'));
144
+ const command = [process.execPath, '-e', 'process.stdout.write("42")'];
145
+ await Bun.write(
146
+ join(dir, 'main.qml'),
147
+ `import QtQuick\nimport Quickshell.Io\nText {id: root; property int code: -1; text: collector.text + ":" + code; Process {running: true; command: ${JSON.stringify(command)}; stdout: StdioCollector {id: collector; waitForEnd: true}; onExited: function(exitCode,exitStatus) {root.code=exitCode} }}`,
148
+ );
149
+ const b = await createBrowser({ spareRenderer: false });
150
+ try {
151
+ await b.context.navigate(Bun.pathToFileURL(join(dir, 'main.qml')).href);
152
+ await until(
153
+ async () =>
154
+ (await b.context.evaluate('document.body.innerText')) === '42:0',
155
+ );
156
+ expect(await b.context.evaluate('document.body.innerText')).toBe('42:0');
157
+ } finally {
158
+ b.close();
159
+ rmSync(dir, { recursive: true, force: true });
160
+ }
161
+ }, 30000);
162
+
163
+ test('upstream SettingsRuler changes shared Theme, disables taps and follows the system again', async () => {
164
+ const dir=mkdtempSync(join(tmpdir(),'qml-settings-')),oldHome=process.env.HOME,oldRoots=process.env.QML_IMPORT_PATH;
165
+ const theme=join(dir,'.local/state/omarchy/current/theme');
166
+ mkdirSync(theme,{recursive:true});
167
+ await Bun.write(join(theme,'shell.toml'),'[font]\nbase-size = 14\n');
168
+ process.env.HOME=dir;
169
+ process.env.QML_IMPORT_PATH=Bun.fileURLToPath(new URL('imports/',root));
170
+ const b=await createBrowser({spareRenderer:false});
171
+ try {
172
+ await b.context.navigate(new URL('settings.qml',root).href);
173
+ const status=()=>b.context.evaluate('document.getElementById("status").textContent');
174
+ await until(async()=>await status()==='Size: 14 / clicks: 0');
175
+ const font=()=>b.context.evaluate('document.getElementById("sample").style.fontSize');
176
+ expect(await font()).toBe('14px');
177
+ const tap=async size=>{
178
+ const point=JSON.parse(await b.context.evaluate(`JSON.stringify((()=>{const e=[...document.querySelectorAll("[data-qml-type=Text]")].find(e=>e.textContent===${JSON.stringify(String(size))}).parentElement;const r=e.getBoundingClientRect();return [r.x+r.width/2,r.y+r.height/2]})())`));
179
+ await b.context.click(...point);
180
+ };
181
+ await tap(20);
182
+ expect(await status()).toBe('Size: 20 / clicks: 1');
183
+ expect(await font()).toBe('20px');
184
+ await b.context.evaluate('document.getElementById("toggle").click()');
185
+ await tap(9);
186
+ expect(await status()).toBe('Size: 20 / clicks: 1');
187
+ await b.context.evaluate('document.getElementById("toggle").click()');
188
+ await tap(9);
189
+ expect(await status()).toBe('Size: 9 / clicks: 2');
190
+ expect(await font()).toBe('9px');
191
+ await b.context.evaluate('document.getElementById("reset").click()');
192
+ expect(await status()).toBe('Size: 14 / clicks: 2');
193
+ expect(await font()).toBe('14px');
194
+ await Bun.write('/tmp/browser-flea-settings.png',Buffer.from(await b.context.screenshot({format:'png'}),'base64'));
195
+ } finally {
196
+ b.close();
197
+ if(oldHome===undefined)delete process.env.HOME;else process.env.HOME=oldHome;
198
+ if(oldRoots===undefined)delete process.env.QML_IMPORT_PATH;else process.env.QML_IMPORT_PATH=oldRoots;
199
+ rmSync(dir,{recursive:true,force:true});
200
+ }
201
+ },30000);
202
+
203
+ test('StdioCollector exposes original bytes separately from decoded text',async()=>{
204
+ const dir=mkdtempSync(join(tmpdir(),'qml-stdio-bytes-'));let browser;
205
+ try{
206
+ const command=[process.execPath,'-e','process.stdout.write(Buffer.from([0,255,228,184,173]))'];
207
+ await Bun.write(join(dir,'main.qml'),`import QtQuick
208
+ import Quickshell.Io
209
+ Text {id:root;text:"waiting"
210
+ Process {running:true;command:${JSON.stringify(command)}
211
+ stdout:StdioCollector {waitForEnd:true;onStreamFinished:root.text=Array.from(new Uint8Array(this.data)).join(",")+"|"+this.text.length}
212
+ }
213
+ }`);
214
+ browser=await createBrowser({spareRenderer:false});await browser.context.navigate(Bun.pathToFileURL(join(dir,'main.qml')).href);
215
+ await until(async()=>(await browser.context.evaluate('document.body.innerText'))!=='waiting');
216
+ expect(await browser.context.evaluate('document.body.innerText')).toBe('0,255,228,184,173|3');
217
+ }finally{browser?.close();rmSync(dir,{recursive:true,force:true});}
218
+ },30000);
219
+
220
+ test('Markdown soft breaks collapse while hard breaks and code lines survive with matching line boxes', async () => {
221
+ const markdown = '中文第一行\n第二行\n\nthird \nfourth\n\n```\ncode one\ncode two\n```';
222
+ const dir = mkdtempSync(join(tmpdir(), "qml-markdown-"));
223
+ const b = await createBrowser({spareRenderer:false});
224
+ try {
225
+ await Bun.write(join(dir, "main.qml"), `import QtQuick
226
+ Item {width:500;height:400
227
+ Text {id:md;width:450;text:${JSON.stringify(markdown)};textFormat:Text.MarkdownText;font.pixelSize:16}
228
+ Text {id:status;y:350;text:md.contentHeight+":"+md.lineCount}
229
+ }`);
230
+ await b.context.navigate(Bun.pathToFileURL(join(dir, "main.qml")).href);
231
+ const result = JSON.parse(await b.context.evaluate( `JSON.stringify({text:document.getElementById('md').textContent,code:document.getElementById('md').querySelector('pre').textContent,br:document.getElementById('md').querySelectorAll('br').length,line:parseFloat(document.getElementById('md').style.lineHeight),metrics:document.getElementById('status').textContent})`));
232
+ expect(result.text).toContain('中文第一行 第二行');
233
+ expect(result.code).toBe('code one\ncode two\n');
234
+ expect(result.br).toBe(1);
235
+ const [height, count] = result.metrics.split(':').map(Number);
236
+ expect(result.line).toBeCloseTo(height / count, 5);
237
+ } finally { b.close(); rmSync(dir, {recursive:true,force:true}); }
238
+ }, 30000);
@@ -0,0 +1,102 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {createBrowser} from '../lib/headless-shell.js';
3
+ import {loadQmlGraph,parseQmldir} from '../lib/qml/loader.js';
4
+ const fixture=new URL('./fixtures/qml/imports/main.qml',import.meta.url);
5
+ test('file imports: instances, scoped IDs, singleton, JS imports and qmldir versions',async()=>{
6
+ const browser=await createBrowser({spareRenderer:false});
7
+ try{
8
+ await browser.context.resize(640,440,1);await browser.context.navigate(fixture.href);
9
+ const text=()=>browser.context.evaluate('document.body.textContent');
10
+ expect(await text()).toContain('one:1:1');expect(await text()).toContain('two:1:1');
11
+ expect(await text()).toContain('Shared: 1 / multiply: 42');expect(await text()).toContain('Private ID: undefined');expect(await text()).toContain('module version 1.0');
12
+ await browser.context.evaluate('document.querySelector("[data-qml-type=MouseArea]").click()');
13
+ expect(await text()).toContain('changed');expect(await text()).toContain('two:2:1');expect(await text()).toContain('Shared: 2');
14
+ await Bun.write('/tmp/qml-real-imports.png',Buffer.from(await browser.context.screenshot({format:'png'}),'base64'));
15
+ }finally{browser.close();}
16
+ },30000);
17
+ test('qmldir version selection respects the requested minor version',()=>{
18
+ const text='module Demo\nThing 1.0 Old.qml\nThing 1.2 New.qml\nThing 2.0 Other.qml';
19
+ expect(parseQmldir(text,'file:///demo/qmldir','1.1').entries.get('Thing').url).toBe('file:///demo/Old.qml');
20
+ expect(parseQmldir(text,'file:///demo/qmldir','1.2').entries.get('Thing').url).toBe('file:///demo/New.qml');
21
+ });
22
+ test('remote QML cannot request a local file, and native plugins fail explicitly',async()=>{
23
+ const fetch=async()=>({ok:true,body:new TextEncoder().encode('plugin unsafe')});
24
+ await expect(loadQmlGraph('import "file:///tmp"\nItem {}','https://qml.test/main.qml',fetch)).rejects.toThrow('cannot import local');
25
+ await expect(loadQmlGraph('import "."\nItem {}','https://qml.test/main.qml',fetch)).rejects.toThrow('Unsupported qmldir directive');
26
+ });
27
+
28
+ test('HTTP imports share library JS, reset on navigation, and keep ordinary HTML working',async()=>{
29
+ const root=new URL('./fixtures/qml/imports/',import.meta.url);
30
+ const requests=[];
31
+ const server=Bun.serve({port:0,async fetch(req){
32
+ const path=new URL(req.url).pathname;requests.push(path);
33
+ if(path==='/plain')return new Response('<title>plain</title><p>ordinary HTML</p>',{headers:{'content-type':'text/html'}});
34
+ const file=Bun.file(new URL('.'+path,root));
35
+ return await file.exists()?new Response(file,{headers:{'content-type':path.endsWith('.qml')?'text/x-qml':'text/plain'}}):new Response('missing',{status:404});
36
+ }});
37
+ const browser=await createBrowser({spareRenderer:false});
38
+ try{
39
+ const url=`http://127.0.0.1:${server.port}/main.qml`;
40
+ let loaded;const ready=new Promise(resolve=>{loaded=browser.context.onLifecycle(name=>{if(name==='load')resolve();});});
41
+ await browser.context.navigate(url);await ready;loaded();
42
+ expect(await browser.context.evaluate('document.body.textContent')).toContain('one:1:1/1');
43
+ expect(await browser.context.evaluate('document.body.textContent')).toContain('two:1:1/2');
44
+ expect(requests.filter(p=>p==='/theme/Theme.qml').length).toBe(1);
45
+ expect(await browser.context.evaluate('typeof Bun')).toBe('undefined');
46
+ expect(await browser.context.evaluate('typeof __qmlHost')).toBe('undefined');
47
+ await browser.context.evaluate('document.querySelector("[data-qml-type=MouseArea]").click()');
48
+ const reloaded=new Promise(resolve=>{loaded=browser.context.onLifecycle(name=>{if(name==='load')resolve();});});
49
+ await browser.context.reload();await reloaded;loaded();
50
+ expect(await browser.context.evaluate('document.body.textContent')).toContain('one:1:1/1');
51
+ await browser.context.navigate(`http://127.0.0.1:${server.port}/plain`);
52
+ expect(await browser.context.evaluate('document.title')).toBe('plain');
53
+ }finally{browser.close();server.stop(true);}
54
+ },30000);
55
+
56
+ function memoryFetch(files){return async url=>files[url]===undefined?{ok:false,status:404}:{ok:true,body:new TextEncoder().encode(files[url])};}
57
+ test('cycle and missing resource diagnostics do not silently substitute stubs',async()=>{
58
+ const {createQmlDocument}=await import('../lib/qml/document.js');
59
+ const url='https://qml.test/main.qml';
60
+ const files={
61
+ 'https://qml.test/A.qml':'import QtQuick\nA {}',
62
+ 'https://qml.test/bad.js':'.import "./bad.js" as Self\nfunction f() {}',
63
+ };
64
+ const graph=await loadQmlGraph('import QtQuick\nA {}',url,memoryFetch(files));
65
+ expect(createQmlDocument('import QtQuick\nA {}',url,{graph}).document.body.textContent).toContain('Recursive QML component');
66
+ await expect(loadQmlGraph('import "./bad.js" as Bad\nItem {}',url,memoryFetch(files))).rejects.toThrow('Cyclic JS import');
67
+ await expect(loadQmlGraph('import "./missing.js" as Missing\nItem {}',url,memoryFetch(files))).rejects.toThrow('not found');
68
+ });
69
+ test('readonly component properties reject external overrides',async()=>{
70
+ const {loadQmlDocument}=await import('../lib/qml/document.js');
71
+ const files={
72
+ 'https://qml.test/qmldir':'Thing Thing.qml',
73
+ 'https://qml.test/Thing.qml':'import QtQuick\nItem { readonly property int count: 1 }'
74
+ };
75
+ const result=await loadQmlDocument('import QtQuick\nThing { count: 2 }','https://qml.test/main.qml',{fetchResource:memoryFetch(files)});
76
+ expect(result.document.body.textContent).toContain('Cannot override readonly');
77
+ });
78
+
79
+ test('mutual singleton references, absent pragma, duplicate aliases and import budgets are explicit',async()=>{
80
+ const {loadQmlDocument}=await import('../lib/qml/document.js');
81
+ const url='https://qml.test/main.qml';
82
+ const files={
83
+ 'https://qml.test/qmldir':'singleton A A.qml\nsingleton B B.qml',
84
+ 'https://qml.test/A.qml':'pragma Singleton\nimport QtQuick\nQtObject { property int n: B.n }',
85
+ 'https://qml.test/B.qml':'pragma Singleton\nimport QtQuick\nQtObject { property int n: A.n }',
86
+ };
87
+ const result=await loadQmlDocument('import QtQuick\nText { text: A.n }',url,{fetchResource:memoryFetch(files)});
88
+ // Singleton references may be mutual (Theme/Glass and Color/Style); object construction must terminate.
89
+ expect(result.document.querySelector('script')).not.toBeNull();
90
+ files['https://qml.test/B.qml']='import QtQuick\nQtObject { property int n: 1 }';
91
+ await expect(loadQmlGraph('import QtQuick\nText { text: A.n }',url,memoryFetch(files))).rejects.toThrow('needs pragma Singleton');
92
+ await expect(loadQmlGraph('import QtQuick as Q\nimport QtQuick.Controls as Q\nItem {}',url,memoryFetch({}))).rejects.toThrow('Duplicate QML import alias');
93
+ await expect(loadQmlGraph('import "./big.js" as Big\nItem {}',url,memoryFetch({'https://qml.test/big.js':' '.repeat(256*1024+1)}))).rejects.toThrow('byte limit');
94
+ });
95
+
96
+
97
+ test('qualified built-in imports resolve both object types and enums',async()=>{
98
+ const {loadQmlDocument}=await import('../lib/qml/document.js');
99
+ const result=await loadQmlDocument('import QtQuick as Q\nQ.Rectangle { Q.Text { text: "alias"; textFormat: Q.Text.PlainText } }','https://qml.test/main.qml',{fetchResource:memoryFetch({})});
100
+ expect(result.document.querySelector('[data-qml-type="Rectangle"]')).not.toBeNull();
101
+ expect(result.document.querySelector('[data-qml-type="Text"]')).not.toBeNull();
102
+ });
@@ -0,0 +1,51 @@
1
+ import { test, expect } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { createBrowser } from '../lib/headless-shell.js';
4
+
5
+ test('component handlers retain base connections when pooled delegates are reused', async () => {
6
+ const dir = mkdtempSync('/tmp/qml-inherited-handlers-');
7
+ let browser;
8
+ try {
9
+ await Bun.write(dir + '/Cell.qml', `import QtQuick
10
+ Text {
11
+ id: base
12
+ property bool live: true
13
+ property int baseCompleted: 0
14
+ property int baseReused: 0
15
+ width: 200; height: 20
16
+ Component.onCompleted: base.baseCompleted++
17
+ ListView.onPooled: base.live = false
18
+ ListView.onReused: { base.live = true; base.baseReused++ }
19
+ }`);
20
+ await Bun.write(dir + '/main.qml', `import QtQuick
21
+ import QtQml.Models
22
+ Item {
23
+ id: root; width:300; height:200
24
+ Component.onCompleted: rows.items.addGroups(0,3,"shown")
25
+ property string prefix: "old"
26
+ ListView {
27
+ id: view; width:200; height:100; model:rows; reuseItems:true
28
+ DelegateModel {id:rows;model:100;filterOnGroup:"shown";groups:DelegateModelGroup {id:shown;name:"shown";includeByDefault:false}}
29
+ delegate: Cell {
30
+ id: cell; required property int index
31
+ property int derivedCompleted: 0
32
+ property int derivedReused: 0
33
+ Component.onCompleted: cell.derivedCompleted++
34
+ ListView.onReused: cell.derivedReused++
35
+ text: live ? root.prefix+index+":"+baseCompleted+":"+derivedCompleted+":"+baseReused+":"+derivedReused : cell.text
36
+ }
37
+ }
38
+ Button { id: shrink; y:120; onClicked:{shown.removeGroups(1,2,"shown");view.forceLayout()} }
39
+ Button { id: grow; y:150; onClicked:{root.prefix="new";rows.items.addGroups(1,2,"shown");view.forceLayout()} }
40
+ }`);
41
+ browser = await createBrowser({ spareRenderer:false, rendererTimeout:20000 });
42
+ await browser.context.navigate(Bun.pathToFileURL(dir + '/main.qml').href);
43
+ const rows = () => browser.context.evaluate(`Array.from(document.querySelectorAll('[data-qml-type="Text"]')).filter(e=>e.getBoundingClientRect().width>0).map(e=>e.textContent).sort()`);
44
+ expect(await rows()).toEqual(['old0:1:1:0:0','old1:1:1:0:0','old2:1:1:0:0']);
45
+ for (let i=1;i<=3;i++) {
46
+ await browser.context.evaluate('document.getElementById("shrink").click()');
47
+ await browser.context.evaluate('document.getElementById("grow").click()');
48
+ expect(await rows()).toEqual(['new0:1:1:0:0',`new1:1:1:${i}:${i}`,`new2:1:1:${i}:${i}`]);
49
+ }
50
+ } finally { browser?.close(); rmSync(dir,{recursive:true,force:true}); }
51
+ },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('QML input selection paints full and partial ranges without changing input geometry',async()=>{
7
+ const dir=mkdtempSync('/tmp/qml-selection-');let browser;
8
+ try{
9
+ await Bun.write(dir+'/main.qml',`import QtQuick
10
+ Rectangle {width:360;height:160;color:"white"
11
+ TextInput {id:field;x:10;y:10;width:300;height:30;text:"abcdefghij";font.pixelSize:20;color:"black";selectionColor:"#00aaff";selectedTextColor:"white"}
12
+ Button {id:all;y:70;onClicked:{field.forceActiveFocus();field.selectAll()}}
13
+ Button {id:part;x:90;y:70;onClicked:{field.forceActiveFocus();field.select(2,5)}}
14
+ Button {id:none;x:180;y:70;onClicked:{field.forceActiveFocus();field.deselect()}}
15
+ }`);
16
+ browser=await createBrowser({spareRenderer:false});await browser.context.resize(360,160);await browser.context.navigate(Bun.pathToFileURL(dir+'/main.qml').href);
17
+ const run=s=>browser.context.evaluate(s),click=async id=>{await run(`document.getElementById('${id}').click()`);await run('0');};
18
+ const bounds=()=>run('document.getElementById("field").getBoundingClientRect().toJSON()');
19
+ const original=await bounds();
20
+ const {CanvasKit:kit}=await loadGraphics();
21
+ async function blue(){const png=Buffer.from(await browser.context.screenshot({format:'png'}),'base64'),im=kit.MakeImageFromEncoded(png);try{const data=im.readPixels(0,0,{width:360,height:160,colorType:kit.ColorType.RGBA_8888,alphaType:kit.AlphaType.Unpremul,colorSpace:kit.ColorSpace.SRGB});let n=0;for(let i=0;i<data.length;i+=4)if(data[i]===0&&data[i+1]===170&&data[i+2]===255)n++;return n;}finally{im.delete();}}
22
+ await click('all');const full=await blue();expect(full).toBeGreaterThan(300);expect(await bounds()).toEqual(original);
23
+ expect(await run('({start:document.getElementById("field").selectionStart,end:document.getElementById("field").selectionEnd})')).toEqual({start:0,end:10});
24
+ await click('part');const partial=await blue();expect(partial).toBeGreaterThan(50);expect(partial).toBeLessThan(full);expect(await bounds()).toEqual(original);
25
+ await click('none');expect(await blue()).toBe(0);
26
+ await browser.context.click(original.x+1,original.y+12);
27
+ expect(await run('document.getElementById("field").selectionStart')).toBe(0);
28
+ await browser.context.type('X');expect(await run('document.getElementById("field").value')).toBe('Xabcdefghij');
29
+ await click('all');await browser.context.press('Backspace',{code:'Backspace'});expect(await run('document.getElementById("field").value')).toBe('');expect(await blue()).toBe(0);
30
+ }finally{browser?.close();rmSync(dir,{recursive:true,force:true});}
31
+ },30000);
32
+
33
+ test('QML input arrows collapse and extend selection, and the caret paints and blinks',async()=>{
34
+ const dir=mkdtempSync('/tmp/qml-caret-');let browser;
35
+ try{
36
+ await Bun.write(dir+'/main.qml',`import QtQuick
37
+ Rectangle {width:360;height:100;color:"white"
38
+ TextInput {id:field;x:10;y:10;width:300;height:30;text:"abc😀xyz";font.pixelSize:20;color:"black"}
39
+ Button {id:all;y:60;onClicked:{field.forceActiveFocus();field.selectAll()}}
40
+ }`);
41
+ browser=await createBrowser({spareRenderer:false});await browser.context.resize(360,100);await browser.context.navigate(Bun.pathToFileURL(dir+'/main.qml').href);
42
+ const run=s=>browser.context.evaluate(s),state=()=>run(`(()=>{const e=document.getElementById('field');return [e.selectionStart,e.selectionEnd]})()`);
43
+ const key=async(k,modifiers=0)=>{await browser.context.press(k,{code:k,modifiers});await run('0');};
44
+ await run(`document.getElementById('all').click()`);await run('0');
45
+ await key('ArrowRight');expect(await state()).toEqual([8,8]);
46
+ await key('ArrowLeft');expect(await state()).toEqual([7,7]);
47
+ await key('ArrowLeft',8);expect(await state()).toEqual([6,7]);
48
+ await key('ArrowLeft',8);expect(await state()).toEqual([5,7]);
49
+ await key('ArrowLeft',8);expect(await state()).toEqual([3,7]);
50
+ await key('ArrowRight');expect(await state()).toEqual([7,7]);
51
+ await key('a',2);await key('ArrowLeft');expect(await state()).toEqual([0,0]);
52
+ const on=await browser.context.screenshot({format:'png'});
53
+ // Capture work can span a blink phase on a loaded machine. Observe several
54
+ // frames rather than assuming two wall-clock samples land in opposite phases.
55
+ let changed=false;
56
+ for(let i=0;i<20;i++){
57
+ await Bun.sleep(125);
58
+ if(await browser.context.screenshot({format:'png'})!==on){changed=true;break;}
59
+ }
60
+ expect(changed).toBe(true);
61
+ await run(`document.getElementById('field').blur()`);await run('0');
62
+ const blurred=await browser.context.screenshot({format:'png'});await Bun.sleep(550);
63
+ expect(await browser.context.screenshot({format:'png'})).toBe(blurred);
64
+
65
+ }finally{browser?.close();rmSync(dir,{recursive:true,force:true});}
66
+ },30000);