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,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);
@@ -0,0 +1,69 @@
1
+ import { test, expect } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { createBrowser } from '../lib/headless-shell.js';
5
+ async function run(files, source, verify) {
6
+ const dir = mkdtempSync('/tmp/qml-worker-');
7
+ let browser;
8
+ try {
9
+ for (const [name, text] of Object.entries({ ...files, 'main.qml': source }))
10
+ await Bun.write(join(dir, name), text);
11
+ browser = await createBrowser({ spareRenderer: false });
12
+ await browser.context.navigate(
13
+ Bun.pathToFileURL(join(dir, 'main.qml')).href,
14
+ );
15
+ const read = () =>
16
+ browser.context.evaluate('document.getElementById("result").textContent');
17
+ let text;
18
+ for (let i = 0; i < 200; i++) {
19
+ text = await read();
20
+ if (text !== 'waiting') break;
21
+ await Bun.sleep(20);
22
+ }
23
+ await verify(text, browser);
24
+ } finally {
25
+ browser?.close();
26
+ rmSync(dir, { recursive: true, force: true });
27
+ }
28
+ }
29
+ test('WorkerScript includes shared script scope, clones messages and keeps independent worker state', async () => {
30
+ await run(
31
+ {
32
+ 'worker.js':
33
+ 'Qt.include("helper.js");var count=0;WorkerScript.onMessage=function(m){m.answer=twice(m.n)+ ++count;WorkerScript.sendMessage(m);};',
34
+ 'helper.js': '.pragma library\nfunction twice(n){return n*2;}',
35
+ },
36
+ `import QtQuick
37
+ Text {id:result;text:"waiting";property var input:({n:4});property var replies:[]
38
+ WorkerScript {id:a;source:"worker.js";onMessage:function(m){result.replies=result.replies.concat([m.answer]);if(result.replies.length===2)result.text=JSON.stringify(result.replies)+":"+result.input.answer}}
39
+ WorkerScript {id:b;source:"worker.js";onMessage:function(m){result.replies=result.replies.concat([m.answer]);if(result.replies.length===2)result.text=JSON.stringify(result.replies)+":"+result.input.answer}}
40
+ Component.onCompleted:{a.sendMessage(input);b.sendMessage(input)}
41
+ }`,
42
+ (text) => expect(text).toBe('[9,9]:undefined'),
43
+ );
44
+ }, 30000);
45
+ test.skipIf(!process.env.BUNINU_FLEA_ROOT)(
46
+ 'unchanged Flea MarkdownWorker parses headings and paragraphs in a real worker',
47
+ async () => {
48
+ const url = Bun.pathToFileURL(
49
+ resolve(process.env.BUNINU_FLEA_ROOT, 'ui/MarkdownWorker.js'),
50
+ ).href;
51
+ await run(
52
+ {},
53
+ `import QtQuick
54
+ Text {id:result;text:"waiting"
55
+ WorkerScript {id:worker;source:${JSON.stringify(url)};onMessage:function(m){result.text=JSON.stringify(m)}}
56
+ Component.onCompleted:worker.sendMessage({seq:12,source:"# Hello\\n\\nA **bold** paragraph.",dir:"/tmp",chrome:"#000000",ink:"#ffffff"})
57
+ }`,
58
+ (text) => {
59
+ const result = JSON.parse(text);
60
+ expect(result.error).toBe('');
61
+ expect(result.seq).toBe(12);
62
+ expect(result.blocks.length).toBeGreaterThan(1);
63
+ expect(JSON.stringify(result.blocks)).toContain('Hello');
64
+ expect(JSON.stringify(result.blocks)).toContain('bold');
65
+ },
66
+ );
67
+ },
68
+ 30000,
69
+ );
@@ -0,0 +1,83 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {parseQml} from '../lib/qml/parser.js';
3
+ import {createQmlDocument} from '../lib/qml/document.js';
4
+ import {isQmlDocument} from '../lib/qml/format.js';
5
+ import {displayDocument} from '../lib/document-types.js';
6
+ import {RendererHost} from '../lib/renderer/host.js';
7
+ const url='https://qml.test/hello.qml';
8
+ const source=await Bun.file(new URL('./fixtures/qml/hello.qml',import.meta.url)).text();
9
+
10
+ test('QML builds DOM directly, preserves source through response classification',()=>{
11
+ const response=displayDocument({url,contentType:'text/plain',body:new TextEncoder().encode(source)});
12
+ expect(response).toEqual({source,contentType:'text/x-qml'});
13
+ expect(isQmlDocument(url,'text/html')).toBe(false);
14
+ const {document}=createQmlDocument(source,url);
15
+ expect(document.querySelectorAll('[data-qml-node]').length).toBe(4);
16
+ expect(document.querySelector('#root')).not.toBeNull();
17
+ expect(document.querySelector('script').textContent).toContain('addEventListener');
18
+ });
19
+ test('unsupported imports/types are visible diagnostics and source is inert',()=>{
20
+ const {document}=createQmlDocument('import Evil\nRectangle { color: "<script>boom</script>" }',url);
21
+ expect(document.body.textContent).toContain('Unsupported QML import');
22
+ expect(document.querySelector('script')).toBeNull();
23
+ expect(()=>parseQml('Rectangle { width: }')).toThrow();
24
+ const disabled=createQmlDocument(source,url,{scripting:false}).document;
25
+ expect(disabled.querySelector('script')).toBeNull();
26
+ expect(disabled.body.textContent).toContain('requires page scripting');
27
+ });
28
+ test('Acorn handles modern JS expressions and braces inside strings',()=>{
29
+ const ast=parseQml('import QtQuick\nText { text: ({a: "}"})?.a ?? "fallback"; onClicked: { console.log("}"); } }');
30
+ expect(ast.root.properties.length).toBe(2);
31
+ });
32
+ test('QML uses normal realm, reactive DOM, input, viewport resize and navigation',async()=>{
33
+ const host=new RendererHost({timeout:20000});
34
+ try{
35
+ await host.start(); await host.call('resize',640,360,1);
36
+ await host.call('loadDocument',{url,source,contentType:'text/x-qml'}); await host.call('whenLoaded');
37
+ expect(await host.call('evaluate','document.title')).toBe('QML DOM experiment');
38
+ expect(await host.call('evaluate','document.getElementById("button").style.width')).toBe('320px');
39
+ expect(await host.call('evaluate','document.body.textContent.includes("Hello World! Clicks: 0")')).toBe(true);
40
+ await host.call('evaluate',`document.querySelector('[data-qml-node="3"]').click()`);
41
+ expect(await host.call('evaluate',`document.querySelector('[data-qml-node="2"]').textContent`)).toBe('Hello World! Clicks: 1');
42
+ await host.call('resize',800,400,1);
43
+ expect(await host.call('evaluate','document.getElementById("button").style.width')).toBe('400px');
44
+ await host.call('loadDocument',{url,contentType:'text/x-qml',source:`import QtQuick
45
+ Rectangle {
46
+ id: root
47
+ implicitWidth: 300
48
+ implicitHeight: 120
49
+ property int count: 1
50
+ Rectangle { id: child; anchors.fill: parent
51
+ Text { id: label; text: root.count + ":" + parent.width }
52
+ }
53
+ onClicked: { root.width = 222; root.implicitWidth = 500; root.count++; }
54
+ }`}); await host.call('whenLoaded');
55
+ expect(await host.call('evaluate','document.getElementById("label").textContent')).toBe('1:300');
56
+ await host.call('evaluate','document.getElementById("root").click()');
57
+ expect(await host.call('evaluate','document.getElementById("label").textContent')).toBe('2:222');
58
+ await host.call('loadDocument',{url:'https://qml.test/plain' ,source:'<p>ordinary HTML</p>',contentType:'text/html'});await host.call('whenLoaded');
59
+ expect(await host.call('evaluate','document.body.textContent')).toBe('ordinary HTML');
60
+ }finally{host.close();}
61
+ },30000);
62
+
63
+ test('Quickshell multiply fixture uses editable DOM controls and click handlers',async()=>{
64
+ const host=new RendererHost({timeout:20000});
65
+ const source=await Bun.file(new URL('./fixtures/qml/multiply.qml',import.meta.url)).text();
66
+ try{
67
+ await host.start();await host.call('resize',640,480,1);
68
+ await host.call('loadDocument',{url:'https://qml.test/multiply.qml',source,contentType:'text/x-qml'});await host.call('whenLoaded');
69
+ expect(await host.call('evaluate',"document.querySelectorAll('input').length")).toBe(3);
70
+ const point=async selector=>JSON.parse(await host.call('evaluate',`JSON.stringify((()=>{const r=document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();return [r.x+r.width/2,r.y+r.height/2]})())`));
71
+ const click=async selector=>host.call('click',...await point(selector));
72
+ const value=()=>host.call('evaluate',"document.getElementById('result').value");
73
+ await click('button');expect(await value()).toBe('42');
74
+ await click('#first');await host.call('press','a',{modifiers:2});await host.call('type','-2.5');
75
+ await click('#second');await host.call('press','a',{modifiers:2});await host.call('type','4');
76
+ await click('button');expect(await value()).toBe('-10');
77
+ await click('#result');await host.call('type','999');expect(await value()).toBe('-10');
78
+ await click('#first');await host.call('press','a',{modifiers:2});await host.call('press','Backspace');
79
+ await click('button');expect(await value()).toBe('Please enter two valid numbers');
80
+ const rects=JSON.parse(await host.call('evaluate',"JSON.stringify([...document.querySelectorAll('input,button')].map(e=>{const r=e.getBoundingClientRect();return [r.x,r.y,r.width,r.height]}))"));
81
+ for(let i=1;i<rects.length;i++)expect(rects[i][1]).toBeGreaterThanOrEqual(rects[i-1][1]+rects[i-1][3]);
82
+ }finally{host.close();}
83
+ },30000);
@@ -70,3 +70,14 @@ test("fetches only http(s)/data resources on the renderer's behalf", async () =>
70
70
  .toEqual({ error: "blocked script-with-cookies request for https:" });
71
71
  expect(requested).toEqual(["https://example.test/a.css"]);
72
72
  });
73
+
74
+
75
+ test("explicit close prevents queued calls and captures from restarting the renderer", async () => {
76
+ const renderer = host();
77
+ const queued = renderer.call("title");
78
+ renderer.close();
79
+ await expect(queued).rejects.toThrow("Renderer is closed");
80
+ await expect(renderer.start()).rejects.toThrow("Renderer is closed");
81
+ await expect(renderer.composite("screenshot", { format: "bgra" })).rejects.toThrow("Renderer is closed");
82
+ expect(renderer.pid).toBeNull();
83
+ });
@@ -0,0 +1,31 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {createBrowser} from '../lib/headless-shell.js';
3
+ test('pointer places the caret at shaped text, including selected and scrolled controls',async()=>{
4
+ const b=await createBrowser({spareRenderer:false});const run=s=>b.context.evaluate(s);
5
+ try{
6
+ await b.context.resize(500,400);
7
+ await b.context.navigate('data:text/html,'+encodeURIComponent(`<style>body{margin:0}input,textarea{display:block;margin:10px;padding:0;border:0;font:20px monospace;line-height:24px;width:160px}input{height:24px}textarea{height:72px}</style><input id="i" value="abcdefghij"><textarea id="t">abcde\nfghij\nklmno</textarea><input id="p" type="password" value="a😀bcd">`));
8
+ const pos=()=>run('({start:document.activeElement.selectionStart,end:document.activeElement.selectionEnd})');
9
+ // 20px monospace advances approximately 12px. Click the boundary after b.
10
+ await b.context.click(34,20);expect(await pos()).toEqual({start:2,end:2});
11
+ await b.context.press('a',{modifiers:2});await b.context.click(46,20);expect(await pos()).toEqual({start:3,end:3});
12
+ await b.context.type('X');expect(await run('document.getElementById("i").value')).toBe('abcXdefghij');
13
+ await run('document.getElementById("i").value="abcdefghijklmnopqrstuvwxyz"');
14
+ await b.context.press('End');
15
+ expect(await run('document.getElementById("i").scrollLeft')).toBeGreaterThan(0);
16
+ await b.context.click(45,20);const scrolled=await pos();expect(scrolled.start).toBeGreaterThan(10);expect(scrolled.start).toBeLessThan(26);
17
+ const top=await run('document.getElementById("t").getBoundingClientRect().top');
18
+ await b.context.click(34,top+36);expect(await pos()).toEqual({start:8,end:8});
19
+ await b.context.click(400,top+36); // outside must not reposition
20
+ expect(await pos()).toEqual({start:8,end:8});
21
+ await run('document.getElementById("t").value="abcde ".repeat(20);document.getElementById("t").focus();document.getElementById("t").setSelectionRange(0,0)');
22
+ await b.context.click(34,top+36);const wrapped=await pos();expect(wrapped.start).toBeGreaterThan(10);expect(wrapped.start).toBeLessThan(30);
23
+ await b.context.press('End',{modifiers:2});
24
+ expect(await run('document.getElementById("t").scrollTop')).toBeGreaterThan(0);
25
+ await b.context.click(34,top+12);expect((await pos()).start).toBeGreaterThan(60);expect((await pos()).start).toBeLessThan(120);
26
+ const passTop=await run('document.getElementById("p").getBoundingClientRect().top');
27
+ await b.context.click(34,passTop+12);expect(await pos()).toEqual({start:3,end:3});
28
+ await run('document.getElementById("i").onmousedown=e=>e.preventDefault();document.getElementById("i").focus();document.getElementById("i").setSelectionRange(5,5)');
29
+ await b.context.click(12,20);expect(await pos()).toEqual({start:5,end:5});
30
+ }finally{b.close();}
31
+ },30000);
@@ -0,0 +1,29 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {createBrowser} from '../lib/headless-shell.js';
3
+ import {loadGraphics} from '../lib/renderer/page-renderer.js';
4
+ test('text controls clip overflow, keep long inputs on one line and reveal the caret',async()=>{
5
+ const b=await createBrowser({spareRenderer:false});
6
+ const run=s=>b.context.evaluate(s);
7
+ try {
8
+ await b.context.resize(400,300);
9
+ await b.context.navigate('data:text/html,'+encodeURIComponent(`<style>body{margin:0;background:white}input,textarea{display:block;width:200px;box-sizing:border-box;border:1px solid black;padding:4px;font:18px monospace;margin:10px}input{height:36px}textarea{height:100px}</style><input id="i" value="${'abcdef'.repeat(20)}"><textarea id="t">${Array.from({length:12},(_,i)=>'第'+i+'行 abcdef').join('\n')}</textarea><button id="b">after</button>`));
10
+ const state=id=>run(`(()=>{const e=document.getElementById('${id}');return {x:e.scrollLeft,y:e.scrollTop,w:e.scrollWidth,h:e.scrollHeight,rect:e.getBoundingClientRect().toJSON()}})()`);
11
+ const initial=await state('i'),area=await state('t');
12
+ expect(area.rect.y).toBe(56);
13
+ expect((await run(`document.getElementById('b').getBoundingClientRect().top`))).toBeLessThan(180);
14
+ const {CanvasKit:k}=await loadGraphics();
15
+ const ink=async(x,y,w,h)=>{
16
+ const bytes=Buffer.from(await b.context.screenshot({format:'png'}),'base64'),im=k.MakeImageFromEncoded(bytes);
17
+ try{const p=im.readPixels(x,y,{width:w,height:h,colorType:k.ColorType.RGBA_8888,alphaType:k.AlphaType.Unpremul,colorSpace:k.ColorSpace.SRGB});let count=0;for(let i=0;i<p.length;i+=4)if(p[i]<180&&p[i+1]<180&&p[i+2]<180)count++;return count;}finally{im.delete();}
18
+ };
19
+ expect(await ink(212,10,180,145)).toBe(0);
20
+ for(const id of ['i','t']) {
21
+ await run(`document.getElementById('${id}').focus();document.getElementById('${id}').setSelectionRange(0,0)`);
22
+ await b.context.press('End',{modifiers:2});
23
+ const end=await state(id);expect(id==='i'?end.x:end.y).toBeGreaterThan(0);
24
+ if(id==='i'){expect(end.h).toBe(initial.h);expect(await ink(16,16,180,20)).toBeGreaterThan(100);}
25
+ await b.context.press('Home',{modifiers:2});const home=await state(id);expect(id==='i'?home.x:home.y).toBe(0);
26
+ await b.context.press('End',{modifiers:10});const selected=await state(id);expect(id==='i'?selected.x:selected.y).toBeGreaterThan(0);
27
+ }
28
+ }finally{b.close();}
29
+ },30000);