winauto-cli 0.1.0__py3-none-any.whl
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.
- winauto_cli/__init__.py +3 -0
- winauto_cli/__main__.py +5 -0
- winauto_cli/automation.py +635 -0
- winauto_cli/cli.py +422 -0
- winauto_cli/errors.py +14 -0
- winauto_cli/models.py +53 -0
- winauto_cli-0.1.0.dist-info/METADATA +217 -0
- winauto_cli-0.1.0.dist-info/RECORD +12 -0
- winauto_cli-0.1.0.dist-info/WHEEL +5 -0
- winauto_cli-0.1.0.dist-info/entry_points.txt +2 -0
- winauto_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- winauto_cli-0.1.0.dist-info/top_level.txt +1 -0
winauto_cli/__init__.py
ADDED
winauto_cli/__main__.py
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from winauto_cli.errors import ActionExecutionError, AssertionExecutionError, ElementResolutionError
|
|
10
|
+
from winauto_cli.models import RuntimeOptions, Selector
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from pywinauto import Desktop
|
|
14
|
+
except ImportError: # pragma: no cover - handled at runtime
|
|
15
|
+
Desktop = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
COMMON_PATTERNS = {
|
|
19
|
+
"invoke": "iface_invoke",
|
|
20
|
+
"value": "iface_value",
|
|
21
|
+
"selection": "iface_selection",
|
|
22
|
+
"selection_item": "iface_selection_item",
|
|
23
|
+
"toggle": "iface_toggle",
|
|
24
|
+
"expand_collapse": "iface_expand_collapse",
|
|
25
|
+
"text": "iface_text",
|
|
26
|
+
"grid": "iface_grid",
|
|
27
|
+
"table": "iface_table",
|
|
28
|
+
"legacy": "iface_legacy_iaccessible",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
ALLOWED_PATH_ATTRS = {"name", "auto_id", "class_name", "control_type", "window_text"}
|
|
32
|
+
PATH_ATTR_PATTERN = re.compile(r"\[@(?P<key>[a-zA-Z_]+)=(?P<quote>['\"])(?P<value>.*?)(?P=quote)\]")
|
|
33
|
+
PATH_INDEX_PATTERN = re.compile(r"\[(?P<index>\d+)\]")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class PathStep:
|
|
38
|
+
axis: str
|
|
39
|
+
control_type: str | None = None
|
|
40
|
+
filters: dict[str, str] = field(default_factory=dict)
|
|
41
|
+
index: int | None = None
|
|
42
|
+
raw: str = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _require_pywinauto() -> None:
|
|
47
|
+
if Desktop is None:
|
|
48
|
+
raise ActionExecutionError(
|
|
49
|
+
"pywinauto is not installed. Install dependencies first with: python -m pip install -e ."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _log(enabled: bool, message: str) -> None:
|
|
55
|
+
if enabled:
|
|
56
|
+
print(f"[debug] {message}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _desktop(backend: str):
|
|
61
|
+
_require_pywinauto()
|
|
62
|
+
return Desktop(backend=backend)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _wait_until(timeout: float):
|
|
67
|
+
deadline = time.monotonic() + max(timeout, 0)
|
|
68
|
+
while True:
|
|
69
|
+
yield
|
|
70
|
+
if time.monotonic() >= deadline:
|
|
71
|
+
break
|
|
72
|
+
time.sleep(0.2)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_scope(window_selector: Selector, options: RuntimeOptions):
|
|
77
|
+
if window_selector.is_empty():
|
|
78
|
+
return None
|
|
79
|
+
desktop = _desktop(options.backend)
|
|
80
|
+
return desktop.window(**window_selector.to_kwargs()).wrapper_object()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve_element(window_selector: Selector, target_selector: Selector, options: RuntimeOptions, path_query: str | None = None):
|
|
85
|
+
matches = find_elements(window_selector, target_selector, options, max_results=1, require_match=True, path_query=path_query)
|
|
86
|
+
return matches[0]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def find_elements(
|
|
91
|
+
window_selector: Selector,
|
|
92
|
+
target_selector: Selector,
|
|
93
|
+
options: RuntimeOptions,
|
|
94
|
+
max_results: int = 20,
|
|
95
|
+
require_match: bool = False,
|
|
96
|
+
path_query: str | None = None,
|
|
97
|
+
):
|
|
98
|
+
last_error: Exception | None = None
|
|
99
|
+
|
|
100
|
+
for _ in _wait_until(options.timeout):
|
|
101
|
+
try:
|
|
102
|
+
scope = resolve_scope(window_selector, options)
|
|
103
|
+
if scope is not None:
|
|
104
|
+
_log(options.verbose, f"resolved parent window: {safe_summary(scope)}")
|
|
105
|
+
matches = _collect_matches(scope, target_selector, options, max_results=max_results, path_query=path_query)
|
|
106
|
+
if matches:
|
|
107
|
+
_log(options.verbose, f"resolved {len(matches)} candidate(s)")
|
|
108
|
+
return matches
|
|
109
|
+
last_error = None
|
|
110
|
+
except Exception as exc: # pragma: no cover - depends on UI state
|
|
111
|
+
last_error = exc
|
|
112
|
+
|
|
113
|
+
if require_match:
|
|
114
|
+
selector_blob = {
|
|
115
|
+
"window": window_selector.to_kwargs(),
|
|
116
|
+
"target": target_selector.to_kwargs(),
|
|
117
|
+
"path": path_query,
|
|
118
|
+
"backend": options.backend,
|
|
119
|
+
"top_level_only": options.top_level_only,
|
|
120
|
+
}
|
|
121
|
+
raise ElementResolutionError(
|
|
122
|
+
f"Unable to resolve element within {options.timeout:.1f}s: {json.dumps(selector_blob, ensure_ascii=True)}"
|
|
123
|
+
) from last_error
|
|
124
|
+
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _collect_matches(scope, target_selector: Selector, options: RuntimeOptions, max_results: int, path_query: str | None = None):
|
|
130
|
+
if path_query:
|
|
131
|
+
return _collect_path_matches(scope, path_query, options, max_results=max_results)
|
|
132
|
+
|
|
133
|
+
target_kwargs = target_selector.to_kwargs()
|
|
134
|
+
desktop = _desktop(options.backend)
|
|
135
|
+
|
|
136
|
+
if scope is None:
|
|
137
|
+
if target_selector.is_empty():
|
|
138
|
+
windows = desktop.windows()
|
|
139
|
+
else:
|
|
140
|
+
windows = desktop.windows(**target_kwargs)
|
|
141
|
+
return list(windows)[:max_results]
|
|
142
|
+
|
|
143
|
+
if target_selector.is_empty():
|
|
144
|
+
candidates = scope.children() if options.top_level_only else scope.descendants()
|
|
145
|
+
else:
|
|
146
|
+
candidates = scope.children(**target_kwargs) if options.top_level_only else scope.descendants(**target_kwargs)
|
|
147
|
+
return list(candidates)[:max_results]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _collect_path_matches(scope, path_query: str, options: RuntimeOptions, max_results: int):
|
|
152
|
+
steps = parse_path_query(path_query)
|
|
153
|
+
desktop = _desktop(options.backend)
|
|
154
|
+
if scope is None:
|
|
155
|
+
current_nodes = list(desktop.windows())
|
|
156
|
+
else:
|
|
157
|
+
current_nodes = [scope]
|
|
158
|
+
|
|
159
|
+
for position, step in enumerate(steps):
|
|
160
|
+
next_nodes = []
|
|
161
|
+
for node in current_nodes:
|
|
162
|
+
next_nodes.extend(_step_candidates(node, step, root_scope=(position == 0 and scope is None)))
|
|
163
|
+
if step.index is not None:
|
|
164
|
+
next_nodes = next_nodes[step.index:step.index + 1]
|
|
165
|
+
current_nodes = next_nodes
|
|
166
|
+
if not current_nodes:
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
return current_nodes[:max_results]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _step_candidates(node, step: PathStep, root_scope: bool = False):
|
|
174
|
+
if root_scope:
|
|
175
|
+
if step.axis == "direct":
|
|
176
|
+
pool = [node]
|
|
177
|
+
else:
|
|
178
|
+
pool = [node]
|
|
179
|
+
try:
|
|
180
|
+
pool.extend(node.descendants())
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
else:
|
|
184
|
+
try:
|
|
185
|
+
pool = node.children() if step.axis == "direct" else node.descendants()
|
|
186
|
+
except Exception:
|
|
187
|
+
return []
|
|
188
|
+
return [candidate for candidate in pool if _matches_path_step(candidate, step)]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _matches_path_step(element, step: PathStep) -> bool:
|
|
193
|
+
summary = safe_summary(element)
|
|
194
|
+
if step.control_type and str(summary.get("control_type") or "") != step.control_type:
|
|
195
|
+
return False
|
|
196
|
+
for key, expected in step.filters.items():
|
|
197
|
+
actual = summary.get(key)
|
|
198
|
+
if actual is None:
|
|
199
|
+
return False
|
|
200
|
+
if str(actual) != expected:
|
|
201
|
+
return False
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def parse_path_query(path_query: str) -> list[PathStep]:
|
|
207
|
+
query = (path_query or "").strip()
|
|
208
|
+
if not query:
|
|
209
|
+
raise ElementResolutionError("Path query cannot be empty.")
|
|
210
|
+
|
|
211
|
+
steps: list[PathStep] = []
|
|
212
|
+
position = 0
|
|
213
|
+
while position < len(query):
|
|
214
|
+
axis = "descendant"
|
|
215
|
+
if query.startswith("//", position):
|
|
216
|
+
axis = "descendant"
|
|
217
|
+
position += 2
|
|
218
|
+
elif query.startswith("/", position):
|
|
219
|
+
axis = "direct"
|
|
220
|
+
position += 1
|
|
221
|
+
elif steps:
|
|
222
|
+
axis = "direct"
|
|
223
|
+
|
|
224
|
+
start = position
|
|
225
|
+
bracket_depth = 0
|
|
226
|
+
while position < len(query):
|
|
227
|
+
char = query[position]
|
|
228
|
+
if char == '[':
|
|
229
|
+
bracket_depth += 1
|
|
230
|
+
elif char == ']':
|
|
231
|
+
bracket_depth -= 1
|
|
232
|
+
elif char == '/' and bracket_depth == 0:
|
|
233
|
+
break
|
|
234
|
+
position += 1
|
|
235
|
+
|
|
236
|
+
segment = query[start:position].strip()
|
|
237
|
+
if not segment:
|
|
238
|
+
continue
|
|
239
|
+
steps.append(_parse_path_segment(segment, axis))
|
|
240
|
+
|
|
241
|
+
if not steps:
|
|
242
|
+
raise ElementResolutionError(f"Invalid path query: {path_query}")
|
|
243
|
+
return steps
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _parse_path_segment(segment: str, axis: str) -> PathStep:
|
|
248
|
+
bracket_start = segment.find('[')
|
|
249
|
+
head = segment if bracket_start == -1 else segment[:bracket_start]
|
|
250
|
+
head = head.strip()
|
|
251
|
+
control_type = None if head in ("", "*") else head
|
|
252
|
+
|
|
253
|
+
filters: dict[str, str] = {}
|
|
254
|
+
for match in PATH_ATTR_PATTERN.finditer(segment):
|
|
255
|
+
key = match.group("key")
|
|
256
|
+
if key not in ALLOWED_PATH_ATTRS:
|
|
257
|
+
raise ElementResolutionError(f"Unsupported path attribute: {key}")
|
|
258
|
+
filters[key] = match.group("value")
|
|
259
|
+
|
|
260
|
+
index = None
|
|
261
|
+
for match in PATH_INDEX_PATTERN.finditer(segment):
|
|
262
|
+
if match.group(0).startswith("[@"):
|
|
263
|
+
continue
|
|
264
|
+
index = int(match.group("index"))
|
|
265
|
+
|
|
266
|
+
return PathStep(axis=axis, control_type=control_type, filters=filters, index=index, raw=segment)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def list_windows(options: RuntimeOptions, max_results: int = 50):
|
|
271
|
+
desktop = _desktop(options.backend)
|
|
272
|
+
windows = desktop.windows()
|
|
273
|
+
return [safe_summary(window) for window in windows[:max_results]]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def safe_summary(element) -> dict[str, Any]:
|
|
278
|
+
info: dict[str, Any] = {"friendly_class_name": None, "window_text": None}
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
info["friendly_class_name"] = element.friendly_class_name()
|
|
282
|
+
except Exception:
|
|
283
|
+
pass
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
info["window_text"] = element.window_text()
|
|
287
|
+
except Exception:
|
|
288
|
+
pass
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
rect = element.rectangle()
|
|
292
|
+
info["rectangle"] = {
|
|
293
|
+
"left": rect.left,
|
|
294
|
+
"top": rect.top,
|
|
295
|
+
"right": rect.right,
|
|
296
|
+
"bottom": rect.bottom,
|
|
297
|
+
}
|
|
298
|
+
except Exception:
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
element_info = getattr(element, "element_info", None)
|
|
302
|
+
if element_info is not None:
|
|
303
|
+
for source_name, target_name in [
|
|
304
|
+
("name", "name"),
|
|
305
|
+
("automation_id", "automation_id"),
|
|
306
|
+
("class_name", "class_name"),
|
|
307
|
+
("control_type", "control_type"),
|
|
308
|
+
("handle", "handle"),
|
|
309
|
+
("rich_text", "rich_text"),
|
|
310
|
+
]:
|
|
311
|
+
try:
|
|
312
|
+
info[target_name] = getattr(element_info, source_name)
|
|
313
|
+
except Exception:
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
return info
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def inspect_element(element, child_limit: int = 20, parent_limit: int = 8) -> dict[str, Any]:
|
|
321
|
+
summary = safe_summary(element)
|
|
322
|
+
children = []
|
|
323
|
+
parents = []
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
for child in element.children()[:child_limit]:
|
|
327
|
+
children.append(safe_summary(child))
|
|
328
|
+
except Exception:
|
|
329
|
+
pass
|
|
330
|
+
|
|
331
|
+
current = element
|
|
332
|
+
for _ in range(parent_limit):
|
|
333
|
+
try:
|
|
334
|
+
current = current.parent()
|
|
335
|
+
except Exception:
|
|
336
|
+
break
|
|
337
|
+
if current is None:
|
|
338
|
+
break
|
|
339
|
+
parents.append(safe_summary(current))
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
"element": summary,
|
|
343
|
+
"children": children,
|
|
344
|
+
"parents": parents,
|
|
345
|
+
"supported_patterns": _supported_patterns(element),
|
|
346
|
+
"recommended_selectors": recommended_selectors(summary),
|
|
347
|
+
"recommended_paths": recommended_paths(summary, parents),
|
|
348
|
+
"relative_path": build_relative_path(summary),
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def recommended_selectors(summary: dict[str, Any]) -> list[dict[str, str]]:
|
|
354
|
+
selectors: list[dict[str, str]] = []
|
|
355
|
+
automation_id = summary.get("automation_id")
|
|
356
|
+
name = summary.get("name") or summary.get("window_text")
|
|
357
|
+
control_type = summary.get("control_type")
|
|
358
|
+
class_name = summary.get("class_name")
|
|
359
|
+
|
|
360
|
+
if automation_id:
|
|
361
|
+
selectors.append({"auto_id": str(automation_id)})
|
|
362
|
+
if name and control_type:
|
|
363
|
+
selectors.append({"name": str(name), "control_type": str(control_type)})
|
|
364
|
+
if name:
|
|
365
|
+
selectors.append({"name": str(name)})
|
|
366
|
+
if class_name and control_type:
|
|
367
|
+
selectors.append({"class_name": str(class_name), "control_type": str(control_type)})
|
|
368
|
+
return selectors
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def recommended_paths(summary: dict[str, Any], parents: list[dict[str, Any]]) -> list[str]:
|
|
373
|
+
leaf = _path_segment_for_summary(summary)
|
|
374
|
+
parent_segments = [_path_segment_for_summary(parent) for parent in reversed(parents[:2])]
|
|
375
|
+
|
|
376
|
+
candidates: list[str] = []
|
|
377
|
+
if leaf:
|
|
378
|
+
candidates.append(f"//{leaf}")
|
|
379
|
+
if parent_segments and leaf:
|
|
380
|
+
candidates.append("//" + "/".join(parent_segments + [leaf]))
|
|
381
|
+
if summary.get("control_type"):
|
|
382
|
+
candidates.append(f"//{summary['control_type']}")
|
|
383
|
+
|
|
384
|
+
seen = set()
|
|
385
|
+
deduped = []
|
|
386
|
+
for candidate in candidates:
|
|
387
|
+
if candidate not in seen:
|
|
388
|
+
seen.add(candidate)
|
|
389
|
+
deduped.append(candidate)
|
|
390
|
+
return deduped
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def build_relative_path(summary: dict[str, Any]) -> str | None:
|
|
395
|
+
segment = _path_segment_for_summary(summary)
|
|
396
|
+
if not segment:
|
|
397
|
+
return None
|
|
398
|
+
return segment
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _path_segment_for_summary(summary: dict[str, Any]) -> str | None:
|
|
403
|
+
control_type = summary.get("control_type")
|
|
404
|
+
if not control_type:
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
auto_id = summary.get("automation_id")
|
|
408
|
+
if auto_id:
|
|
409
|
+
return f"{control_type}[@auto_id={json.dumps(str(auto_id), ensure_ascii=True)}]"
|
|
410
|
+
|
|
411
|
+
name = summary.get("name") or summary.get("window_text")
|
|
412
|
+
if name:
|
|
413
|
+
return f"{control_type}[@name={json.dumps(str(name), ensure_ascii=True)}]"
|
|
414
|
+
|
|
415
|
+
class_name = summary.get("class_name")
|
|
416
|
+
if class_name:
|
|
417
|
+
return f"{control_type}[@class_name={json.dumps(str(class_name), ensure_ascii=True)}]"
|
|
418
|
+
|
|
419
|
+
return str(control_type)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _supported_patterns(element) -> list[str]:
|
|
424
|
+
supported = []
|
|
425
|
+
for pattern_name, attr_name in COMMON_PATTERNS.items():
|
|
426
|
+
try:
|
|
427
|
+
value = getattr(element, attr_name, None)
|
|
428
|
+
if value is not None:
|
|
429
|
+
supported.append(pattern_name)
|
|
430
|
+
except Exception:
|
|
431
|
+
continue
|
|
432
|
+
return supported
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def dump_identifiers(element) -> str:
|
|
437
|
+
lines: list[str] = []
|
|
438
|
+
_append_tree(lines, element, depth=0, max_depth=8)
|
|
439
|
+
return "\n".join(lines).rstrip()
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def highlight_element(element, duration_ms: int = 800, colour: str = "red", thickness: int = 3) -> dict[str, Any]:
|
|
444
|
+
try:
|
|
445
|
+
element.draw_outline(colour=colour, thickness=thickness)
|
|
446
|
+
time.sleep(max(duration_ms, 0) / 1000.0)
|
|
447
|
+
except Exception as exc: # pragma: no cover - depends on UI state
|
|
448
|
+
raise ActionExecutionError(f"Failed to highlight element: {safe_summary(element)}") from exc
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
"status": "ok",
|
|
452
|
+
"duration_ms": duration_ms,
|
|
453
|
+
"colour": colour,
|
|
454
|
+
"thickness": thickness,
|
|
455
|
+
"element": safe_summary(element),
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def capture_image(
|
|
461
|
+
element,
|
|
462
|
+
out_path: str,
|
|
463
|
+
highlight: bool = False,
|
|
464
|
+
highlight_duration_ms: int = 800,
|
|
465
|
+
highlight_colour: str = "red",
|
|
466
|
+
highlight_thickness: int = 3,
|
|
467
|
+
) -> dict[str, Any]:
|
|
468
|
+
if highlight:
|
|
469
|
+
highlight_element(
|
|
470
|
+
element,
|
|
471
|
+
duration_ms=highlight_duration_ms,
|
|
472
|
+
colour=highlight_colour,
|
|
473
|
+
thickness=highlight_thickness,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
image = element.capture_as_image()
|
|
478
|
+
image.save(out_path)
|
|
479
|
+
except Exception as exc: # pragma: no cover - depends on UI state
|
|
480
|
+
raise ActionExecutionError(f"Failed to capture screenshot for element: {safe_summary(element)}") from exc
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
"status": "ok",
|
|
484
|
+
"out": out_path,
|
|
485
|
+
"highlight": highlight,
|
|
486
|
+
"highlight_duration_ms": highlight_duration_ms if highlight else 0,
|
|
487
|
+
"highlight_colour": highlight_colour if highlight else None,
|
|
488
|
+
"highlight_thickness": highlight_thickness if highlight else None,
|
|
489
|
+
"element": safe_summary(element),
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _append_tree(lines: list[str], element, depth: int, max_depth: int) -> None:
|
|
495
|
+
indent = " " * depth
|
|
496
|
+
summary = safe_summary(element)
|
|
497
|
+
label_parts: list[str] = []
|
|
498
|
+
|
|
499
|
+
control_type = summary.get("control_type")
|
|
500
|
+
friendly_class_name = summary.get("friendly_class_name")
|
|
501
|
+
name = summary.get("name") or summary.get("window_text")
|
|
502
|
+
auto_id = summary.get("automation_id")
|
|
503
|
+
class_name = summary.get("class_name")
|
|
504
|
+
|
|
505
|
+
if control_type:
|
|
506
|
+
label_parts.append(f"control_type={control_type}")
|
|
507
|
+
if friendly_class_name:
|
|
508
|
+
label_parts.append(f"friendly_class={friendly_class_name}")
|
|
509
|
+
if name:
|
|
510
|
+
label_parts.append(f"name={name!r}")
|
|
511
|
+
if auto_id:
|
|
512
|
+
label_parts.append(f"auto_id={auto_id!r}")
|
|
513
|
+
if class_name:
|
|
514
|
+
label_parts.append(f"class_name={class_name!r}")
|
|
515
|
+
|
|
516
|
+
if not label_parts:
|
|
517
|
+
label_parts.append(repr(summary))
|
|
518
|
+
|
|
519
|
+
lines.append(f"{indent}- " + ", ".join(label_parts))
|
|
520
|
+
|
|
521
|
+
if depth >= max_depth:
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
try:
|
|
525
|
+
children = element.children()
|
|
526
|
+
except Exception:
|
|
527
|
+
return
|
|
528
|
+
|
|
529
|
+
for child in children:
|
|
530
|
+
_append_tree(lines, child, depth + 1, max_depth)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def click_element(element) -> None:
|
|
535
|
+
try:
|
|
536
|
+
element.click_input()
|
|
537
|
+
except Exception as exc: # pragma: no cover - depends on UI state
|
|
538
|
+
raise ActionExecutionError(f"Failed to click element: {safe_summary(element)}") from exc
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def type_into_element(element, text: str) -> None:
|
|
543
|
+
try:
|
|
544
|
+
element.set_focus()
|
|
545
|
+
except Exception:
|
|
546
|
+
pass
|
|
547
|
+
|
|
548
|
+
try:
|
|
549
|
+
element.type_keys(text, with_spaces=True, set_foreground=True)
|
|
550
|
+
except Exception:
|
|
551
|
+
try:
|
|
552
|
+
element.set_edit_text(text)
|
|
553
|
+
except Exception as exc: # pragma: no cover - depends on UI state
|
|
554
|
+
raise ActionExecutionError(f"Failed to type into element: {safe_summary(element)}") from exc
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def invoke_element(element) -> None:
|
|
559
|
+
try:
|
|
560
|
+
element.invoke()
|
|
561
|
+
except Exception as exc: # pragma: no cover - depends on UI state
|
|
562
|
+
raise ActionExecutionError(f"Failed to invoke element: {safe_summary(element)}") from exc
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def perform_action(element, action: str, text: str | None = None) -> dict[str, Any]:
|
|
567
|
+
if action == "click":
|
|
568
|
+
click_element(element)
|
|
569
|
+
elif action == "type":
|
|
570
|
+
if text is None:
|
|
571
|
+
raise ActionExecutionError("The 'type' action requires --text.")
|
|
572
|
+
type_into_element(element, text)
|
|
573
|
+
elif action == "invoke":
|
|
574
|
+
invoke_element(element)
|
|
575
|
+
elif action == "set_text":
|
|
576
|
+
if text is None:
|
|
577
|
+
raise ActionExecutionError("The 'set_text' action requires --text.")
|
|
578
|
+
try:
|
|
579
|
+
element.set_edit_text(text)
|
|
580
|
+
except Exception as exc: # pragma: no cover - depends on UI state
|
|
581
|
+
raise ActionExecutionError(f"Failed to set text on element: {safe_summary(element)}") from exc
|
|
582
|
+
else:
|
|
583
|
+
raise ActionExecutionError(f"Unsupported action: {action}")
|
|
584
|
+
|
|
585
|
+
payload = {"status": "ok", "action": action, "element": safe_summary(element)}
|
|
586
|
+
if text is not None:
|
|
587
|
+
payload["text"] = text
|
|
588
|
+
return payload
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def run_assertion(elements: list, assertion: str, expected: str | None = None, expected_bool: bool | None = None) -> dict[str, Any]:
|
|
593
|
+
if assertion == "exists":
|
|
594
|
+
passed = len(elements) > 0
|
|
595
|
+
actual = len(elements)
|
|
596
|
+
elif assertion == "count":
|
|
597
|
+
if expected is None:
|
|
598
|
+
raise AssertionExecutionError("The 'count' assertion requires --expected.")
|
|
599
|
+
actual = len(elements)
|
|
600
|
+
passed = actual == int(expected)
|
|
601
|
+
else:
|
|
602
|
+
if not elements:
|
|
603
|
+
raise AssertionExecutionError("Target element was not found for assertion.")
|
|
604
|
+
element = elements[0]
|
|
605
|
+
summary = safe_summary(element)
|
|
606
|
+
if assertion == "text_equals":
|
|
607
|
+
if expected is None:
|
|
608
|
+
raise AssertionExecutionError("The 'text_equals' assertion requires --expected.")
|
|
609
|
+
actual = str(summary.get("name") or summary.get("window_text") or "")
|
|
610
|
+
passed = actual == expected
|
|
611
|
+
elif assertion == "enabled":
|
|
612
|
+
actual = bool(element.is_enabled())
|
|
613
|
+
passed = actual is expected_bool
|
|
614
|
+
elif assertion == "visible":
|
|
615
|
+
actual = bool(element.is_visible())
|
|
616
|
+
passed = actual is expected_bool
|
|
617
|
+
else:
|
|
618
|
+
raise AssertionExecutionError(f"Unsupported assertion: {assertion}")
|
|
619
|
+
|
|
620
|
+
result = {
|
|
621
|
+
"status": "passed" if passed else "failed",
|
|
622
|
+
"assertion": assertion,
|
|
623
|
+
"actual": actual,
|
|
624
|
+
}
|
|
625
|
+
if expected is not None:
|
|
626
|
+
result["expected"] = expected
|
|
627
|
+
if expected_bool is not None:
|
|
628
|
+
result["expected"] = expected_bool
|
|
629
|
+
if elements:
|
|
630
|
+
result["element"] = safe_summary(elements[0])
|
|
631
|
+
result["count"] = len(elements)
|
|
632
|
+
|
|
633
|
+
if not passed:
|
|
634
|
+
raise AssertionExecutionError(json.dumps(result, ensure_ascii=True))
|
|
635
|
+
return result
|