@zenalexa/unicli 0.217.0 → 0.217.3

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 (341) hide show
  1. package/AGENTS.md +7 -6
  2. package/README.md +59 -19
  3. package/README.zh-CN.md +36 -15
  4. package/crates/unicli-atspi/Cargo.toml +47 -0
  5. package/crates/unicli-atspi/README.md +6 -0
  6. package/crates/unicli-atspi/src/errors.rs +213 -0
  7. package/crates/unicli-atspi/src/input.rs +1004 -0
  8. package/crates/unicli-atspi/src/invoke.rs +1132 -0
  9. package/crates/unicli-atspi/src/main.rs +130 -0
  10. package/crates/unicli-atspi/src/refs.rs +24 -0
  11. package/crates/unicli-atspi/src/screenshot.rs +756 -0
  12. package/crates/unicli-atspi/src/tree.rs +2319 -0
  13. package/crates/unicli-shared/Cargo.toml +13 -0
  14. package/crates/unicli-shared/src/lib.rs +77 -0
  15. package/crates/unicli-uia/Cargo.toml +29 -0
  16. package/crates/unicli-uia/README.md +6 -0
  17. package/crates/unicli-uia/src/errors.rs +179 -0
  18. package/crates/unicli-uia/src/input.rs +790 -0
  19. package/crates/unicli-uia/src/invoke.rs +977 -0
  20. package/crates/unicli-uia/src/main.rs +130 -0
  21. package/crates/unicli-uia/src/refs.rs +24 -0
  22. package/crates/unicli-uia/src/screenshot.rs +685 -0
  23. package/crates/unicli-uia/src/tree.rs +2135 -0
  24. package/dist/adapters/macos/actions.d.ts +9 -0
  25. package/dist/adapters/macos/actions.d.ts.map +1 -0
  26. package/dist/adapters/macos/actions.js +55 -0
  27. package/dist/adapters/macos/actions.js.map +1 -0
  28. package/dist/browser/bridge.d.ts +2 -0
  29. package/dist/browser/bridge.d.ts.map +1 -1
  30. package/dist/browser/bridge.js +39 -0
  31. package/dist/browser/bridge.js.map +1 -1
  32. package/dist/browser/cdp-client.d.ts +2 -0
  33. package/dist/browser/cdp-client.d.ts.map +1 -1
  34. package/dist/browser/cdp-client.js +7 -0
  35. package/dist/browser/cdp-client.js.map +1 -1
  36. package/dist/browser/page.d.ts +2 -0
  37. package/dist/browser/page.d.ts.map +1 -1
  38. package/dist/browser/page.js +35 -0
  39. package/dist/browser/page.js.map +1 -1
  40. package/dist/cli.d.ts.map +1 -1
  41. package/dist/cli.js +13 -1
  42. package/dist/cli.js.map +1 -1
  43. package/dist/commands/approvals.d.ts +3 -0
  44. package/dist/commands/approvals.d.ts.map +1 -0
  45. package/dist/commands/approvals.js +123 -0
  46. package/dist/commands/approvals.js.map +1 -0
  47. package/dist/commands/browser-operator-runtime.d.ts.map +1 -1
  48. package/dist/commands/browser-operator-runtime.js +1 -0
  49. package/dist/commands/browser-operator-runtime.js.map +1 -1
  50. package/dist/commands/browser-operator.d.ts.map +1 -1
  51. package/dist/commands/browser-operator.js +84 -12
  52. package/dist/commands/browser-operator.js.map +1 -1
  53. package/dist/commands/compute.d.ts +3 -0
  54. package/dist/commands/compute.d.ts.map +1 -0
  55. package/dist/commands/compute.js +324 -0
  56. package/dist/commands/compute.js.map +1 -0
  57. package/dist/commands/dispatch.d.ts.map +1 -1
  58. package/dist/commands/dispatch.js +10 -4
  59. package/dist/commands/dispatch.js.map +1 -1
  60. package/dist/commands/doctor-compute.d.ts +38 -0
  61. package/dist/commands/doctor-compute.d.ts.map +1 -0
  62. package/dist/commands/doctor-compute.js +376 -0
  63. package/dist/commands/doctor-compute.js.map +1 -0
  64. package/dist/commands/lint.d.ts.map +1 -1
  65. package/dist/commands/lint.js +69 -1
  66. package/dist/commands/lint.js.map +1 -1
  67. package/dist/commands/mcp.d.ts.map +1 -1
  68. package/dist/commands/mcp.js +4 -0
  69. package/dist/commands/mcp.js.map +1 -1
  70. package/dist/commands/runs.d.ts +3 -0
  71. package/dist/commands/runs.d.ts.map +1 -0
  72. package/dist/commands/runs.js +367 -0
  73. package/dist/commands/runs.js.map +1 -0
  74. package/dist/core/envelope.d.ts +8 -0
  75. package/dist/core/envelope.d.ts.map +1 -1
  76. package/dist/core/envelope.js +1 -0
  77. package/dist/core/envelope.js.map +1 -1
  78. package/dist/discovery/aliases.d.ts.map +1 -1
  79. package/dist/discovery/aliases.js +15 -0
  80. package/dist/discovery/aliases.js.map +1 -1
  81. package/dist/discovery/loader.d.ts.map +1 -1
  82. package/dist/discovery/loader.js +9 -0
  83. package/dist/discovery/loader.js.map +1 -1
  84. package/dist/discovery/macos-dynamic.d.ts +58 -0
  85. package/dist/discovery/macos-dynamic.d.ts.map +1 -0
  86. package/dist/discovery/macos-dynamic.js +429 -0
  87. package/dist/discovery/macos-dynamic.js.map +1 -0
  88. package/dist/discovery/search.d.ts.map +1 -1
  89. package/dist/discovery/search.js +152 -3
  90. package/dist/discovery/search.js.map +1 -1
  91. package/dist/electron-apps.d.ts +1 -0
  92. package/dist/electron-apps.d.ts.map +1 -1
  93. package/dist/electron-apps.js +1 -0
  94. package/dist/electron-apps.js.map +1 -1
  95. package/dist/engine/approval-store.d.ts +43 -0
  96. package/dist/engine/approval-store.d.ts.map +1 -0
  97. package/dist/engine/approval-store.js +193 -0
  98. package/dist/engine/approval-store.js.map +1 -0
  99. package/dist/engine/browser/action-evidence.d.ts +2 -0
  100. package/dist/engine/browser/action-evidence.d.ts.map +1 -1
  101. package/dist/engine/browser/action-evidence.js +35 -1
  102. package/dist/engine/browser/action-evidence.js.map +1 -1
  103. package/dist/engine/browser/evidence.d.ts +22 -0
  104. package/dist/engine/browser/evidence.d.ts.map +1 -1
  105. package/dist/engine/browser/evidence.js +72 -0
  106. package/dist/engine/browser/evidence.js.map +1 -1
  107. package/dist/engine/browser/session-lease.d.ts +53 -0
  108. package/dist/engine/browser/session-lease.d.ts.map +1 -0
  109. package/dist/engine/browser/session-lease.js +100 -0
  110. package/dist/engine/browser/session-lease.js.map +1 -0
  111. package/dist/engine/browser/session-lock.d.ts +17 -0
  112. package/dist/engine/browser/session-lock.d.ts.map +1 -0
  113. package/dist/engine/browser/session-lock.js +114 -0
  114. package/dist/engine/browser/session-lock.js.map +1 -0
  115. package/dist/engine/browser/session-runtime.d.ts +10 -0
  116. package/dist/engine/browser/session-runtime.d.ts.map +1 -0
  117. package/dist/engine/browser/session-runtime.js +87 -0
  118. package/dist/engine/browser/session-runtime.js.map +1 -0
  119. package/dist/engine/capability-policy.d.ts +13 -2
  120. package/dist/engine/capability-policy.d.ts.map +1 -1
  121. package/dist/engine/capability-policy.js +113 -3
  122. package/dist/engine/capability-policy.js.map +1 -1
  123. package/dist/engine/executor.d.ts +8 -3
  124. package/dist/engine/executor.d.ts.map +1 -1
  125. package/dist/engine/executor.js +9 -2
  126. package/dist/engine/executor.js.map +1 -1
  127. package/dist/engine/kernel/execute.d.ts +1 -0
  128. package/dist/engine/kernel/execute.d.ts.map +1 -1
  129. package/dist/engine/kernel/execute.js +125 -3
  130. package/dist/engine/kernel/execute.js.map +1 -1
  131. package/dist/engine/kernel/types.d.ts +13 -0
  132. package/dist/engine/kernel/types.d.ts.map +1 -1
  133. package/dist/engine/operation-policy.d.ts +9 -1
  134. package/dist/engine/operation-policy.d.ts.map +1 -1
  135. package/dist/engine/operation-policy.js +6 -2
  136. package/dist/engine/operation-policy.js.map +1 -1
  137. package/dist/engine/permission-rules.d.ts +43 -0
  138. package/dist/engine/permission-rules.d.ts.map +1 -0
  139. package/dist/engine/permission-rules.js +401 -0
  140. package/dist/engine/permission-rules.js.map +1 -0
  141. package/dist/engine/permission-runtime.d.ts +11 -0
  142. package/dist/engine/permission-runtime.d.ts.map +1 -0
  143. package/dist/engine/permission-runtime.js +21 -0
  144. package/dist/engine/permission-runtime.js.map +1 -0
  145. package/dist/engine/repair/remedies.d.ts +4 -0
  146. package/dist/engine/repair/remedies.d.ts.map +1 -0
  147. package/dist/engine/repair/remedies.js +169 -0
  148. package/dist/engine/repair/remedies.js.map +1 -0
  149. package/dist/engine/runtime-resource-guard.d.ts +23 -0
  150. package/dist/engine/runtime-resource-guard.d.ts.map +1 -0
  151. package/dist/engine/runtime-resource-guard.js +85 -0
  152. package/dist/engine/runtime-resource-guard.js.map +1 -0
  153. package/dist/engine/session/args.d.ts +3 -0
  154. package/dist/engine/session/args.d.ts.map +1 -0
  155. package/dist/engine/session/args.js +17 -0
  156. package/dist/engine/session/args.js.map +1 -0
  157. package/dist/engine/session/compare.d.ts +92 -0
  158. package/dist/engine/session/compare.d.ts.map +1 -0
  159. package/dist/engine/session/compare.js +324 -0
  160. package/dist/engine/session/compare.js.map +1 -0
  161. package/dist/engine/session/environment.d.ts +4 -0
  162. package/dist/engine/session/environment.d.ts.map +1 -0
  163. package/dist/engine/session/environment.js +25 -0
  164. package/dist/engine/session/environment.js.map +1 -0
  165. package/dist/engine/session/events.d.ts +2 -0
  166. package/dist/engine/session/events.d.ts.map +1 -1
  167. package/dist/engine/session/events.js +12 -0
  168. package/dist/engine/session/events.js.map +1 -1
  169. package/dist/engine/session/query.d.ts +47 -0
  170. package/dist/engine/session/query.d.ts.map +1 -0
  171. package/dist/engine/session/query.js +299 -0
  172. package/dist/engine/session/query.js.map +1 -0
  173. package/dist/engine/session/replay.d.ts +35 -0
  174. package/dist/engine/session/replay.d.ts.map +1 -0
  175. package/dist/engine/session/replay.js +144 -0
  176. package/dist/engine/session/replay.js.map +1 -0
  177. package/dist/engine/session/run-loop.d.ts.map +1 -1
  178. package/dist/engine/session/run-loop.js +62 -23
  179. package/dist/engine/session/run-loop.js.map +1 -1
  180. package/dist/engine/session/store.d.ts +7 -0
  181. package/dist/engine/session/store.d.ts.map +1 -1
  182. package/dist/engine/session/store.js +131 -1
  183. package/dist/engine/session/store.js.map +1 -1
  184. package/dist/engine/session/types.d.ts +3 -1
  185. package/dist/engine/session/types.d.ts.map +1 -1
  186. package/dist/engine/steps/compute.d.ts +41 -0
  187. package/dist/engine/steps/compute.d.ts.map +1 -0
  188. package/dist/engine/steps/compute.js +55 -0
  189. package/dist/engine/steps/compute.js.map +1 -0
  190. package/dist/engine/steps/desktop-ax.d.ts +8 -0
  191. package/dist/engine/steps/desktop-ax.d.ts.map +1 -1
  192. package/dist/engine/steps/desktop-ax.js +16 -0
  193. package/dist/engine/steps/desktop-ax.js.map +1 -1
  194. package/dist/engine/steps/desktop-sidecar.d.ts +49 -0
  195. package/dist/engine/steps/desktop-sidecar.d.ts.map +1 -0
  196. package/dist/engine/steps/desktop-sidecar.js +50 -0
  197. package/dist/engine/steps/desktop-sidecar.js.map +1 -0
  198. package/dist/engine/steps/download.d.ts +1 -1
  199. package/dist/engine/steps/download.d.ts.map +1 -1
  200. package/dist/engine/steps/download.js +24 -2
  201. package/dist/engine/steps/download.js.map +1 -1
  202. package/dist/engine/steps/exec.d.ts +1 -1
  203. package/dist/engine/steps/exec.d.ts.map +1 -1
  204. package/dist/engine/steps/exec.js +23 -7
  205. package/dist/engine/steps/exec.js.map +1 -1
  206. package/dist/engine/steps/fetch-text.d.ts +1 -1
  207. package/dist/engine/steps/fetch-text.d.ts.map +1 -1
  208. package/dist/engine/steps/fetch-text.js +12 -4
  209. package/dist/engine/steps/fetch-text.js.map +1 -1
  210. package/dist/engine/steps/fetch.d.ts +2 -1
  211. package/dist/engine/steps/fetch.d.ts.map +1 -1
  212. package/dist/engine/steps/fetch.js +29 -6
  213. package/dist/engine/steps/fetch.js.map +1 -1
  214. package/dist/engine/steps/index.d.ts +2 -0
  215. package/dist/engine/steps/index.d.ts.map +1 -1
  216. package/dist/engine/steps/index.js +2 -0
  217. package/dist/engine/steps/index.js.map +1 -1
  218. package/dist/engine/steps/navigate.d.ts +1 -1
  219. package/dist/engine/steps/navigate.d.ts.map +1 -1
  220. package/dist/engine/steps/navigate.js +29 -2
  221. package/dist/engine/steps/navigate.js.map +1 -1
  222. package/dist/fast-path.d.ts.map +1 -1
  223. package/dist/fast-path.js +96 -12
  224. package/dist/fast-path.js.map +1 -1
  225. package/dist/manifest-compact.txt +2 -2
  226. package/dist/manifest-search.json +1 -1
  227. package/dist/manifest.json +1024 -1
  228. package/dist/mcp/handler.d.ts +2 -2
  229. package/dist/mcp/handler.d.ts.map +1 -1
  230. package/dist/mcp/handler.js +59 -5
  231. package/dist/mcp/handler.js.map +1 -1
  232. package/dist/mcp/profiles/computer-use.d.ts +4 -0
  233. package/dist/mcp/profiles/computer-use.d.ts.map +1 -0
  234. package/dist/mcp/profiles/computer-use.js +305 -0
  235. package/dist/mcp/profiles/computer-use.js.map +1 -0
  236. package/dist/mcp/server.d.ts.map +1 -1
  237. package/dist/mcp/server.js +30 -6
  238. package/dist/mcp/server.js.map +1 -1
  239. package/dist/mcp/tools.d.ts +9 -0
  240. package/dist/mcp/tools.d.ts.map +1 -1
  241. package/dist/mcp/tools.js +20 -0
  242. package/dist/mcp/tools.js.map +1 -1
  243. package/dist/output/envelope.d.ts +6 -0
  244. package/dist/output/envelope.d.ts.map +1 -1
  245. package/dist/output/envelope.js.map +1 -1
  246. package/dist/output/error-map.d.ts.map +1 -1
  247. package/dist/output/error-map.js +4 -0
  248. package/dist/output/error-map.js.map +1 -1
  249. package/dist/registry.d.ts +1 -0
  250. package/dist/registry.d.ts.map +1 -1
  251. package/dist/registry.js +5 -0
  252. package/dist/registry.js.map +1 -1
  253. package/dist/transport/adapters/cdp-browser.d.ts +38 -2
  254. package/dist/transport/adapters/cdp-browser.d.ts.map +1 -1
  255. package/dist/transport/adapters/cdp-browser.js +349 -22
  256. package/dist/transport/adapters/cdp-browser.js.map +1 -1
  257. package/dist/transport/adapters/desktop-atspi.d.ts +23 -17
  258. package/dist/transport/adapters/desktop-atspi.d.ts.map +1 -1
  259. package/dist/transport/adapters/desktop-atspi.js +143 -32
  260. package/dist/transport/adapters/desktop-atspi.js.map +1 -1
  261. package/dist/transport/adapters/desktop-ax-helpers.d.ts +24 -0
  262. package/dist/transport/adapters/desktop-ax-helpers.d.ts.map +1 -0
  263. package/dist/transport/adapters/desktop-ax-helpers.js +190 -0
  264. package/dist/transport/adapters/desktop-ax-helpers.js.map +1 -0
  265. package/dist/transport/adapters/desktop-ax-swift.d.ts +13 -0
  266. package/dist/transport/adapters/desktop-ax-swift.d.ts.map +1 -1
  267. package/dist/transport/adapters/desktop-ax-swift.js +176 -2
  268. package/dist/transport/adapters/desktop-ax-swift.js.map +1 -1
  269. package/dist/transport/adapters/desktop-ax.d.ts +11 -2
  270. package/dist/transport/adapters/desktop-ax.d.ts.map +1 -1
  271. package/dist/transport/adapters/desktop-ax.js +131 -16
  272. package/dist/transport/adapters/desktop-ax.js.map +1 -1
  273. package/dist/transport/adapters/desktop-sidecar-errors.d.ts +3 -0
  274. package/dist/transport/adapters/desktop-sidecar-errors.d.ts.map +1 -0
  275. package/dist/transport/adapters/desktop-sidecar-errors.js +34 -0
  276. package/dist/transport/adapters/desktop-sidecar-errors.js.map +1 -0
  277. package/dist/transport/adapters/desktop-sidecar-snapshot.d.ts +10 -0
  278. package/dist/transport/adapters/desktop-sidecar-snapshot.d.ts.map +1 -0
  279. package/dist/transport/adapters/desktop-sidecar-snapshot.js +89 -0
  280. package/dist/transport/adapters/desktop-sidecar-snapshot.js.map +1 -0
  281. package/dist/transport/adapters/desktop-uia.d.ts +23 -17
  282. package/dist/transport/adapters/desktop-uia.d.ts.map +1 -1
  283. package/dist/transport/adapters/desktop-uia.js +142 -32
  284. package/dist/transport/adapters/desktop-uia.js.map +1 -1
  285. package/dist/transport/adapters/subprocess.d.ts +7 -0
  286. package/dist/transport/adapters/subprocess.d.ts.map +1 -1
  287. package/dist/transport/adapters/subprocess.js +64 -0
  288. package/dist/transport/adapters/subprocess.js.map +1 -1
  289. package/dist/transport/bus.d.ts +2 -0
  290. package/dist/transport/bus.d.ts.map +1 -1
  291. package/dist/transport/bus.js +7 -11
  292. package/dist/transport/bus.js.map +1 -1
  293. package/dist/transport/capability.d.ts.map +1 -1
  294. package/dist/transport/capability.js +123 -98
  295. package/dist/transport/capability.js.map +1 -1
  296. package/dist/transport/cascade.d.ts +5 -0
  297. package/dist/transport/cascade.d.ts.map +1 -0
  298. package/dist/transport/cascade.js +550 -0
  299. package/dist/transport/cascade.js.map +1 -0
  300. package/dist/transport/cdp-session.d.ts +11 -0
  301. package/dist/transport/cdp-session.d.ts.map +1 -0
  302. package/dist/transport/cdp-session.js +52 -0
  303. package/dist/transport/cdp-session.js.map +1 -0
  304. package/dist/transport/refs.d.ts +51 -0
  305. package/dist/transport/refs.d.ts.map +1 -0
  306. package/dist/transport/refs.js +135 -0
  307. package/dist/transport/refs.js.map +1 -0
  308. package/dist/transport/sidecar-binary.d.ts +18 -0
  309. package/dist/transport/sidecar-binary.d.ts.map +1 -0
  310. package/dist/transport/sidecar-binary.js +55 -0
  311. package/dist/transport/sidecar-binary.js.map +1 -0
  312. package/dist/transport/sidecar.d.ts +35 -0
  313. package/dist/transport/sidecar.d.ts.map +1 -0
  314. package/dist/transport/sidecar.js +134 -0
  315. package/dist/transport/sidecar.js.map +1 -0
  316. package/dist/transport/snapshot-encoder.d.ts +34 -0
  317. package/dist/transport/snapshot-encoder.d.ts.map +1 -0
  318. package/dist/transport/snapshot-encoder.js +139 -0
  319. package/dist/transport/snapshot-encoder.js.map +1 -0
  320. package/dist/transport/types.d.ts +6 -1
  321. package/dist/transport/types.d.ts.map +1 -1
  322. package/dist/types.d.ts +2 -0
  323. package/dist/types.d.ts.map +1 -1
  324. package/dist/types.js.map +1 -1
  325. package/docs/mcp/clients/claude-code.md +29 -0
  326. package/docs/mcp/clients/claude-desktop.md +47 -0
  327. package/docs/mcp/clients/codex.md +29 -0
  328. package/docs/mcp/clients/cursor.md +38 -0
  329. package/docs/mcp/clients/gemini-cli.md +38 -0
  330. package/docs/operate/compute.md +172 -0
  331. package/docs/operate/electron.md +87 -0
  332. package/docs/operate/focus-behavior.md +40 -0
  333. package/docs/operate/troubleshooting.md +379 -0
  334. package/package.json +29 -4
  335. package/src/adapters/juejin/hot.test.ts +25 -0
  336. package/src/adapters/juejin/hot.yaml +52 -0
  337. package/src/adapters/juejin/search.test.ts +27 -0
  338. package/src/adapters/juejin/search.yaml +58 -0
  339. package/src/adapters/leetcode/discuss-search.test.ts +29 -0
  340. package/src/adapters/leetcode/discuss-search.yaml +56 -0
  341. package/src/adapters/macos/actions.ts +63 -0
@@ -0,0 +1,2135 @@
1
+ use std::collections::BTreeMap;
2
+ use std::thread::sleep;
3
+ use std::time::{Duration, Instant};
4
+
5
+ use serde_json::Value;
6
+ use unicli_shared::SidecarRequest;
7
+
8
+ use crate::errors::{HandlerResult, UiaError};
9
+ use crate::refs::RefTable;
10
+
11
+ #[derive(Default)]
12
+ pub struct State {
13
+ refs: RefTable,
14
+ }
15
+
16
+ impl State {
17
+ pub fn new() -> Self {
18
+ Self::default()
19
+ }
20
+
21
+ pub fn refs_mut(&mut self) -> &mut RefTable {
22
+ &mut self.refs
23
+ }
24
+ }
25
+
26
+ #[derive(Debug, Clone, PartialEq, Eq)]
27
+ pub(crate) struct WindowRecord {
28
+ pub(crate) hwnd: String,
29
+ pub(crate) pid: u32,
30
+ pub(crate) title: String,
31
+ pub(crate) children: Vec<ElementRecord>,
32
+ }
33
+
34
+ #[derive(Debug, Clone, PartialEq, Eq)]
35
+ pub(crate) struct ElementRecord {
36
+ pub(crate) role: String,
37
+ pub(crate) name: String,
38
+ pub(crate) value: Option<String>,
39
+ pub(crate) bounds: Option<ElementBounds>,
40
+ pub(crate) states: Vec<String>,
41
+ pub(crate) children: Vec<ElementRecord>,
42
+ }
43
+
44
+ #[derive(Debug, Clone, PartialEq, Eq)]
45
+ pub(crate) struct ElementBounds {
46
+ pub(crate) x: i32,
47
+ pub(crate) y: i32,
48
+ pub(crate) width: u32,
49
+ pub(crate) height: u32,
50
+ }
51
+
52
+ #[cfg(any(target_os = "windows", test))]
53
+ #[derive(Debug, Clone, PartialEq, Eq)]
54
+ struct UiaElementProperties {
55
+ control_type_id: i32,
56
+ name: Option<String>,
57
+ value: Option<String>,
58
+ bounds: Option<ElementBounds>,
59
+ enabled: bool,
60
+ focusable: bool,
61
+ horizontally_scrollable: bool,
62
+ vertically_scrollable: bool,
63
+ }
64
+
65
+ #[cfg(any(target_os = "windows", test))]
66
+ fn element_record_from_uia_properties(props: UiaElementProperties) -> Option<ElementRecord> {
67
+ let role = uia_control_type_role(props.control_type_id)?.to_string();
68
+ let name = props.name.unwrap_or_default();
69
+ let value = props.value.filter(|value| !value.is_empty());
70
+ if name.is_empty() && value.is_none() && props.bounds.is_none() {
71
+ return None;
72
+ }
73
+ let mut states = Vec::new();
74
+ if props.enabled {
75
+ states.push("enabled".into());
76
+ }
77
+ if props.focusable {
78
+ states.push("focusable".into());
79
+ }
80
+ if props.horizontally_scrollable {
81
+ states.push("horizontally_scrollable".into());
82
+ }
83
+ if props.vertically_scrollable {
84
+ states.push("vertically_scrollable".into());
85
+ }
86
+ Some(ElementRecord {
87
+ role,
88
+ name,
89
+ value,
90
+ bounds: props.bounds,
91
+ states,
92
+ children: Vec::new(),
93
+ })
94
+ }
95
+
96
+ #[cfg(any(target_os = "windows", test))]
97
+ fn uia_control_type_role(id: i32) -> Option<&'static str> {
98
+ Some(match id {
99
+ 50000 => "Button",
100
+ 50001 => "Calendar",
101
+ 50002 => "CheckBox",
102
+ 50003 => "ComboBox",
103
+ 50004 => "Edit",
104
+ 50005 => "Hyperlink",
105
+ 50006 => "Image",
106
+ 50007 => "ListItem",
107
+ 50008 => "List",
108
+ 50009 => "Menu",
109
+ 50010 => "MenuBar",
110
+ 50011 => "MenuItem",
111
+ 50012 => "ProgressBar",
112
+ 50013 => "RadioButton",
113
+ 50014 => "ScrollBar",
114
+ 50015 => "Slider",
115
+ 50016 => "Spinner",
116
+ 50017 => "StatusBar",
117
+ 50018 => "Tab",
118
+ 50019 => "TabItem",
119
+ 50020 => "Text",
120
+ 50021 => "ToolBar",
121
+ 50022 => "ToolTip",
122
+ 50023 => "Tree",
123
+ 50024 => "TreeItem",
124
+ 50025 => "Custom",
125
+ 50026 => "Group",
126
+ 50027 => "Thumb",
127
+ 50028 => "DataGrid",
128
+ 50029 => "DataItem",
129
+ 50030 => "Document",
130
+ 50031 => "SplitButton",
131
+ 50032 => "Window",
132
+ 50033 => "Pane",
133
+ 50034 => "Header",
134
+ 50035 => "HeaderItem",
135
+ 50036 => "Table",
136
+ 50037 => "TitleBar",
137
+ 50038 => "Separator",
138
+ 50039 => "SemanticZoom",
139
+ 50040 => "AppBar",
140
+ _ => return None,
141
+ })
142
+ }
143
+
144
+ pub fn handle_apps(_state: &mut State, _request: &SidecarRequest) -> HandlerResult {
145
+ let windows = enumerate_top_level_windows()?;
146
+ Ok(apps_response_from_windows(&windows))
147
+ }
148
+
149
+ pub fn handle_windows(_state: &mut State, request: &SidecarRequest) -> HandlerResult {
150
+ let windows = enumerate_top_level_windows()?;
151
+ Ok(windows_response_from_windows(&windows, &request.params))
152
+ }
153
+
154
+ pub fn handle_snapshot(state: &mut State, request: &SidecarRequest) -> HandlerResult {
155
+ state.refs_mut().clear();
156
+ let windows = enumerate_top_level_windows()?;
157
+ Ok(snapshot_response_from_windows(&windows, &request.params))
158
+ }
159
+
160
+ pub fn handle_find(_state: &mut State, request: &SidecarRequest) -> HandlerResult {
161
+ let windows = enumerate_top_level_windows()?;
162
+ find_response_from_windows(&windows, &request.params)
163
+ }
164
+
165
+ pub fn handle_wait(_state: &mut State, request: &SidecarRequest) -> HandlerResult {
166
+ let timeout = read_timeout(&request.params);
167
+ let poll_interval = read_poll_interval(&request.params);
168
+ let started = Instant::now();
169
+
170
+ loop {
171
+ let windows = enumerate_top_level_windows()?;
172
+ if let Ok(response) = wait_response_from_windows(&windows, &request.params) {
173
+ return Ok(response);
174
+ }
175
+
176
+ if started.elapsed() >= timeout {
177
+ return Err(UiaError::no_element("top-level window wait"));
178
+ }
179
+
180
+ sleep(poll_interval.min(timeout.saturating_sub(started.elapsed())));
181
+ }
182
+ }
183
+
184
+ pub fn handle_observe(_state: &mut State, request: &SidecarRequest) -> HandlerResult {
185
+ let windows = enumerate_top_level_windows()?;
186
+ Ok(observe_response_from_windows(&windows, &request.params))
187
+ }
188
+
189
+ pub fn handle_assert(_state: &mut State, request: &SidecarRequest) -> HandlerResult {
190
+ let windows = enumerate_top_level_windows()?;
191
+ assert_response_from_windows(&windows, &request.params)
192
+ }
193
+
194
+ fn apps_response_from_windows(windows: &[WindowRecord]) -> Value {
195
+ let mut by_pid: BTreeMap<u32, (&str, usize)> = BTreeMap::new();
196
+ for window in windows {
197
+ by_pid
198
+ .entry(window.pid)
199
+ .and_modify(|(_, count)| *count += 1)
200
+ .or_insert((window.title.as_str(), 1));
201
+ }
202
+
203
+ let mut apps: Vec<Value> = by_pid
204
+ .into_iter()
205
+ .map(|(pid, (name, window_count))| {
206
+ serde_json::json!({
207
+ "name": name,
208
+ "pid": pid,
209
+ "windowCount": window_count,
210
+ })
211
+ })
212
+ .collect();
213
+ apps.sort_by(|left, right| {
214
+ let left_name = left
215
+ .get("name")
216
+ .and_then(Value::as_str)
217
+ .unwrap_or_default()
218
+ .to_ascii_lowercase();
219
+ let right_name = right
220
+ .get("name")
221
+ .and_then(Value::as_str)
222
+ .unwrap_or_default()
223
+ .to_ascii_lowercase();
224
+ left_name.cmp(&right_name)
225
+ });
226
+
227
+ serde_json::json!({
228
+ "mode": "apps",
229
+ "count": apps.len(),
230
+ "apps": apps,
231
+ })
232
+ }
233
+
234
+ fn windows_response_from_windows(windows: &[WindowRecord], params: &Value) -> Value {
235
+ let windows: Vec<Value> = windows
236
+ .iter()
237
+ .filter(|window| window_matches_params(window, params))
238
+ .map(|window| {
239
+ serde_json::json!({
240
+ "id": window.hwnd,
241
+ "hwnd": window.hwnd,
242
+ "name": window.title,
243
+ "title": window.title,
244
+ "pid": window.pid,
245
+ "visible": true,
246
+ })
247
+ })
248
+ .collect();
249
+
250
+ serde_json::json!({
251
+ "mode": "windows",
252
+ "count": windows.len(),
253
+ "windows": windows,
254
+ })
255
+ }
256
+
257
+ fn snapshot_response_from_windows(windows: &[WindowRecord], params: &Value) -> Value {
258
+ let children: Vec<Value> = windows
259
+ .iter()
260
+ .filter(|window| window_matches_params(window, params))
261
+ .map(|window| {
262
+ let index = pid_local_window_index(windows, window);
263
+ window_node(window, index, false)
264
+ })
265
+ .collect();
266
+
267
+ serde_json::json!({
268
+ "role": "Desktop",
269
+ "name": "Windows Desktop",
270
+ "path": "Desktop[0]",
271
+ "scope": "desktop",
272
+ "children": children,
273
+ })
274
+ }
275
+
276
+ fn find_response_from_windows(windows: &[WindowRecord], params: &Value) -> HandlerResult {
277
+ let mut matches = Vec::new();
278
+ for window in windows
279
+ .iter()
280
+ .filter(|window| window_matches_params(window, params))
281
+ {
282
+ let index = pid_local_window_index(windows, window);
283
+ if window_node_matches_find_params(window, params) {
284
+ matches.push(window_node(window, index, true));
285
+ }
286
+ collect_descendant_matches(&mut matches, window, index, params);
287
+ }
288
+
289
+ if params.get("first").and_then(Value::as_bool) == Some(true) {
290
+ return matches
291
+ .into_iter()
292
+ .next()
293
+ .ok_or_else(|| UiaError::no_element("top-level window query"));
294
+ }
295
+
296
+ Ok(serde_json::json!(matches))
297
+ }
298
+
299
+ fn wait_response_from_windows(windows: &[WindowRecord], params: &Value) -> HandlerResult {
300
+ let (via, node) = first_matching_node(windows, params)
301
+ .ok_or_else(|| UiaError::no_element("top-level window query"))?;
302
+
303
+ Ok(serde_json::json!({
304
+ "matched": true,
305
+ "via": via,
306
+ "node": node,
307
+ }))
308
+ }
309
+
310
+ fn observe_response_from_windows(windows: &[WindowRecord], params: &Value) -> Value {
311
+ let goal = params
312
+ .get("goal")
313
+ .and_then(Value::as_str)
314
+ .unwrap_or_default();
315
+ let top_k = read_top_k(params);
316
+ let mut candidates: Vec<Value> = Vec::new();
317
+ for window in windows
318
+ .iter()
319
+ .filter(|window| window_matches_params(window, params))
320
+ {
321
+ if let Some((confidence, reason)) = score_window_for_goal(window, goal) {
322
+ let index = pid_local_window_index(windows, window);
323
+ let stable = window_stable(window, index);
324
+ candidates.push(serde_json::json!({
325
+ "action": "click",
326
+ "ref": stable,
327
+ "stable": stable,
328
+ "role": "Window",
329
+ "name": window.title,
330
+ "confidence": confidence,
331
+ "reason": reason,
332
+ }));
333
+ }
334
+
335
+ let index = pid_local_window_index(windows, window);
336
+ collect_descendant_observe_candidates(&mut candidates, window, index, goal);
337
+ }
338
+
339
+ candidates.sort_by(|left, right| {
340
+ let left_confidence = left
341
+ .get("confidence")
342
+ .and_then(Value::as_f64)
343
+ .unwrap_or_default();
344
+ let right_confidence = right
345
+ .get("confidence")
346
+ .and_then(Value::as_f64)
347
+ .unwrap_or_default();
348
+ right_confidence
349
+ .total_cmp(&left_confidence)
350
+ .then_with(|| candidate_name(left).cmp(&candidate_name(right)))
351
+ });
352
+ candidates.truncate(top_k);
353
+
354
+ serde_json::json!({
355
+ "goal": goal,
356
+ "count": candidates.len(),
357
+ "candidates": candidates,
358
+ })
359
+ }
360
+
361
+ fn assert_response_from_windows(windows: &[WindowRecord], params: &Value) -> HandlerResult {
362
+ if let Some(stable) = stable_param(params) {
363
+ if !stable.starts_with("desktop-uia:") {
364
+ return Err(UiaError::invalid_input(
365
+ "uia_assert requires a desktop-uia stable ref when ref is provided",
366
+ ));
367
+ }
368
+ let (via, node) = assert_target_ref_node(windows, stable, params)
369
+ .ok_or_else(|| UiaError::no_element(stable.to_string()))?;
370
+
371
+ return Ok(serde_json::json!({
372
+ "asserted": true,
373
+ "via": via,
374
+ "checks": assertion_checks(params),
375
+ "node": node,
376
+ }));
377
+ }
378
+
379
+ let (via, node) = first_assertion_node(windows, params)
380
+ .ok_or_else(|| UiaError::no_element("top-level window assertion"))?;
381
+
382
+ Ok(serde_json::json!({
383
+ "asserted": true,
384
+ "via": via,
385
+ "checks": assertion_checks(params),
386
+ "node": node,
387
+ }))
388
+ }
389
+
390
+ fn stable_param(params: &Value) -> Option<&str> {
391
+ params
392
+ .get("stable")
393
+ .or_else(|| params.get("ref"))
394
+ .and_then(Value::as_str)
395
+ }
396
+
397
+ #[allow(dead_code)] // Used by ref-backed focus/invoke handlers after top-level refs land.
398
+ pub(crate) fn resolve_top_level_window_ref<'a>(
399
+ windows: &'a [WindowRecord],
400
+ stable: &str,
401
+ ) -> Option<&'a WindowRecord> {
402
+ let (scope, path) = stable.strip_prefix("desktop-uia:")?.split_once(':')?;
403
+ let pid = scope.strip_prefix("pid-")?.parse::<u32>().ok()?;
404
+ let index = path
405
+ .strip_prefix("Window[")?
406
+ .strip_suffix(']')?
407
+ .parse::<usize>()
408
+ .ok()?;
409
+
410
+ windows.iter().filter(|window| window.pid == pid).nth(index)
411
+ }
412
+
413
+ fn assert_target_ref_node(
414
+ windows: &[WindowRecord],
415
+ stable: &str,
416
+ params: &Value,
417
+ ) -> Option<(&'static str, Value)> {
418
+ if let Some(window) = resolve_top_level_window_ref(windows, stable) {
419
+ if window_satisfies_assertion(window, params) {
420
+ let index = pid_local_window_index(windows, window);
421
+ return Some((
422
+ "top_level_window_inventory",
423
+ window_node(window, index, true),
424
+ ));
425
+ }
426
+ return None;
427
+ }
428
+
429
+ let (window, element, path) = resolve_descendant_element_ref(windows, stable)?;
430
+ if element_matches_find_params(element, params) && element_state_filter_matches(element, params)
431
+ {
432
+ let scope = format!("pid-{}", window.pid);
433
+ return Some((
434
+ "native_descendant_tree",
435
+ element_node(
436
+ element,
437
+ &scope,
438
+ &window.title,
439
+ window.pid,
440
+ &path,
441
+ true,
442
+ false,
443
+ ),
444
+ ));
445
+ }
446
+ None
447
+ }
448
+
449
+ pub(crate) fn resolve_descendant_element_ref<'a>(
450
+ windows: &'a [WindowRecord],
451
+ stable: &str,
452
+ ) -> Option<(&'a WindowRecord, &'a ElementRecord, String)> {
453
+ let (scope, path) = stable.strip_prefix("desktop-uia:")?.split_once(':')?;
454
+ let pid = scope.strip_prefix("pid-")?.parse::<u32>().ok()?;
455
+ let mut segments = path.split('/');
456
+ let (window_role, window_index) = parse_indexed_path_segment(segments.next()?)?;
457
+ if window_role != "Window" {
458
+ return None;
459
+ }
460
+ let window = windows
461
+ .iter()
462
+ .filter(|window| window.pid == pid)
463
+ .nth(window_index)?;
464
+ let mut resolved_path = format!("Window[{window_index}]");
465
+ let mut children = window.children.as_slice();
466
+ let mut current = None;
467
+
468
+ for segment in segments {
469
+ let (role, index) = parse_indexed_path_segment(segment)?;
470
+ let element = children
471
+ .iter()
472
+ .filter(|element| element.role == role)
473
+ .nth(index)?;
474
+ resolved_path.push('/');
475
+ resolved_path.push_str(segment);
476
+ children = element.children.as_slice();
477
+ current = Some(element);
478
+ }
479
+
480
+ current.map(|element| (window, element, resolved_path))
481
+ }
482
+
483
+ fn parse_indexed_path_segment(segment: &str) -> Option<(&str, usize)> {
484
+ let (role, raw_index) = segment.split_once('[')?;
485
+ let index = raw_index.strip_suffix(']')?.parse::<usize>().ok()?;
486
+ Some((role, index))
487
+ }
488
+
489
+ #[cfg(target_os = "windows")]
490
+ pub(crate) fn resolve_live_descendant_element(
491
+ window: &WindowRecord,
492
+ stable: &str,
493
+ ) -> Result<windows::Win32::UI::Accessibility::IUIAutomationElement, UiaError> {
494
+ use std::ffi::c_void;
495
+ use windows::Win32::Foundation::HWND;
496
+ use windows::Win32::System::Com::{
497
+ CoCreateInstance, CoInitializeEx, CLSCTX_ALL, COINIT_MULTITHREADED,
498
+ };
499
+ use windows::Win32::UI::Accessibility::{CUIAutomation, IUIAutomation};
500
+
501
+ let (_, path) = stable
502
+ .strip_prefix("desktop-uia:")
503
+ .and_then(|tail| tail.split_once(':'))
504
+ .ok_or_else(|| UiaError::invalid_input(format!("invalid UIA stable ref {stable}")))?;
505
+ let mut segments = path.split('/');
506
+ let (window_role, _) = segments
507
+ .next()
508
+ .and_then(parse_indexed_path_segment)
509
+ .ok_or_else(|| UiaError::invalid_input(format!("invalid UIA stable ref {stable}")))?;
510
+ if window_role != "Window" {
511
+ return Err(UiaError::invalid_input(format!(
512
+ "invalid UIA stable ref {stable}"
513
+ )));
514
+ }
515
+
516
+ let hwnd = parse_hwnd(&window.hwnd)?;
517
+ unsafe {
518
+ let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
519
+ let automation: IUIAutomation = CoCreateInstance(&CUIAutomation, None, CLSCTX_ALL)
520
+ .map_err(|err| {
521
+ UiaError::unavailable(format!("failed to create UIAutomation: {err}"))
522
+ })?;
523
+ let walker = automation
524
+ .ControlViewWalker()
525
+ .map_err(|err| UiaError::unavailable(format!("ControlViewWalker failed: {err}")))?;
526
+ let mut current = automation
527
+ .ElementFromHandle(HWND(hwnd as *mut c_void))
528
+ .map_err(|err| UiaError::unavailable(format!("ElementFromHandle failed: {err}")))?;
529
+
530
+ for segment in segments {
531
+ let (role, index) = parse_indexed_path_segment(segment).ok_or_else(|| {
532
+ UiaError::invalid_input(format!("invalid UIA stable ref {stable}"))
533
+ })?;
534
+ current = child_element_by_role_index(&walker, &current, role, index)
535
+ .ok_or_else(|| UiaError::no_element(stable.to_string()))?;
536
+ }
537
+
538
+ Ok(current)
539
+ }
540
+ }
541
+
542
+ #[cfg(target_os = "windows")]
543
+ fn child_element_by_role_index(
544
+ walker: &windows::Win32::UI::Accessibility::IUIAutomationTreeWalker,
545
+ parent: &windows::Win32::UI::Accessibility::IUIAutomationElement,
546
+ role: &str,
547
+ target_index: usize,
548
+ ) -> Option<windows::Win32::UI::Accessibility::IUIAutomationElement> {
549
+ let mut child = unsafe { walker.GetFirstChildElement(parent) }.ok()?;
550
+ let mut role_index = 0usize;
551
+ loop {
552
+ if live_uia_element_role(&child) == Some(role) {
553
+ if role_index == target_index {
554
+ return Some(child);
555
+ }
556
+ role_index += 1;
557
+ }
558
+ child = match unsafe { walker.GetNextSiblingElement(&child) } {
559
+ Ok(next) => next,
560
+ Err(_) => break,
561
+ };
562
+ }
563
+ None
564
+ }
565
+
566
+ #[cfg(target_os = "windows")]
567
+ fn live_uia_element_role(
568
+ element: &windows::Win32::UI::Accessibility::IUIAutomationElement,
569
+ ) -> Option<&'static str> {
570
+ let control_type_id = unsafe { element.CurrentControlType().ok()? }.0;
571
+ uia_control_type_role(control_type_id)
572
+ }
573
+
574
+ #[cfg(target_os = "windows")]
575
+ fn parse_hwnd(value: &str) -> Result<isize, UiaError> {
576
+ let raw = value.strip_prefix("0x").unwrap_or(value);
577
+ isize::from_str_radix(raw, 16)
578
+ .map_err(|_| UiaError::invalid_input(format!("invalid window handle {value}")))
579
+ }
580
+
581
+ fn pid_local_window_index(windows: &[WindowRecord], target: &WindowRecord) -> usize {
582
+ windows
583
+ .iter()
584
+ .filter(|window| window.pid == target.pid)
585
+ .position(|window| window.hwnd == target.hwnd)
586
+ .unwrap_or(0)
587
+ }
588
+
589
+ fn window_node(window: &WindowRecord, index: usize, include_stable: bool) -> Value {
590
+ let path = format!("Window[{index}]");
591
+ let scope = format!("pid-{}", window.pid);
592
+ let mut node = serde_json::json!({
593
+ "role": "Window",
594
+ "name": window.title,
595
+ "path": path,
596
+ "scope": scope,
597
+ "app": window.title,
598
+ "pid": window.pid,
599
+ "states": ["visible"],
600
+ "metadata": {
601
+ "hwnd": window.hwnd,
602
+ },
603
+ });
604
+
605
+ if include_stable {
606
+ node["stable"] = serde_json::json!(window_stable(window, index));
607
+ }
608
+ if !window.children.is_empty() {
609
+ node["children"] = serde_json::json!(element_nodes(
610
+ &window.children,
611
+ &scope,
612
+ &window.title,
613
+ window.pid,
614
+ &path,
615
+ include_stable,
616
+ ));
617
+ }
618
+
619
+ node
620
+ }
621
+
622
+ fn window_stable(window: &WindowRecord, index: usize) -> String {
623
+ format!("desktop-uia:pid-{}:Window[{index}]", window.pid)
624
+ }
625
+
626
+ fn window_matches_params(window: &WindowRecord, params: &Value) -> bool {
627
+ let pid_filter = params
628
+ .get("pid")
629
+ .and_then(Value::as_u64)
630
+ .map(|pid| pid as u32);
631
+ let app_filter = params
632
+ .get("app")
633
+ .and_then(Value::as_str)
634
+ .map(|app| app.to_ascii_lowercase());
635
+
636
+ pid_filter.map_or(true, |pid| window.pid == pid)
637
+ && app_filter
638
+ .as_ref()
639
+ .map_or(true, |app| window.title.to_ascii_lowercase().contains(app))
640
+ }
641
+
642
+ fn window_matches_find_params(window: &WindowRecord, params: &Value) -> bool {
643
+ window_matches_params(window, params) && window_node_matches_find_params(window, params)
644
+ }
645
+
646
+ fn window_node_matches_find_params(window: &WindowRecord, params: &Value) -> bool {
647
+ let role_filter = params
648
+ .get("role")
649
+ .and_then(Value::as_str)
650
+ .map(|role| role.to_ascii_lowercase());
651
+ let name_filter = params
652
+ .get("name")
653
+ .or_else(|| params.get("title"))
654
+ .and_then(Value::as_str)
655
+ .map(|name| name.to_ascii_lowercase());
656
+
657
+ role_filter
658
+ .as_ref()
659
+ .map_or(true, |role| role == "window" || role == "desktop-window")
660
+ && name_filter.as_ref().map_or(true, |name| {
661
+ window.title.to_ascii_lowercase().contains(name)
662
+ })
663
+ && text_matches(&window.title, None, params)
664
+ }
665
+
666
+ fn window_satisfies_assertion(window: &WindowRecord, params: &Value) -> bool {
667
+ window_matches_find_params(window, params)
668
+ && text_filter_matches(window, params)
669
+ && window_state_filter_matches(params)
670
+ }
671
+
672
+ fn text_filter_matches(window: &WindowRecord, params: &Value) -> bool {
673
+ text_matches(&window.title, None, params)
674
+ }
675
+
676
+ fn window_state_filter_matches(params: &Value) -> bool {
677
+ params
678
+ .get("state")
679
+ .and_then(Value::as_str)
680
+ .map(|state| {
681
+ matches!(
682
+ state.to_ascii_lowercase().as_str(),
683
+ "visible" | "appear" | "enabled"
684
+ )
685
+ })
686
+ .unwrap_or(true)
687
+ }
688
+
689
+ fn assertion_checks(params: &Value) -> Value {
690
+ let mut checks = serde_json::Map::new();
691
+ if let Some(text) = params.get("text").and_then(Value::as_str) {
692
+ checks.insert("text".into(), serde_json::json!(text));
693
+ }
694
+ if let Some(state) = params.get("state").and_then(Value::as_str) {
695
+ checks.insert("state".into(), serde_json::json!(state));
696
+ }
697
+ Value::Object(checks)
698
+ }
699
+
700
+ fn read_timeout(params: &Value) -> Duration {
701
+ Duration::from_millis(
702
+ read_u64_param(params, &["timeoutMs", "timeout_ms", "timeout"], 10_000).clamp(1, 60_000),
703
+ )
704
+ }
705
+
706
+ fn read_poll_interval(params: &Value) -> Duration {
707
+ Duration::from_millis(
708
+ read_u64_param(
709
+ params,
710
+ &["pollMs", "poll_ms", "intervalMs", "interval_ms"],
711
+ 100,
712
+ )
713
+ .clamp(10, 1_000),
714
+ )
715
+ }
716
+
717
+ fn read_top_k(params: &Value) -> usize {
718
+ read_u64_param(params, &["topK", "top_k", "limit"], 5)
719
+ .clamp(1, 50)
720
+ .try_into()
721
+ .unwrap_or(5)
722
+ }
723
+
724
+ fn read_u64_param(params: &Value, names: &[&str], default: u64) -> u64 {
725
+ names
726
+ .iter()
727
+ .find_map(|name| params.get(*name))
728
+ .and_then(|value| {
729
+ value
730
+ .as_u64()
731
+ .or_else(|| value.as_str().and_then(|text| text.parse::<u64>().ok()))
732
+ })
733
+ .unwrap_or(default)
734
+ }
735
+
736
+ fn score_window_for_goal(window: &WindowRecord, goal: &str) -> Option<(f64, &'static str)> {
737
+ score_label_for_goal(&window.title, goal, "title", Some("Window"))
738
+ }
739
+
740
+ fn score_label_for_goal(
741
+ label: &str,
742
+ goal: &str,
743
+ label_kind: &'static str,
744
+ fallback_role: Option<&str>,
745
+ ) -> Option<(f64, &'static str)> {
746
+ let goal_tokens = tokenize(goal);
747
+ if goal_tokens.is_empty() {
748
+ return None;
749
+ }
750
+
751
+ let normalized_goal = goal_tokens.join(" ");
752
+ if label.to_ascii_lowercase().trim() == normalized_goal {
753
+ return Some((
754
+ 0.95,
755
+ if label_kind == "name" {
756
+ "exact name match"
757
+ } else {
758
+ "exact title match"
759
+ },
760
+ ));
761
+ }
762
+
763
+ let label_tokens = tokenize(label);
764
+ let matched = goal_tokens
765
+ .iter()
766
+ .filter(|query| token_matches_any(query, &label_tokens))
767
+ .count();
768
+ if matched == goal_tokens.len() {
769
+ return Some((
770
+ 0.85,
771
+ if label_kind == "name" {
772
+ "all goal tokens in name"
773
+ } else {
774
+ "all goal tokens in title"
775
+ },
776
+ ));
777
+ }
778
+ if matched > 0 {
779
+ let confidence = 0.4 + (matched as f64 / goal_tokens.len() as f64) * 0.4;
780
+ return Some((
781
+ round_confidence(confidence),
782
+ if label_kind == "name" {
783
+ "some goal tokens in name"
784
+ } else {
785
+ "some goal tokens in title"
786
+ },
787
+ ));
788
+ }
789
+ if let Some(role) = fallback_role {
790
+ if goal_tokens
791
+ .iter()
792
+ .any(|token| token == &role.to_ascii_lowercase())
793
+ {
794
+ return Some((0.1, "role match"));
795
+ }
796
+ }
797
+
798
+ None
799
+ }
800
+
801
+ fn token_matches_any(query: &str, tokens: &[String]) -> bool {
802
+ tokens.iter().any(|label| {
803
+ if query.len() < 3 {
804
+ label == query
805
+ } else {
806
+ label == query || label.contains(query) || (label.len() >= 3 && query.contains(label))
807
+ }
808
+ })
809
+ }
810
+
811
+ fn tokenize(text: &str) -> Vec<String> {
812
+ text.to_ascii_lowercase()
813
+ .chars()
814
+ .map(|character| {
815
+ if character.is_alphanumeric() {
816
+ character
817
+ } else {
818
+ ' '
819
+ }
820
+ })
821
+ .collect::<String>()
822
+ .split_whitespace()
823
+ .map(str::to_string)
824
+ .collect()
825
+ }
826
+
827
+ fn round_confidence(confidence: f64) -> f64 {
828
+ (confidence * 1000.0).round() / 1000.0
829
+ }
830
+
831
+ fn candidate_name(candidate: &Value) -> String {
832
+ candidate
833
+ .get("name")
834
+ .and_then(Value::as_str)
835
+ .unwrap_or_default()
836
+ .to_ascii_lowercase()
837
+ }
838
+
839
+ fn element_nodes(
840
+ elements: &[ElementRecord],
841
+ scope: &str,
842
+ app: &str,
843
+ pid: u32,
844
+ parent_path: &str,
845
+ include_stable: bool,
846
+ ) -> Vec<Value> {
847
+ let mut role_counts: BTreeMap<&str, usize> = BTreeMap::new();
848
+ elements
849
+ .iter()
850
+ .map(|element| {
851
+ let index = role_counts.entry(element.role.as_str()).or_insert(0);
852
+ let path = format!("{parent_path}/{}[{index}]", element.role);
853
+ *index += 1;
854
+ element_node(element, scope, app, pid, &path, include_stable, true)
855
+ })
856
+ .collect()
857
+ }
858
+
859
+ fn element_node(
860
+ element: &ElementRecord,
861
+ scope: &str,
862
+ app: &str,
863
+ pid: u32,
864
+ path: &str,
865
+ include_stable: bool,
866
+ include_children: bool,
867
+ ) -> Value {
868
+ let mut node = serde_json::json!({
869
+ "role": element.role,
870
+ "name": element.name,
871
+ "path": path,
872
+ "scope": scope,
873
+ "app": app,
874
+ "pid": pid,
875
+ "states": element.states,
876
+ });
877
+
878
+ if let Some(value) = &element.value {
879
+ node["value"] = serde_json::json!(value);
880
+ }
881
+ if let Some(bounds) = &element.bounds {
882
+ node["bounds"] = serde_json::json!({
883
+ "x": bounds.x,
884
+ "y": bounds.y,
885
+ "width": bounds.width,
886
+ "height": bounds.height,
887
+ });
888
+ }
889
+ if include_stable {
890
+ node["stable"] = serde_json::json!(format!("desktop-uia:{scope}:{path}"));
891
+ }
892
+ if include_children && !element.children.is_empty() {
893
+ node["children"] = serde_json::json!(element_nodes(
894
+ &element.children,
895
+ scope,
896
+ app,
897
+ pid,
898
+ path,
899
+ include_stable,
900
+ ));
901
+ }
902
+
903
+ node
904
+ }
905
+
906
+ fn collect_descendant_matches(
907
+ matches: &mut Vec<Value>,
908
+ window: &WindowRecord,
909
+ window_index: usize,
910
+ params: &Value,
911
+ ) {
912
+ let scope = format!("pid-{}", window.pid);
913
+ let path = format!("Window[{window_index}]");
914
+ collect_element_matches(
915
+ matches,
916
+ &window.children,
917
+ &scope,
918
+ &window.title,
919
+ window.pid,
920
+ &path,
921
+ params,
922
+ );
923
+ }
924
+
925
+ fn collect_descendant_observe_candidates(
926
+ candidates: &mut Vec<Value>,
927
+ window: &WindowRecord,
928
+ window_index: usize,
929
+ goal: &str,
930
+ ) {
931
+ let scope = format!("pid-{}", window.pid);
932
+ let path = format!("Window[{window_index}]");
933
+ collect_element_observe_candidates(candidates, &window.children, &scope, &path, goal);
934
+ }
935
+
936
+ fn collect_element_observe_candidates(
937
+ candidates: &mut Vec<Value>,
938
+ elements: &[ElementRecord],
939
+ scope: &str,
940
+ parent_path: &str,
941
+ goal: &str,
942
+ ) {
943
+ let mut role_counts: BTreeMap<&str, usize> = BTreeMap::new();
944
+ for element in elements {
945
+ let index = role_counts.entry(element.role.as_str()).or_insert(0);
946
+ let path = format!("{parent_path}/{}[{index}]", element.role);
947
+ *index += 1;
948
+ if let Some((confidence, reason)) =
949
+ score_label_for_goal(&element.name, goal, "name", Some(&element.role))
950
+ {
951
+ let stable = format!("desktop-uia:{scope}:{path}");
952
+ let mut candidate = serde_json::json!({
953
+ "action": action_for_element(element),
954
+ "ref": stable,
955
+ "stable": stable,
956
+ "role": element.role,
957
+ "name": element.name,
958
+ "states": element.states,
959
+ "confidence": confidence,
960
+ "reason": reason,
961
+ });
962
+ if let Some(value) = &element.value {
963
+ candidate["value"] = serde_json::json!(value);
964
+ }
965
+ if let Some(bounds) = &element.bounds {
966
+ candidate["bounds"] = serde_json::json!({
967
+ "x": bounds.x,
968
+ "y": bounds.y,
969
+ "width": bounds.width,
970
+ "height": bounds.height,
971
+ });
972
+ }
973
+ candidates.push(candidate);
974
+ }
975
+ collect_element_observe_candidates(candidates, &element.children, scope, &path, goal);
976
+ }
977
+ }
978
+
979
+ fn action_for_element(element: &ElementRecord) -> &'static str {
980
+ if element_is_scrollable(element) {
981
+ "scroll"
982
+ } else if element_is_settable(element) {
983
+ "set_value"
984
+ } else {
985
+ "click"
986
+ }
987
+ }
988
+
989
+ fn element_is_scrollable(element: &ElementRecord) -> bool {
990
+ let role = element.role.to_ascii_lowercase();
991
+ role.contains("scroll")
992
+ || element.states.iter().any(|state| {
993
+ matches!(
994
+ state.as_str(),
995
+ "scrollable" | "horizontally_scrollable" | "vertically_scrollable"
996
+ )
997
+ })
998
+ }
999
+
1000
+ fn element_is_settable(element: &ElementRecord) -> bool {
1001
+ let role = element.role.to_ascii_lowercase();
1002
+ role.contains("edit")
1003
+ || role.contains("text")
1004
+ || role.contains("slider")
1005
+ || role.contains("spinner")
1006
+ || role.contains("range")
1007
+ }
1008
+
1009
+ fn first_matching_node(windows: &[WindowRecord], params: &Value) -> Option<(&'static str, Value)> {
1010
+ for window in windows
1011
+ .iter()
1012
+ .filter(|window| window_matches_params(window, params))
1013
+ {
1014
+ let index = pid_local_window_index(windows, window);
1015
+ if window_node_matches_find_params(window, params) {
1016
+ return Some((
1017
+ "top_level_window_inventory",
1018
+ window_node(window, index, true),
1019
+ ));
1020
+ }
1021
+
1022
+ let mut descendants = Vec::new();
1023
+ collect_descendant_matches(&mut descendants, window, index, params);
1024
+ if let Some(node) = descendants.into_iter().next() {
1025
+ return Some(("native_descendant_tree", node));
1026
+ }
1027
+ }
1028
+
1029
+ None
1030
+ }
1031
+
1032
+ fn first_assertion_node(windows: &[WindowRecord], params: &Value) -> Option<(&'static str, Value)> {
1033
+ for window in windows
1034
+ .iter()
1035
+ .filter(|window| window_matches_params(window, params))
1036
+ {
1037
+ let index = pid_local_window_index(windows, window);
1038
+ if window_node_matches_find_params(window, params) && window_state_filter_matches(params) {
1039
+ return Some((
1040
+ "top_level_window_inventory",
1041
+ window_node(window, index, true),
1042
+ ));
1043
+ }
1044
+
1045
+ let mut descendants = Vec::new();
1046
+ collect_descendant_assertion_matches(&mut descendants, window, index, params);
1047
+ if let Some(node) = descendants.into_iter().next() {
1048
+ return Some(("native_descendant_tree", node));
1049
+ }
1050
+ }
1051
+
1052
+ None
1053
+ }
1054
+
1055
+ fn collect_element_matches(
1056
+ matches: &mut Vec<Value>,
1057
+ elements: &[ElementRecord],
1058
+ scope: &str,
1059
+ app: &str,
1060
+ pid: u32,
1061
+ parent_path: &str,
1062
+ params: &Value,
1063
+ ) {
1064
+ let mut role_counts: BTreeMap<&str, usize> = BTreeMap::new();
1065
+ for element in elements {
1066
+ let index = role_counts.entry(element.role.as_str()).or_insert(0);
1067
+ let path = format!("{parent_path}/{}[{index}]", element.role);
1068
+ *index += 1;
1069
+ if element_matches_find_params(element, params) {
1070
+ matches.push(element_node(element, scope, app, pid, &path, true, false));
1071
+ }
1072
+ collect_element_matches(matches, &element.children, scope, app, pid, &path, params);
1073
+ }
1074
+ }
1075
+
1076
+ fn collect_descendant_assertion_matches(
1077
+ matches: &mut Vec<Value>,
1078
+ window: &WindowRecord,
1079
+ window_index: usize,
1080
+ params: &Value,
1081
+ ) {
1082
+ let scope = format!("pid-{}", window.pid);
1083
+ let path = format!("Window[{window_index}]");
1084
+ collect_element_assertion_matches(
1085
+ matches,
1086
+ &window.children,
1087
+ &scope,
1088
+ &window.title,
1089
+ window.pid,
1090
+ &path,
1091
+ params,
1092
+ );
1093
+ }
1094
+
1095
+ fn collect_element_assertion_matches(
1096
+ matches: &mut Vec<Value>,
1097
+ elements: &[ElementRecord],
1098
+ scope: &str,
1099
+ app: &str,
1100
+ pid: u32,
1101
+ parent_path: &str,
1102
+ params: &Value,
1103
+ ) {
1104
+ let mut role_counts: BTreeMap<&str, usize> = BTreeMap::new();
1105
+ for element in elements {
1106
+ let index = role_counts.entry(element.role.as_str()).or_insert(0);
1107
+ let path = format!("{parent_path}/{}[{index}]", element.role);
1108
+ *index += 1;
1109
+ if element_matches_find_params(element, params)
1110
+ && element_state_filter_matches(element, params)
1111
+ {
1112
+ matches.push(element_node(element, scope, app, pid, &path, true, false));
1113
+ }
1114
+ collect_element_assertion_matches(
1115
+ matches,
1116
+ &element.children,
1117
+ scope,
1118
+ app,
1119
+ pid,
1120
+ &path,
1121
+ params,
1122
+ );
1123
+ }
1124
+ }
1125
+
1126
+ fn element_matches_find_params(element: &ElementRecord, params: &Value) -> bool {
1127
+ let role_filter = params
1128
+ .get("role")
1129
+ .and_then(Value::as_str)
1130
+ .map(|role| role.to_ascii_lowercase());
1131
+ let name_filter = params
1132
+ .get("name")
1133
+ .or_else(|| params.get("title"))
1134
+ .and_then(Value::as_str)
1135
+ .map(|name| name.to_ascii_lowercase());
1136
+
1137
+ role_filter
1138
+ .as_ref()
1139
+ .map_or(true, |role| element.role.to_ascii_lowercase() == *role)
1140
+ && name_filter.as_ref().map_or(true, |name| {
1141
+ element.name.to_ascii_lowercase().contains(name)
1142
+ })
1143
+ && text_matches(&element.name, element.value.as_deref(), params)
1144
+ }
1145
+
1146
+ fn text_matches(name: &str, value: Option<&str>, params: &Value) -> bool {
1147
+ let Some(text) = params.get("text").and_then(Value::as_str) else {
1148
+ return true;
1149
+ };
1150
+ let needle = text.to_ascii_lowercase();
1151
+ value
1152
+ .map(|value| value.to_ascii_lowercase().contains(&needle))
1153
+ .unwrap_or(false)
1154
+ || name.to_ascii_lowercase().contains(&needle)
1155
+ }
1156
+
1157
+ fn element_state_filter_matches(element: &ElementRecord, params: &Value) -> bool {
1158
+ let Some(state) = params.get("state").and_then(Value::as_str) else {
1159
+ return true;
1160
+ };
1161
+ let state = state.to_ascii_lowercase();
1162
+ if state == "appear" {
1163
+ return true;
1164
+ }
1165
+ element
1166
+ .states
1167
+ .iter()
1168
+ .any(|candidate| candidate.to_ascii_lowercase() == state)
1169
+ }
1170
+
1171
+ #[cfg(target_os = "windows")]
1172
+ pub(crate) fn enumerate_top_level_windows() -> Result<Vec<WindowRecord>, UiaError> {
1173
+ let mut windows = Vec::new();
1174
+ let ok = unsafe { win32::enum_windows(Some(enum_window), &mut windows as *mut _ as isize) };
1175
+ if ok != 0 {
1176
+ return Ok(windows);
1177
+ }
1178
+
1179
+ Err(UiaError::unavailable(format!(
1180
+ "EnumWindows failed: {}",
1181
+ std::io::Error::last_os_error()
1182
+ )))
1183
+ }
1184
+
1185
+ #[cfg(not(target_os = "windows"))]
1186
+ pub(crate) fn enumerate_top_level_windows() -> Result<Vec<WindowRecord>, UiaError> {
1187
+ Err(crate::errors::backend_unavailable())
1188
+ }
1189
+
1190
+ #[cfg(target_os = "windows")]
1191
+ unsafe extern "system" fn enum_window(hwnd: isize, lparam: isize) -> i32 {
1192
+ let windows = &mut *(lparam as *mut Vec<WindowRecord>);
1193
+ if let Some(window) = window_record_for_hwnd(hwnd) {
1194
+ windows.push(window);
1195
+ }
1196
+ 1
1197
+ }
1198
+
1199
+ #[cfg(target_os = "windows")]
1200
+ fn window_record_for_hwnd(hwnd: isize) -> Option<WindowRecord> {
1201
+ if unsafe { win32::is_window_visible(hwnd) } == 0 {
1202
+ return None;
1203
+ }
1204
+
1205
+ let title_len = unsafe { win32::get_window_text_length_w(hwnd) };
1206
+ if title_len <= 0 {
1207
+ return None;
1208
+ }
1209
+
1210
+ let mut buffer = vec![0u16; title_len as usize + 1];
1211
+ let copied =
1212
+ unsafe { win32::get_window_text_w(hwnd, buffer.as_mut_ptr(), buffer.len() as i32) };
1213
+ if copied <= 0 {
1214
+ return None;
1215
+ }
1216
+
1217
+ buffer.truncate(copied as usize);
1218
+ let title = String::from_utf16_lossy(&buffer).trim().to_string();
1219
+ if title.is_empty() {
1220
+ return None;
1221
+ }
1222
+
1223
+ let mut pid = 0u32;
1224
+ unsafe {
1225
+ win32::get_window_thread_process_id(hwnd, &mut pid);
1226
+ }
1227
+ if pid == 0 {
1228
+ return None;
1229
+ }
1230
+
1231
+ let children = descendant_records_for_hwnd(hwnd).unwrap_or_default();
1232
+
1233
+ Some(WindowRecord {
1234
+ hwnd: format!("0x{:x}", hwnd as usize),
1235
+ pid,
1236
+ title,
1237
+ children,
1238
+ })
1239
+ }
1240
+
1241
+ #[cfg(target_os = "windows")]
1242
+ fn descendant_records_for_hwnd(hwnd: isize) -> Result<Vec<ElementRecord>, UiaError> {
1243
+ use std::ffi::c_void;
1244
+ use windows::Win32::Foundation::HWND;
1245
+ use windows::Win32::System::Com::{
1246
+ CoCreateInstance, CoInitializeEx, CLSCTX_ALL, COINIT_MULTITHREADED,
1247
+ };
1248
+ use windows::Win32::UI::Accessibility::{CUIAutomation, IUIAutomation};
1249
+
1250
+ unsafe {
1251
+ let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
1252
+ let automation: IUIAutomation = CoCreateInstance(&CUIAutomation, None, CLSCTX_ALL)
1253
+ .map_err(|err| {
1254
+ UiaError::unavailable(format!("failed to create UIAutomation: {err}"))
1255
+ })?;
1256
+ let root = automation
1257
+ .ElementFromHandle(HWND(hwnd as *mut c_void))
1258
+ .map_err(|err| UiaError::unavailable(format!("ElementFromHandle failed: {err}")))?;
1259
+ let walker = automation
1260
+ .ControlViewWalker()
1261
+ .map_err(|err| UiaError::unavailable(format!("ControlViewWalker failed: {err}")))?;
1262
+ let mut count = 0usize;
1263
+ Ok(collect_child_records(&walker, &root, 0, &mut count))
1264
+ }
1265
+ }
1266
+
1267
+ #[cfg(target_os = "windows")]
1268
+ fn collect_child_records(
1269
+ walker: &windows::Win32::UI::Accessibility::IUIAutomationTreeWalker,
1270
+ element: &windows::Win32::UI::Accessibility::IUIAutomationElement,
1271
+ depth: usize,
1272
+ count: &mut usize,
1273
+ ) -> Vec<ElementRecord> {
1274
+ const MAX_DEPTH: usize = 6;
1275
+ const MAX_ELEMENTS: usize = 512;
1276
+ if depth >= MAX_DEPTH || *count >= MAX_ELEMENTS {
1277
+ return Vec::new();
1278
+ }
1279
+
1280
+ let mut records = Vec::new();
1281
+ let Ok(mut child) = (unsafe { walker.GetFirstChildElement(element) }) else {
1282
+ return records;
1283
+ };
1284
+
1285
+ loop {
1286
+ if *count >= MAX_ELEMENTS {
1287
+ break;
1288
+ }
1289
+ if let Some(mut record) = element_record_from_windows_uia(&child) {
1290
+ *count += 1;
1291
+ record.children = collect_child_records(walker, &child, depth + 1, count);
1292
+ records.push(record);
1293
+ } else {
1294
+ let grandchildren = collect_child_records(walker, &child, depth + 1, count);
1295
+ records.extend(grandchildren);
1296
+ }
1297
+ child = match unsafe { walker.GetNextSiblingElement(&child) } {
1298
+ Ok(next) => next,
1299
+ Err(_) => break,
1300
+ };
1301
+ }
1302
+ records
1303
+ }
1304
+
1305
+ #[cfg(target_os = "windows")]
1306
+ fn element_record_from_windows_uia(
1307
+ element: &windows::Win32::UI::Accessibility::IUIAutomationElement,
1308
+ ) -> Option<ElementRecord> {
1309
+ use windows::Win32::UI::Accessibility::UIA_ValueValuePropertyId;
1310
+
1311
+ let control_type_id = unsafe { element.CurrentControlType().ok()? }.0;
1312
+ let name = unsafe { element.CurrentName() }
1313
+ .ok()
1314
+ .map(|value| value.to_string())
1315
+ .filter(|value| !value.is_empty());
1316
+ let value = unsafe { element.GetCurrentPropertyValue(UIA_ValueValuePropertyId) }
1317
+ .ok()
1318
+ .map(|value| value.to_string())
1319
+ .filter(|value| !value.is_empty());
1320
+ let enabled = unsafe { element.CurrentIsEnabled() }
1321
+ .ok()
1322
+ .map(|value| value.as_bool())
1323
+ .unwrap_or(false);
1324
+ let focusable = unsafe { element.CurrentIsKeyboardFocusable() }
1325
+ .ok()
1326
+ .map(|value| value.as_bool())
1327
+ .unwrap_or(false);
1328
+ let (horizontally_scrollable, vertically_scrollable) = scrollability_from_windows_uia(element);
1329
+ let bounds = unsafe { element.CurrentBoundingRectangle() }
1330
+ .ok()
1331
+ .and_then(|rect| {
1332
+ let width = rect.right - rect.left;
1333
+ let height = rect.bottom - rect.top;
1334
+ if width <= 0 || height <= 0 {
1335
+ return None;
1336
+ }
1337
+ Some(ElementBounds {
1338
+ x: rect.left,
1339
+ y: rect.top,
1340
+ width: width as u32,
1341
+ height: height as u32,
1342
+ })
1343
+ });
1344
+
1345
+ element_record_from_uia_properties(UiaElementProperties {
1346
+ control_type_id,
1347
+ name,
1348
+ value,
1349
+ bounds,
1350
+ enabled,
1351
+ focusable,
1352
+ horizontally_scrollable,
1353
+ vertically_scrollable,
1354
+ })
1355
+ }
1356
+
1357
+ #[cfg(target_os = "windows")]
1358
+ fn scrollability_from_windows_uia(
1359
+ element: &windows::Win32::UI::Accessibility::IUIAutomationElement,
1360
+ ) -> (bool, bool) {
1361
+ use windows::Win32::UI::Accessibility::{IUIAutomationScrollPattern, UIA_ScrollPatternId};
1362
+
1363
+ let Ok(pattern) =
1364
+ (unsafe { element.GetCurrentPatternAs::<IUIAutomationScrollPattern>(UIA_ScrollPatternId) })
1365
+ else {
1366
+ return (false, false);
1367
+ };
1368
+ let horizontal = unsafe { pattern.CurrentHorizontallyScrollable() }
1369
+ .map(|value| value.as_bool())
1370
+ .unwrap_or(false);
1371
+ let vertical = unsafe { pattern.CurrentVerticallyScrollable() }
1372
+ .map(|value| value.as_bool())
1373
+ .unwrap_or(false);
1374
+ (horizontal, vertical)
1375
+ }
1376
+
1377
+ #[cfg(target_os = "windows")]
1378
+ mod win32 {
1379
+ pub type EnumWindowsProc = unsafe extern "system" fn(isize, isize) -> i32;
1380
+
1381
+ #[link(name = "user32")]
1382
+ extern "system" {
1383
+ #[link_name = "EnumWindows"]
1384
+ pub fn enum_windows(callback: Option<EnumWindowsProc>, lparam: isize) -> i32;
1385
+ #[link_name = "IsWindowVisible"]
1386
+ pub fn is_window_visible(hwnd: isize) -> i32;
1387
+ #[link_name = "GetWindowTextLengthW"]
1388
+ pub fn get_window_text_length_w(hwnd: isize) -> i32;
1389
+ #[link_name = "GetWindowTextW"]
1390
+ pub fn get_window_text_w(hwnd: isize, text: *mut u16, max_count: i32) -> i32;
1391
+ #[link_name = "GetWindowThreadProcessId"]
1392
+ pub fn get_window_thread_process_id(hwnd: isize, process_id: *mut u32) -> u32;
1393
+ }
1394
+ }
1395
+
1396
+ #[cfg(test)]
1397
+ mod tests {
1398
+ use super::*;
1399
+
1400
+ #[test]
1401
+ fn apps_response_groups_windows_by_pid_and_sorts_by_name() {
1402
+ let response = apps_response_from_windows(&[
1403
+ WindowRecord {
1404
+ hwnd: "0x2".into(),
1405
+ pid: 42,
1406
+ title: "Beta".into(),
1407
+ children: vec![],
1408
+ },
1409
+ WindowRecord {
1410
+ hwnd: "0x3".into(),
1411
+ pid: 7,
1412
+ title: "Alpha".into(),
1413
+ children: vec![],
1414
+ },
1415
+ WindowRecord {
1416
+ hwnd: "0x4".into(),
1417
+ pid: 42,
1418
+ title: "Beta Preferences".into(),
1419
+ children: vec![],
1420
+ },
1421
+ ]);
1422
+
1423
+ assert_eq!(
1424
+ response,
1425
+ serde_json::json!({
1426
+ "mode": "apps",
1427
+ "count": 2,
1428
+ "apps": [
1429
+ {
1430
+ "name": "Alpha",
1431
+ "pid": 7,
1432
+ "windowCount": 1,
1433
+ },
1434
+ {
1435
+ "name": "Beta",
1436
+ "pid": 42,
1437
+ "windowCount": 2,
1438
+ },
1439
+ ],
1440
+ }),
1441
+ );
1442
+ }
1443
+
1444
+ #[test]
1445
+ fn snapshot_response_emits_raw_ax_root_with_window_children() {
1446
+ let response = snapshot_response_from_windows(
1447
+ &[
1448
+ WindowRecord {
1449
+ hwnd: "0x3".into(),
1450
+ pid: 7,
1451
+ title: "Alpha".into(),
1452
+ children: vec![],
1453
+ },
1454
+ WindowRecord {
1455
+ hwnd: "0x2".into(),
1456
+ pid: 42,
1457
+ title: "Beta".into(),
1458
+ children: vec![],
1459
+ },
1460
+ ],
1461
+ &serde_json::json!({ "app": "bet" }),
1462
+ );
1463
+
1464
+ assert_eq!(
1465
+ response,
1466
+ serde_json::json!({
1467
+ "role": "Desktop",
1468
+ "name": "Windows Desktop",
1469
+ "path": "Desktop[0]",
1470
+ "scope": "desktop",
1471
+ "children": [
1472
+ {
1473
+ "role": "Window",
1474
+ "name": "Beta",
1475
+ "path": "Window[0]",
1476
+ "scope": "pid-42",
1477
+ "app": "Beta",
1478
+ "pid": 42,
1479
+ "states": ["visible"],
1480
+ "metadata": {
1481
+ "hwnd": "0x2",
1482
+ },
1483
+ },
1484
+ ],
1485
+ }),
1486
+ );
1487
+ }
1488
+
1489
+ #[test]
1490
+ fn snapshot_response_emits_descendant_bounds() {
1491
+ let response = snapshot_response_from_windows(
1492
+ &[WindowRecord {
1493
+ hwnd: "0x2".into(),
1494
+ pid: 42,
1495
+ title: "Calculator".into(),
1496
+ children: vec![ElementRecord {
1497
+ role: "Button".into(),
1498
+ name: "Eight".into(),
1499
+ value: None,
1500
+ bounds: Some(ElementBounds {
1501
+ x: 120,
1502
+ y: 220,
1503
+ width: 44,
1504
+ height: 36,
1505
+ }),
1506
+ states: vec!["enabled".into()],
1507
+ children: vec![],
1508
+ }],
1509
+ }],
1510
+ &serde_json::json!({}),
1511
+ );
1512
+
1513
+ assert_eq!(
1514
+ response["children"][0]["children"][0]["bounds"],
1515
+ serde_json::json!({
1516
+ "x": 120,
1517
+ "y": 220,
1518
+ "width": 44,
1519
+ "height": 36,
1520
+ }),
1521
+ );
1522
+ }
1523
+
1524
+ #[test]
1525
+ fn find_response_returns_first_matching_top_level_window() {
1526
+ let response = find_response_from_windows(
1527
+ &[
1528
+ WindowRecord {
1529
+ hwnd: "0x3".into(),
1530
+ pid: 7,
1531
+ title: "Alpha".into(),
1532
+ children: vec![],
1533
+ },
1534
+ WindowRecord {
1535
+ hwnd: "0x2".into(),
1536
+ pid: 42,
1537
+ title: "Beta".into(),
1538
+ children: vec![],
1539
+ },
1540
+ ],
1541
+ &serde_json::json!({
1542
+ "role": "window",
1543
+ "name": "bet",
1544
+ "first": true,
1545
+ }),
1546
+ )
1547
+ .expect("find response");
1548
+
1549
+ assert_eq!(
1550
+ response,
1551
+ serde_json::json!({
1552
+ "role": "Window",
1553
+ "name": "Beta",
1554
+ "path": "Window[0]",
1555
+ "scope": "pid-42",
1556
+ "stable": "desktop-uia:pid-42:Window[0]",
1557
+ "app": "Beta",
1558
+ "pid": 42,
1559
+ "states": ["visible"],
1560
+ "metadata": {
1561
+ "hwnd": "0x2",
1562
+ },
1563
+ }),
1564
+ );
1565
+ }
1566
+
1567
+ #[test]
1568
+ fn find_response_returns_descendant_by_role_name_and_value() {
1569
+ let response = find_response_from_windows(
1570
+ &[WindowRecord {
1571
+ hwnd: "0x2".into(),
1572
+ pid: 42,
1573
+ title: "Calculator".into(),
1574
+ children: vec![
1575
+ ElementRecord {
1576
+ role: "Button".into(),
1577
+ name: "Eight".into(),
1578
+ value: None,
1579
+ bounds: None,
1580
+ states: vec!["enabled".into()],
1581
+ children: vec![],
1582
+ },
1583
+ ElementRecord {
1584
+ role: "Edit".into(),
1585
+ name: "Display".into(),
1586
+ value: Some("8".into()),
1587
+ bounds: None,
1588
+ states: vec!["focusable".into(), "enabled".into()],
1589
+ children: vec![],
1590
+ },
1591
+ ],
1592
+ }],
1593
+ &serde_json::json!({
1594
+ "role": "edit",
1595
+ "text": "8",
1596
+ "first": true,
1597
+ }),
1598
+ )
1599
+ .expect("matching descendant");
1600
+
1601
+ assert_eq!(
1602
+ response,
1603
+ serde_json::json!({
1604
+ "role": "Edit",
1605
+ "name": "Display",
1606
+ "value": "8",
1607
+ "path": "Window[0]/Edit[0]",
1608
+ "scope": "pid-42",
1609
+ "stable": "desktop-uia:pid-42:Window[0]/Edit[0]",
1610
+ "app": "Calculator",
1611
+ "pid": 42,
1612
+ "states": ["focusable", "enabled"],
1613
+ }),
1614
+ );
1615
+ }
1616
+
1617
+ #[test]
1618
+ fn wait_response_returns_first_matching_top_level_window() {
1619
+ let response = wait_response_from_windows(
1620
+ &[
1621
+ WindowRecord {
1622
+ hwnd: "0x3".into(),
1623
+ pid: 7,
1624
+ title: "Alpha".into(),
1625
+ children: vec![],
1626
+ },
1627
+ WindowRecord {
1628
+ hwnd: "0x2".into(),
1629
+ pid: 42,
1630
+ title: "Beta Preferences".into(),
1631
+ children: vec![],
1632
+ },
1633
+ ],
1634
+ &serde_json::json!({
1635
+ "role": "window",
1636
+ "name": "preferences",
1637
+ "app": "beta",
1638
+ }),
1639
+ )
1640
+ .expect("matching window");
1641
+
1642
+ assert_eq!(
1643
+ response,
1644
+ serde_json::json!({
1645
+ "matched": true,
1646
+ "via": "top_level_window_inventory",
1647
+ "node": {
1648
+ "role": "Window",
1649
+ "name": "Beta Preferences",
1650
+ "path": "Window[0]",
1651
+ "scope": "pid-42",
1652
+ "stable": "desktop-uia:pid-42:Window[0]",
1653
+ "app": "Beta Preferences",
1654
+ "pid": 42,
1655
+ "states": ["visible"],
1656
+ "metadata": {
1657
+ "hwnd": "0x2",
1658
+ },
1659
+ },
1660
+ }),
1661
+ );
1662
+ }
1663
+
1664
+ #[test]
1665
+ fn wait_response_returns_matching_descendant_by_value() {
1666
+ let response = wait_response_from_windows(
1667
+ &[WindowRecord {
1668
+ hwnd: "0x2".into(),
1669
+ pid: 42,
1670
+ title: "Calculator".into(),
1671
+ children: vec![ElementRecord {
1672
+ role: "Edit".into(),
1673
+ name: "Display".into(),
1674
+ value: Some("8".into()),
1675
+ bounds: None,
1676
+ states: vec!["focusable".into(), "enabled".into()],
1677
+ children: vec![],
1678
+ }],
1679
+ }],
1680
+ &serde_json::json!({
1681
+ "role": "edit",
1682
+ "text": "8",
1683
+ }),
1684
+ )
1685
+ .expect("matching descendant");
1686
+
1687
+ assert_eq!(
1688
+ response,
1689
+ serde_json::json!({
1690
+ "matched": true,
1691
+ "via": "native_descendant_tree",
1692
+ "node": {
1693
+ "role": "Edit",
1694
+ "name": "Display",
1695
+ "value": "8",
1696
+ "path": "Window[0]/Edit[0]",
1697
+ "scope": "pid-42",
1698
+ "stable": "desktop-uia:pid-42:Window[0]/Edit[0]",
1699
+ "app": "Calculator",
1700
+ "pid": 42,
1701
+ "states": ["focusable", "enabled"],
1702
+ },
1703
+ }),
1704
+ );
1705
+ }
1706
+
1707
+ #[test]
1708
+ fn observe_response_ranks_top_level_windows_by_goal() {
1709
+ let response = observe_response_from_windows(
1710
+ &[
1711
+ WindowRecord {
1712
+ hwnd: "0x3".into(),
1713
+ pid: 7,
1714
+ title: "Alpha".into(),
1715
+ children: vec![],
1716
+ },
1717
+ WindowRecord {
1718
+ hwnd: "0x2".into(),
1719
+ pid: 42,
1720
+ title: "Beta Preferences".into(),
1721
+ children: vec![],
1722
+ },
1723
+ ],
1724
+ &serde_json::json!({
1725
+ "goal": "beta preferences",
1726
+ "topK": 1,
1727
+ }),
1728
+ );
1729
+
1730
+ assert_eq!(
1731
+ response,
1732
+ serde_json::json!({
1733
+ "goal": "beta preferences",
1734
+ "count": 1,
1735
+ "candidates": [
1736
+ {
1737
+ "action": "click",
1738
+ "ref": "desktop-uia:pid-42:Window[0]",
1739
+ "stable": "desktop-uia:pid-42:Window[0]",
1740
+ "role": "Window",
1741
+ "name": "Beta Preferences",
1742
+ "confidence": 0.95,
1743
+ "reason": "exact title match",
1744
+ },
1745
+ ],
1746
+ }),
1747
+ );
1748
+ }
1749
+
1750
+ #[test]
1751
+ fn observe_response_ranks_descendant_elements_by_goal() {
1752
+ let response = observe_response_from_windows(
1753
+ &[WindowRecord {
1754
+ hwnd: "0x2".into(),
1755
+ pid: 42,
1756
+ title: "Calculator".into(),
1757
+ children: vec![
1758
+ ElementRecord {
1759
+ role: "Button".into(),
1760
+ name: "Five".into(),
1761
+ value: None,
1762
+ bounds: None,
1763
+ states: vec!["enabled".into()],
1764
+ children: vec![],
1765
+ },
1766
+ ElementRecord {
1767
+ role: "Button".into(),
1768
+ name: "Eight".into(),
1769
+ value: None,
1770
+ bounds: None,
1771
+ states: vec!["enabled".into()],
1772
+ children: vec![],
1773
+ },
1774
+ ],
1775
+ }],
1776
+ &serde_json::json!({
1777
+ "goal": "eight",
1778
+ "topK": 1,
1779
+ }),
1780
+ );
1781
+
1782
+ assert_eq!(
1783
+ response,
1784
+ serde_json::json!({
1785
+ "goal": "eight",
1786
+ "count": 1,
1787
+ "candidates": [
1788
+ {
1789
+ "action": "click",
1790
+ "ref": "desktop-uia:pid-42:Window[0]/Button[1]",
1791
+ "stable": "desktop-uia:pid-42:Window[0]/Button[1]",
1792
+ "role": "Button",
1793
+ "name": "Eight",
1794
+ "states": ["enabled"],
1795
+ "confidence": 0.95,
1796
+ "reason": "exact name match",
1797
+ },
1798
+ ],
1799
+ }),
1800
+ );
1801
+ }
1802
+
1803
+ #[test]
1804
+ fn observe_response_marks_scrollable_descendant_action() {
1805
+ let response = observe_response_from_windows(
1806
+ &[WindowRecord {
1807
+ hwnd: "0x2".into(),
1808
+ pid: 42,
1809
+ title: "Settings".into(),
1810
+ children: vec![ElementRecord {
1811
+ role: "Pane".into(),
1812
+ name: "Results".into(),
1813
+ value: None,
1814
+ bounds: None,
1815
+ states: vec!["enabled".into(), "vertically_scrollable".into()],
1816
+ children: vec![],
1817
+ }],
1818
+ }],
1819
+ &serde_json::json!({
1820
+ "goal": "results",
1821
+ "topK": 1,
1822
+ }),
1823
+ );
1824
+
1825
+ assert_eq!(response["candidates"][0]["action"], "scroll");
1826
+ assert_eq!(
1827
+ response["candidates"][0]["stable"],
1828
+ "desktop-uia:pid-42:Window[0]/Pane[0]",
1829
+ );
1830
+ }
1831
+
1832
+ #[test]
1833
+ fn observe_response_marks_range_descendant_action_as_set_value() {
1834
+ let response = observe_response_from_windows(
1835
+ &[WindowRecord {
1836
+ hwnd: "0x2".into(),
1837
+ pid: 42,
1838
+ title: "Settings".into(),
1839
+ children: vec![ElementRecord {
1840
+ role: "Slider".into(),
1841
+ name: "Volume".into(),
1842
+ value: Some("35".into()),
1843
+ bounds: None,
1844
+ states: vec!["enabled".into()],
1845
+ children: vec![],
1846
+ }],
1847
+ }],
1848
+ &serde_json::json!({
1849
+ "goal": "volume",
1850
+ "topK": 1,
1851
+ }),
1852
+ );
1853
+
1854
+ assert_eq!(response["candidates"][0]["action"], "set_value");
1855
+ assert_eq!(
1856
+ response["candidates"][0]["stable"],
1857
+ "desktop-uia:pid-42:Window[0]/Slider[0]",
1858
+ );
1859
+ }
1860
+
1861
+ #[test]
1862
+ fn observe_response_preserves_descendant_value_states_and_bounds() {
1863
+ let response = observe_response_from_windows(
1864
+ &[WindowRecord {
1865
+ hwnd: "0x2".into(),
1866
+ pid: 42,
1867
+ title: "Editor".into(),
1868
+ children: vec![ElementRecord {
1869
+ role: "Edit".into(),
1870
+ name: "Search".into(),
1871
+ value: Some("filter text".into()),
1872
+ bounds: Some(ElementBounds {
1873
+ x: 10,
1874
+ y: 20,
1875
+ width: 240,
1876
+ height: 32,
1877
+ }),
1878
+ states: vec!["enabled".into(), "focusable".into()],
1879
+ children: vec![],
1880
+ }],
1881
+ }],
1882
+ &serde_json::json!({
1883
+ "goal": "search",
1884
+ "topK": 1,
1885
+ }),
1886
+ );
1887
+
1888
+ assert_eq!(response["candidates"][0]["value"], "filter text");
1889
+ assert_eq!(
1890
+ response["candidates"][0]["states"],
1891
+ serde_json::json!(["enabled", "focusable"]),
1892
+ );
1893
+ assert_eq!(
1894
+ response["candidates"][0]["bounds"],
1895
+ serde_json::json!({
1896
+ "x": 10,
1897
+ "y": 20,
1898
+ "width": 240,
1899
+ "height": 32,
1900
+ }),
1901
+ );
1902
+ }
1903
+
1904
+ #[test]
1905
+ fn assert_response_matches_top_level_window_text_and_visible_state() {
1906
+ let response = assert_response_from_windows(
1907
+ &[
1908
+ WindowRecord {
1909
+ hwnd: "0x3".into(),
1910
+ pid: 7,
1911
+ title: "Alpha".into(),
1912
+ children: vec![],
1913
+ },
1914
+ WindowRecord {
1915
+ hwnd: "0x2".into(),
1916
+ pid: 42,
1917
+ title: "Beta Preferences".into(),
1918
+ children: vec![],
1919
+ },
1920
+ ],
1921
+ &serde_json::json!({
1922
+ "text": "preferences",
1923
+ "app": "beta",
1924
+ "state": "visible",
1925
+ }),
1926
+ )
1927
+ .expect("asserted window");
1928
+
1929
+ assert_eq!(
1930
+ response,
1931
+ serde_json::json!({
1932
+ "asserted": true,
1933
+ "via": "top_level_window_inventory",
1934
+ "checks": {
1935
+ "text": "preferences",
1936
+ "state": "visible",
1937
+ },
1938
+ "node": {
1939
+ "role": "Window",
1940
+ "name": "Beta Preferences",
1941
+ "path": "Window[0]",
1942
+ "scope": "pid-42",
1943
+ "stable": "desktop-uia:pid-42:Window[0]",
1944
+ "app": "Beta Preferences",
1945
+ "pid": 42,
1946
+ "states": ["visible"],
1947
+ "metadata": {
1948
+ "hwnd": "0x2",
1949
+ },
1950
+ },
1951
+ }),
1952
+ );
1953
+ }
1954
+
1955
+ #[test]
1956
+ fn assert_response_matches_descendant_text_value_and_enabled_state() {
1957
+ let response = assert_response_from_windows(
1958
+ &[WindowRecord {
1959
+ hwnd: "0x2".into(),
1960
+ pid: 42,
1961
+ title: "Calculator".into(),
1962
+ children: vec![ElementRecord {
1963
+ role: "Edit".into(),
1964
+ name: "Display".into(),
1965
+ value: Some("8".into()),
1966
+ bounds: None,
1967
+ states: vec!["focusable".into(), "enabled".into()],
1968
+ children: vec![],
1969
+ }],
1970
+ }],
1971
+ &serde_json::json!({
1972
+ "role": "edit",
1973
+ "text": "8",
1974
+ "state": "enabled",
1975
+ }),
1976
+ )
1977
+ .expect("asserted descendant");
1978
+
1979
+ assert_eq!(
1980
+ response,
1981
+ serde_json::json!({
1982
+ "asserted": true,
1983
+ "via": "native_descendant_tree",
1984
+ "checks": {
1985
+ "text": "8",
1986
+ "state": "enabled",
1987
+ },
1988
+ "node": {
1989
+ "role": "Edit",
1990
+ "name": "Display",
1991
+ "value": "8",
1992
+ "path": "Window[0]/Edit[0]",
1993
+ "scope": "pid-42",
1994
+ "stable": "desktop-uia:pid-42:Window[0]/Edit[0]",
1995
+ "app": "Calculator",
1996
+ "pid": 42,
1997
+ "states": ["focusable", "enabled"],
1998
+ },
1999
+ }),
2000
+ );
2001
+ }
2002
+
2003
+ #[test]
2004
+ fn assert_response_resolves_descendant_stable_ref() {
2005
+ let response = assert_response_from_windows(
2006
+ &[WindowRecord {
2007
+ hwnd: "0x2".into(),
2008
+ pid: 42,
2009
+ title: "Calculator".into(),
2010
+ children: vec![ElementRecord {
2011
+ role: "Edit".into(),
2012
+ name: "Display".into(),
2013
+ value: Some("8".into()),
2014
+ bounds: None,
2015
+ states: vec!["focusable".into(), "enabled".into()],
2016
+ children: vec![],
2017
+ }],
2018
+ }],
2019
+ &serde_json::json!({
2020
+ "ref": "desktop-uia:pid-42:Window[0]/Edit[0]",
2021
+ "text": "8",
2022
+ "state": "enabled",
2023
+ }),
2024
+ )
2025
+ .expect("asserted descendant ref");
2026
+
2027
+ assert_eq!(
2028
+ response,
2029
+ serde_json::json!({
2030
+ "asserted": true,
2031
+ "via": "native_descendant_tree",
2032
+ "checks": {
2033
+ "text": "8",
2034
+ "state": "enabled",
2035
+ },
2036
+ "node": {
2037
+ "role": "Edit",
2038
+ "name": "Display",
2039
+ "value": "8",
2040
+ "path": "Window[0]/Edit[0]",
2041
+ "scope": "pid-42",
2042
+ "stable": "desktop-uia:pid-42:Window[0]/Edit[0]",
2043
+ "app": "Calculator",
2044
+ "pid": 42,
2045
+ "states": ["focusable", "enabled"],
2046
+ },
2047
+ }),
2048
+ );
2049
+ }
2050
+
2051
+ #[test]
2052
+ fn resolves_stable_top_level_window_refs_by_pid_and_pid_local_index() {
2053
+ let windows = [
2054
+ WindowRecord {
2055
+ hwnd: "0x1".into(),
2056
+ pid: 42,
2057
+ title: "Beta".into(),
2058
+ children: vec![],
2059
+ },
2060
+ WindowRecord {
2061
+ hwnd: "0x2".into(),
2062
+ pid: 7,
2063
+ title: "Alpha".into(),
2064
+ children: vec![],
2065
+ },
2066
+ WindowRecord {
2067
+ hwnd: "0x3".into(),
2068
+ pid: 42,
2069
+ title: "Beta Preferences".into(),
2070
+ children: vec![],
2071
+ },
2072
+ ];
2073
+
2074
+ let resolved = resolve_top_level_window_ref(&windows, "desktop-uia:pid-42:Window[1]")
2075
+ .expect("stable window ref");
2076
+
2077
+ assert_eq!(resolved.hwnd, "0x3");
2078
+ assert_eq!(resolved.title, "Beta Preferences");
2079
+ }
2080
+
2081
+ #[test]
2082
+ fn uia_properties_create_normalized_descendant_record() {
2083
+ let record = element_record_from_uia_properties(UiaElementProperties {
2084
+ control_type_id: 50004,
2085
+ name: Some("Display".into()),
2086
+ value: Some("123".into()),
2087
+ bounds: Some(ElementBounds {
2088
+ x: 10,
2089
+ y: 20,
2090
+ width: 200,
2091
+ height: 32,
2092
+ }),
2093
+ enabled: true,
2094
+ focusable: true,
2095
+ horizontally_scrollable: false,
2096
+ vertically_scrollable: false,
2097
+ })
2098
+ .expect("normalized element");
2099
+
2100
+ assert_eq!(
2101
+ record,
2102
+ ElementRecord {
2103
+ role: "Edit".into(),
2104
+ name: "Display".into(),
2105
+ value: Some("123".into()),
2106
+ bounds: Some(ElementBounds {
2107
+ x: 10,
2108
+ y: 20,
2109
+ width: 200,
2110
+ height: 32,
2111
+ }),
2112
+ states: vec!["enabled".into(), "focusable".into()],
2113
+ children: vec![],
2114
+ },
2115
+ );
2116
+ }
2117
+
2118
+ #[test]
2119
+ fn uia_properties_include_scrollable_states() {
2120
+ let record = element_record_from_uia_properties(UiaElementProperties {
2121
+ control_type_id: 50033,
2122
+ name: Some("Results".into()),
2123
+ value: None,
2124
+ bounds: None,
2125
+ enabled: true,
2126
+ focusable: false,
2127
+ horizontally_scrollable: false,
2128
+ vertically_scrollable: true,
2129
+ })
2130
+ .expect("scrollable element");
2131
+
2132
+ assert_eq!(record.role, "Pane");
2133
+ assert!(record.states.contains(&"vertically_scrollable".into()));
2134
+ }
2135
+ }