dynamic-test-engine 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.
- automation/__init__.py +0 -0
- automation/config.py +69 -0
- automation/engines/__init__.py +0 -0
- automation/engines/engine_manager.py +382 -0
- automation/engines/uia_engine.py +220 -0
- automation/engines/vision_base.py +30 -0
- automation/engines/vision_engine.py +474 -0
- automation/engines/windows_uia_engine.py +347 -0
- automation/keywords/__init__.py +0 -0
- automation/keywords/automation_keywords.py +83 -0
- automation/utils/__init__.py +0 -0
- automation/utils/adb.py +220 -0
- automation/utils/cache.py +135 -0
- automation/utils/device_interface.py +60 -0
- automation/utils/image_processing.py +258 -0
- automation/utils/scroll_centering.py +48 -0
- automation/utils/spatial.py +63 -0
- automation/utils/windows_host.py +354 -0
- dynamic_test_engine-0.1.0.dist-info/METADATA +405 -0
- dynamic_test_engine-0.1.0.dist-info/RECORD +23 -0
- dynamic_test_engine-0.1.0.dist-info/WHEEL +5 -0
- dynamic_test_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- dynamic_test_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from automation import config
|
|
9
|
+
from automation.utils import spatial
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from automation.utils.windows_host import WindowsHost
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TextNotFoundError(RuntimeError):
|
|
18
|
+
"""Echter Miss: Text nach vollständiger UIA-Suche nicht gefunden, getrennt von
|
|
19
|
+
technischen Fehlern damit engine_manager.auto nur diesen Fall als Fallback behandelt."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FieldNotFoundError(RuntimeError):
|
|
23
|
+
"""Echter Miss: Eingabefeld oder Anker-Text nicht gefunden, oder der Treffer ist ein
|
|
24
|
+
Container ohne editierbares Child. Mehrdeutigkeit ohne anchor bleibt RuntimeError."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class IconNotFoundError(RuntimeError):
|
|
28
|
+
"""Echter Miss: Anker-Text oder Icon-Element für Tap Icon Near Text nicht gefunden, oder
|
|
29
|
+
kein Kandidat erfüllt 'near' innerhalb von SPATIAL_NEAR_MAX_DISTANCE."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class WindowsUiaEngine:
|
|
33
|
+
|
|
34
|
+
def __init__(self, host: "WindowsHost") -> None:
|
|
35
|
+
"""host ist die einzige Quelle des pywinauto.Application-Handles."""
|
|
36
|
+
self._host = host
|
|
37
|
+
|
|
38
|
+
def _require_connected(self) -> None:
|
|
39
|
+
if self._host.app is None:
|
|
40
|
+
raise RuntimeError("Kein Windows-Prozess verbunden. Connect Device zuerst aufrufen.")
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def _tasklist_pid(exe_name: str) -> int | None:
|
|
44
|
+
"""Liest die PID von exe_name per tasklist, None wenn (noch) nicht gefunden."""
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
["tasklist", "/fi", f"IMAGENAME eq {exe_name}", "/fo", "csv", "/nh"],
|
|
47
|
+
capture_output=True,
|
|
48
|
+
encoding="oem",
|
|
49
|
+
errors="replace",
|
|
50
|
+
)
|
|
51
|
+
matches = [ln for ln in result.stdout.strip().splitlines() if exe_name.lower() in ln.lower()]
|
|
52
|
+
return int(matches[0].split('","')[1]) if matches else None
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def _poll(cls, fn, timeout: float, interval: float):
|
|
56
|
+
"""Ruft fn() wiederholt auf bis ein truthy Ergebnis vorliegt oder timeout erreicht ist."""
|
|
57
|
+
deadline = time.time() + timeout
|
|
58
|
+
while True:
|
|
59
|
+
result = fn()
|
|
60
|
+
if result:
|
|
61
|
+
return result
|
|
62
|
+
if time.time() >= deadline:
|
|
63
|
+
return None
|
|
64
|
+
time.sleep(interval)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def _pid_by_name(cls, exe_name: str, timeout: float) -> int:
|
|
68
|
+
"""Ermittelt die aktuelle PID eines laufenden Prozesses per tasklist, mit Retry bis
|
|
69
|
+
timeout, statt der ggf. veralteten PID von Application.start()."""
|
|
70
|
+
pid = cls._poll(lambda: cls._tasklist_pid(exe_name), timeout, interval=0.3)
|
|
71
|
+
if pid is None:
|
|
72
|
+
raise RuntimeError(f"Prozess '{exe_name}' nach dem Start nicht gefunden.")
|
|
73
|
+
return pid
|
|
74
|
+
|
|
75
|
+
def _wait_for_window(self, timeout: float):
|
|
76
|
+
"""Wartet per Retry auf das erste Top-Level-Fenster des Prozesses, da top_window()
|
|
77
|
+
ohne eingebautes Polling sofort scheitert, solange noch keins existiert."""
|
|
78
|
+
def try_top_window():
|
|
79
|
+
try:
|
|
80
|
+
return self._host.app.top_window()
|
|
81
|
+
except Exception:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
window = self._poll(try_top_window, timeout, interval=0.3)
|
|
85
|
+
if window is None:
|
|
86
|
+
raise RuntimeError("Kein Fenster für den gestarteten Prozess gefunden.")
|
|
87
|
+
return window
|
|
88
|
+
|
|
89
|
+
def launch_app(self, exe_path: str, activity: str | None = None, fullscreen: bool = False) -> None:
|
|
90
|
+
"""Beendet eine ggf. laufende Instanz von exe_path und startet sie neu, damit jeder
|
|
91
|
+
Testlauf von einem definierten Ausgangszustand beginnt. activity wird ignoriert."""
|
|
92
|
+
import os
|
|
93
|
+
import subprocess as sp
|
|
94
|
+
from pywinauto import Application # lazy: nur auf Windows installierbar
|
|
95
|
+
|
|
96
|
+
exe_name = os.path.basename(exe_path)
|
|
97
|
+
sp.run(["taskkill", "/f", "/im", exe_name], capture_output=True)
|
|
98
|
+
|
|
99
|
+
Application(backend="uia").start(exe_path, work_dir=os.path.dirname(exe_path))
|
|
100
|
+
pid = self._pid_by_name(exe_name, timeout=config.UIA_APP_START_TIMEOUT)
|
|
101
|
+
self._host.connect(str(pid), process_name=exe_name)
|
|
102
|
+
|
|
103
|
+
window = self._wait_for_window(config.UIA_APP_START_TIMEOUT)
|
|
104
|
+
window.wait("ready", timeout=config.UIA_APP_START_TIMEOUT)
|
|
105
|
+
if fullscreen:
|
|
106
|
+
try:
|
|
107
|
+
window.maximize()
|
|
108
|
+
except Exception:
|
|
109
|
+
# Manche Fenster exponieren kein UIA-WindowPattern. Fallback über die
|
|
110
|
+
# native Windows-API, die nur den Fensterhandle braucht statt UIA-Patterns.
|
|
111
|
+
import win32con
|
|
112
|
+
import win32gui
|
|
113
|
+
win32gui.ShowWindow(window.handle, win32con.SW_MAXIMIZE)
|
|
114
|
+
|
|
115
|
+
def close_app(self) -> None:
|
|
116
|
+
"""Beendet die aktuell verbundene App PID-scoped, siehe WindowsHost.close."""
|
|
117
|
+
self._host.close()
|
|
118
|
+
|
|
119
|
+
def _window(self):
|
|
120
|
+
"""Delegiert an WindowsHost.window(), damit UIA-Suche und Vision-Crop garantiert
|
|
121
|
+
dasselbe Fenster verwenden."""
|
|
122
|
+
return self._host.window()
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _descendants(window, **kwargs):
|
|
126
|
+
"""auto_id/title sind keine gültigen build_condition()-Properties, werden deshalb
|
|
127
|
+
vorab entfernt und danach lokal gegen automation_id()/window_text() abgeglichen."""
|
|
128
|
+
auto_id = kwargs.pop("auto_id", None)
|
|
129
|
+
title = kwargs.pop("title", None)
|
|
130
|
+
matches = window.descendants(cache_enable=True, **kwargs)
|
|
131
|
+
if auto_id is not None:
|
|
132
|
+
matches = [m for m in matches if m.automation_id() == auto_id]
|
|
133
|
+
if title is not None:
|
|
134
|
+
matches = [m for m in matches if m.window_text() == title]
|
|
135
|
+
return matches
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def _find_descendant(cls, window, timeout: float, **kwargs):
|
|
139
|
+
"""Sucht ein Nachfahren-Element per descendants() mit Retry bis timeout, da
|
|
140
|
+
UIAWrapper-Fenster kein eingebautes child_window()/.exists()-Polling kennen."""
|
|
141
|
+
matches = cls._poll(lambda: cls._descendants(window, **kwargs), timeout, interval=0.2)
|
|
142
|
+
return matches[0] if matches else None
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def _find_by_text(cls, window, timeout: float, text: str):
|
|
146
|
+
"""Sucht zuerst per title, dann per auto_id, damit beide Arten sprechender
|
|
147
|
+
UIA-Properties abgedeckt sind."""
|
|
148
|
+
found = cls._find_descendant(window, timeout, title=text)
|
|
149
|
+
if found is not None:
|
|
150
|
+
return found
|
|
151
|
+
return cls._find_descendant(window, timeout, auto_id=text)
|
|
152
|
+
|
|
153
|
+
def text_exists(self, text: str) -> bool:
|
|
154
|
+
"""Prüft UIA-Tree-Existenz von sichtbarem Text (title-Property), keine pixelgenaue
|
|
155
|
+
Sichtbarkeitsprüfung. Technische Fehler laufen ungefangen durch, kein stiller False."""
|
|
156
|
+
self._require_connected()
|
|
157
|
+
return self._find_descendant(self._window(), config.UIA_TIMEOUT, title=text) is not None
|
|
158
|
+
|
|
159
|
+
def tap_text(self, text: str) -> None:
|
|
160
|
+
"""Klickt Text per UIA. Ein echter Miss wirft TextNotFoundError statt RuntimeError,
|
|
161
|
+
damit engine_manager im auto-Modus nur diesen Fall als Fallback behandelt."""
|
|
162
|
+
self._require_connected()
|
|
163
|
+
el = self._find_by_text(self._window(), config.UIA_TIMEOUT, text)
|
|
164
|
+
if el is None:
|
|
165
|
+
raise TextNotFoundError(f"Text nicht gefunden: '{text}'")
|
|
166
|
+
el.click_input()
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _closest_by_point(candidates, ref_x: int, ref_y: int):
|
|
170
|
+
"""Wählt aus candidates das Element mit dem geringsten 2D-Abstand zu (ref_x, ref_y).
|
|
171
|
+
Reine Y-Distanz reicht nicht bei horizontal angeordneten Elementen gleicher Höhe."""
|
|
172
|
+
def dist_sq(el) -> int:
|
|
173
|
+
rect = el.rectangle()
|
|
174
|
+
cx = (rect.left + rect.right) // 2
|
|
175
|
+
cy = (rect.top + rect.bottom) // 2
|
|
176
|
+
return (cx - ref_x) ** 2 + (cy - ref_y) ** 2
|
|
177
|
+
|
|
178
|
+
return min(candidates, key=dist_sq)
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _rect_to_box(rect) -> dict:
|
|
182
|
+
"""UIA-Rectangle in das von spatial.py erwartete Box-Format (left/top/width/height)
|
|
183
|
+
übersetzen. Keine Koordinatenumrechnung nötig, UIA liefert bereits Desktop-Pixel."""
|
|
184
|
+
return {
|
|
185
|
+
"left": rect.left,
|
|
186
|
+
"top": rect.top,
|
|
187
|
+
"width": rect.right - rect.left,
|
|
188
|
+
"height": rect.bottom - rect.top,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def _resolve_editable(cls, el, locator: str):
|
|
193
|
+
"""Löst el auf das tatsächlich editierbare Element auf. Ist el bereits Edit, wird es
|
|
194
|
+
unverändert zurückgegeben, sonst gezielt nach dem einen Edit-Kind im Container gesucht."""
|
|
195
|
+
if el.element_info.control_type == "Edit":
|
|
196
|
+
return el
|
|
197
|
+
children = cls._descendants(el, control_type="Edit")
|
|
198
|
+
if not children:
|
|
199
|
+
raise FieldNotFoundError(
|
|
200
|
+
f"Kein editierbares Child (ControlType=Edit) in Container '{locator}' gefunden."
|
|
201
|
+
)
|
|
202
|
+
if len(children) > 1:
|
|
203
|
+
raise RuntimeError(
|
|
204
|
+
f"Mehrdeutig: {len(children)} editierbare Children in Container '{locator}'."
|
|
205
|
+
)
|
|
206
|
+
return children[0]
|
|
207
|
+
|
|
208
|
+
def _resolve_field_element(self, locator: str, anchor: str | None = None):
|
|
209
|
+
"""Löst locator (+ optionalem anchor) auf das editierbare Zielelement auf; von
|
|
210
|
+
input_text() und field_value() geteilt, damit beide dasselbe Element treffen."""
|
|
211
|
+
window = self._window()
|
|
212
|
+
candidates = self._descendants(window, auto_id=locator)
|
|
213
|
+
if not candidates:
|
|
214
|
+
raise FieldNotFoundError(f"Eingabefeld nicht gefunden: '{locator}'")
|
|
215
|
+
if len(candidates) == 1:
|
|
216
|
+
el = candidates[0]
|
|
217
|
+
elif anchor is not None:
|
|
218
|
+
anchor_el = self._find_by_text(window, config.UIA_TIMEOUT, anchor)
|
|
219
|
+
if anchor_el is None:
|
|
220
|
+
raise FieldNotFoundError(f"Anker-Text nicht gefunden: '{anchor}'")
|
|
221
|
+
rect = anchor_el.rectangle()
|
|
222
|
+
ref_x = (rect.left + rect.right) // 2
|
|
223
|
+
ref_y = (rect.top + rect.bottom) // 2
|
|
224
|
+
el = self._closest_by_point(candidates, ref_x, ref_y)
|
|
225
|
+
else:
|
|
226
|
+
raise RuntimeError(
|
|
227
|
+
f"Mehrdeutig: {len(candidates)} Elemente mit AutomationId='{locator}'. anchor angeben."
|
|
228
|
+
)
|
|
229
|
+
return self._resolve_editable(el, locator)
|
|
230
|
+
|
|
231
|
+
def input_text(self, locator: str, text: str, anchor: str | None = None) -> None:
|
|
232
|
+
"""Setzt den Wert eines per AutomationId gefundenen Feldes (ValuePattern, kein
|
|
233
|
+
Keyboard-Event)."""
|
|
234
|
+
self._require_connected()
|
|
235
|
+
el = self._resolve_field_element(locator, anchor)
|
|
236
|
+
el.set_edit_text(text)
|
|
237
|
+
|
|
238
|
+
def field_value(self, locator: str, anchor: str | None = None) -> str:
|
|
239
|
+
"""Liest den aktuellen Wert eines Feldes per AutomationId. get_value() primär,
|
|
240
|
+
window_text() nur als Fallback (siehe WindowsHost.value_region())."""
|
|
241
|
+
self._require_connected()
|
|
242
|
+
el = self._resolve_field_element(locator, anchor)
|
|
243
|
+
try:
|
|
244
|
+
return el.get_value()
|
|
245
|
+
except Exception:
|
|
246
|
+
return el.window_text()
|
|
247
|
+
|
|
248
|
+
def field_value_should_be(self, locator: str, expected: str, anchor: str | None = None) -> None:
|
|
249
|
+
"""Prüft den tatsächlichen UIA-Feldwert gegen expected. AssertionError mit Ist/Soll
|
|
250
|
+
bei Abweichung."""
|
|
251
|
+
actual = self.field_value(locator, anchor)
|
|
252
|
+
if actual != expected:
|
|
253
|
+
raise AssertionError(
|
|
254
|
+
f"Feldwert von '{locator}': erwartet '{expected}', tatsächlich '{actual}'"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def tap_icon_near_text(self, icon_path: str, text: str) -> None:
|
|
258
|
+
"""Klickt das Element mit AutomationId==icon_path, das 'near' zum Anker-Text liegt.
|
|
259
|
+
Ein echter Miss wirft IconNotFoundError, damit engine_manager im auto-Modus nur
|
|
260
|
+
diesen Fall als Fallback behandelt."""
|
|
261
|
+
self._require_connected()
|
|
262
|
+
window = self._window()
|
|
263
|
+
text_el = self._find_by_text(window, config.UIA_TIMEOUT, text)
|
|
264
|
+
if text_el is None:
|
|
265
|
+
raise IconNotFoundError(f"Anker-Text nicht gefunden: '{text}'")
|
|
266
|
+
|
|
267
|
+
candidates = self._descendants(window, auto_id=icon_path)
|
|
268
|
+
if not candidates:
|
|
269
|
+
raise IconNotFoundError(
|
|
270
|
+
f"Kein Element mit AutomationId='{icon_path}' gefunden. "
|
|
271
|
+
"Für Apps ohne AutomationIds engine='vision' verwenden."
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
anchor_box = self._rect_to_box(text_el.rectangle())
|
|
275
|
+
pairs = [(self._rect_to_box(c.rectangle()), c) for c in candidates]
|
|
276
|
+
candidate_boxes = [box for box, _ in pairs]
|
|
277
|
+
|
|
278
|
+
best = spatial.resolve(
|
|
279
|
+
anchor_box,
|
|
280
|
+
candidate_boxes,
|
|
281
|
+
"near",
|
|
282
|
+
overlap_tol=config.SPATIAL_OVERLAP_TOLERANCE,
|
|
283
|
+
max_distance=config.SPATIAL_NEAR_MAX_DISTANCE,
|
|
284
|
+
)
|
|
285
|
+
if best is None:
|
|
286
|
+
raise IconNotFoundError(
|
|
287
|
+
f"Kein Element mit AutomationId='{icon_path}' erfüllt 'near' zu '{text}' "
|
|
288
|
+
f"(SPATIAL_NEAR_MAX_DISTANCE={config.SPATIAL_NEAR_MAX_DISTANCE})."
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
for box, el in pairs:
|
|
292
|
+
if box == best:
|
|
293
|
+
el.click_input()
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
raise RuntimeError("Interner Fehler: spatial-Treffer konnte keinem UIA-Element zugeordnet werden.")
|
|
297
|
+
|
|
298
|
+
def scroll_to_text(self, text: str) -> bool:
|
|
299
|
+
"""Scrollt per UIA-ScrollPattern bis text sichtbar. Gibt False zurück wenn nach max. Swipes nicht gefunden."""
|
|
300
|
+
self._require_connected()
|
|
301
|
+
window = self._window()
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
if self._find_by_text(window, config.UIA_TIMEOUT, text) is not None:
|
|
305
|
+
return True
|
|
306
|
+
except Exception:
|
|
307
|
+
pass
|
|
308
|
+
|
|
309
|
+
seen_rects = set()
|
|
310
|
+
candidates = []
|
|
311
|
+
for scrollbar in window.descendants(control_type="ScrollBar"):
|
|
312
|
+
try:
|
|
313
|
+
container = scrollbar.parent()
|
|
314
|
+
except Exception:
|
|
315
|
+
continue
|
|
316
|
+
rect = container.rectangle()
|
|
317
|
+
key = (rect.left, rect.top, rect.right, rect.bottom)
|
|
318
|
+
if key in seen_rects:
|
|
319
|
+
continue
|
|
320
|
+
seen_rects.add(key)
|
|
321
|
+
candidates.append(container)
|
|
322
|
+
if not candidates:
|
|
323
|
+
candidates = [window]
|
|
324
|
+
|
|
325
|
+
for candidate in candidates:
|
|
326
|
+
if self._try_scroll_candidate(candidate, window, text):
|
|
327
|
+
return True
|
|
328
|
+
|
|
329
|
+
return False
|
|
330
|
+
|
|
331
|
+
def _try_scroll_candidate(self, candidate, window, text: str) -> bool:
|
|
332
|
+
"""Scrollt candidate bis zu UIA_SCROLL_MAX_SWIPES mal und sucht text nach jedem Schritt.
|
|
333
|
+
False auch wenn candidate kein ScrollPattern hat (nächster Kandidat wird dann versucht)."""
|
|
334
|
+
for _ in range(config.UIA_SCROLL_MAX_SWIPES):
|
|
335
|
+
try:
|
|
336
|
+
candidate.scroll("down", "page")
|
|
337
|
+
except Exception:
|
|
338
|
+
return False
|
|
339
|
+
|
|
340
|
+
time.sleep(0.2)
|
|
341
|
+
try:
|
|
342
|
+
if self._find_by_text(window, 1, text) is not None:
|
|
343
|
+
return True
|
|
344
|
+
except Exception:
|
|
345
|
+
pass
|
|
346
|
+
|
|
347
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from robot.api.deco import keyword
|
|
2
|
+
|
|
3
|
+
from automation.engines.engine_manager import EngineManager
|
|
4
|
+
|
|
5
|
+
_manager = EngineManager()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AutomationKeywords:
|
|
9
|
+
ROBOT_LIBRARY_SCOPE = "GLOBAL"
|
|
10
|
+
|
|
11
|
+
@keyword
|
|
12
|
+
def connect_device(self, device_id: str | None = None, platform: str = "android") -> None:
|
|
13
|
+
_manager.connect_device(device_id, platform=platform)
|
|
14
|
+
|
|
15
|
+
@keyword
|
|
16
|
+
def install_app(self, apk_path: str) -> None:
|
|
17
|
+
_manager.install_app(apk_path)
|
|
18
|
+
|
|
19
|
+
@keyword
|
|
20
|
+
def launch_app(self, package: str, activity: str | None = None, fullscreen: bool = False) -> None:
|
|
21
|
+
"""fullscreen: nur auf Windows wirksam, maximiert das Fenster nach dem Start."""
|
|
22
|
+
_manager.launch_app(package, activity=activity, fullscreen=fullscreen)
|
|
23
|
+
|
|
24
|
+
@keyword
|
|
25
|
+
def close_app(self) -> None:
|
|
26
|
+
"""Nur auf Windows verfügbar, beendet die aktuell verbundene App PID-scoped."""
|
|
27
|
+
_manager.close_app()
|
|
28
|
+
|
|
29
|
+
@keyword
|
|
30
|
+
def tap_text(self, text: str, engine: str = "auto") -> None:
|
|
31
|
+
_manager.tap_text(text, engine=engine)
|
|
32
|
+
|
|
33
|
+
@keyword
|
|
34
|
+
def input_text(self, locator: str, text: str, anchor: str | None = None,
|
|
35
|
+
engine: str = "auto") -> None:
|
|
36
|
+
"""anchor: optionale Disambiguierung bei mehreren Elementen mit derselben AutomationId
|
|
37
|
+
(Abstand zum Anker-Text). Gilt nur für engine=uia, bei engine=vision wird er ignoriert."""
|
|
38
|
+
_manager.input_text(locator, text, anchor=anchor, engine=engine)
|
|
39
|
+
|
|
40
|
+
@keyword
|
|
41
|
+
def tap_icon_near_text(self, icon_path: str, text: str, engine: str = "auto") -> None:
|
|
42
|
+
_manager.tap_icon_near_text(icon_path, text, engine=engine)
|
|
43
|
+
|
|
44
|
+
@keyword
|
|
45
|
+
def field_value_should_be(self, locator: str, expected: str, anchor: str | None = None,
|
|
46
|
+
engine: str = "uia") -> None:
|
|
47
|
+
"""Prüft den tatsächlichen UIA-Feldwert (ValuePattern) gegen expected. Windows-only,
|
|
48
|
+
kein Vision-Pfad, kein engine=auto."""
|
|
49
|
+
_manager.field_value_should_be(locator, expected, anchor=anchor, engine=engine)
|
|
50
|
+
|
|
51
|
+
@keyword
|
|
52
|
+
def scroll_to_text(self, text: str, anchor: str | None = None, relation: str = "near",
|
|
53
|
+
engine: str = "auto") -> bool:
|
|
54
|
+
"""anchor/relation disambiguieren bei Duplikat-Text und verschieben die Scroll-Region
|
|
55
|
+
weg von der Bildschirmmitte. Gelten nur für engine=vision, siehe Find Element."""
|
|
56
|
+
return _manager.scroll_to_text(text, anchor=anchor, relation=relation, engine=engine)
|
|
57
|
+
|
|
58
|
+
@keyword
|
|
59
|
+
def text_should_exist(self, text: str, engine: str = "auto") -> None:
|
|
60
|
+
# Kein Error-Screenshot: läuft als Poll-Primitiv (Wait Until Keyword Succeeds). Siehe BP-NEG-SCREENSHOT.
|
|
61
|
+
if not _manager.text_exists(text, engine=engine):
|
|
62
|
+
raise AssertionError(f"Text not found: '{text}'")
|
|
63
|
+
|
|
64
|
+
@keyword
|
|
65
|
+
def take_screenshot(self, name: str = "screenshot") -> str:
|
|
66
|
+
return _manager.take_screenshot(name)
|
|
67
|
+
|
|
68
|
+
@keyword
|
|
69
|
+
def move_mouse(self, x: int, y: int) -> None:
|
|
70
|
+
_manager.move_mouse(x, y)
|
|
71
|
+
|
|
72
|
+
@keyword
|
|
73
|
+
def find_element(self, target: str, anchor: str, relation: str = "near",
|
|
74
|
+
kind: str = "auto", engine: str = "auto") -> tuple[int, int]:
|
|
75
|
+
"""Lokalisiert ein Ziel live relativ zu einem Anker-Text und gibt (x, y) zurück.
|
|
76
|
+
target: Text oder .png (Custom Element) · relation: near|left_of|right_of|above|below · kind: auto|text|icon."""
|
|
77
|
+
return _manager.find_element(target, anchor, relation=relation, kind=kind, engine=engine)
|
|
78
|
+
|
|
79
|
+
@keyword
|
|
80
|
+
def tap_element(self, target: str, anchor: str, relation: str = "near",
|
|
81
|
+
kind: str = "auto", engine: str = "auto") -> None:
|
|
82
|
+
"""Tippt ein Ziel, das live relativ zu einem Anker-Text lokalisiert wird (siehe Find Element)."""
|
|
83
|
+
_manager.tap_element(target, anchor, relation=relation, kind=kind, engine=engine)
|
|
File without changes
|
automation/utils/adb.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import warnings
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from automation import config
|
|
13
|
+
from automation.utils.device_interface import DeviceInterface
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_device_id: str | None = None
|
|
18
|
+
# Session-konstante Werte memoisieren: App-Version und Geräte-Fingerprint ändern sich
|
|
19
|
+
# innerhalb eines Laufs nicht, die Activity dagegen pro Aktion.
|
|
20
|
+
_app_version_cache: dict[str, str] = {}
|
|
21
|
+
_device_fp_cache: str | None = None
|
|
22
|
+
|
|
23
|
+
def _run(cmd: list[str], timeout: int | None = None) -> subprocess.CompletedProcess:
|
|
24
|
+
prefix = ["-s", _device_id] if _device_id else []
|
|
25
|
+
return subprocess.run(
|
|
26
|
+
["adb"] + prefix + cmd,
|
|
27
|
+
capture_output=True,
|
|
28
|
+
timeout=timeout if timeout is not None else config.ADB_TIMEOUT,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def _list_devices() -> list[str]:
|
|
32
|
+
result = subprocess.run(
|
|
33
|
+
["adb", "devices"],
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
)
|
|
37
|
+
return result.stdout.splitlines()
|
|
38
|
+
|
|
39
|
+
def connect(device_id: str | None = None) -> None:
|
|
40
|
+
global _device_id
|
|
41
|
+
|
|
42
|
+
if device_id:
|
|
43
|
+
# TCP/IP Device (z. B. 192.168.0.10:5555)
|
|
44
|
+
if ":" in device_id:
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
["adb", "connect", device_id],
|
|
47
|
+
capture_output=True,
|
|
48
|
+
text=True,
|
|
49
|
+
)
|
|
50
|
+
if "connected" not in result.stdout:
|
|
51
|
+
raise RuntimeError(f"ADB connect failed: {result.stdout.strip()}")
|
|
52
|
+
else:
|
|
53
|
+
# USB Device prüfen
|
|
54
|
+
device_lines = [ln for ln in _list_devices() if ln.startswith(device_id + "\t")]
|
|
55
|
+
if not device_lines or not any("\tdevice" in ln for ln in device_lines):
|
|
56
|
+
status = device_lines[0].split("\t")[1] if device_lines else "not found"
|
|
57
|
+
raise RuntimeError(f"Device '{device_id}' not ready: {status}")
|
|
58
|
+
|
|
59
|
+
_device_id = device_id
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
lines = [
|
|
63
|
+
line for line in _list_devices()
|
|
64
|
+
if "device" in line and "List" not in line
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
if not lines:
|
|
68
|
+
raise RuntimeError(f"Device not found: {device_id or '(any)'}")
|
|
69
|
+
|
|
70
|
+
if len(lines) > 1:
|
|
71
|
+
raise RuntimeError(
|
|
72
|
+
"Multiple ADB devices detected. Please set ANDROID_DEVICE_ID explicitly."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Genau ein Device -> automatisch verwenden
|
|
76
|
+
detected_device = lines[0].split()[0]
|
|
77
|
+
_device_id = detected_device
|
|
78
|
+
|
|
79
|
+
warnings.warn(f"No device_id provided. Using detected device: {detected_device}")
|
|
80
|
+
logger.info("connect event=auto_detect device_id=%r", detected_device)
|
|
81
|
+
|
|
82
|
+
def screenshot() -> np.ndarray:
|
|
83
|
+
import cv2
|
|
84
|
+
import numpy as np
|
|
85
|
+
result = _run(["exec-out", "screencap", "-p"], timeout=config.ADB_SCREENSHOT_TIMEOUT)
|
|
86
|
+
if result.returncode != 0:
|
|
87
|
+
raise RuntimeError(
|
|
88
|
+
f"Screenshot failed (returncode={result.returncode}): {result.stderr.decode().strip()}"
|
|
89
|
+
)
|
|
90
|
+
arr = np.frombuffer(result.stdout, np.uint8)
|
|
91
|
+
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
|
92
|
+
if img is None:
|
|
93
|
+
raise RuntimeError("Screenshot failed, image decode returned None")
|
|
94
|
+
return img
|
|
95
|
+
|
|
96
|
+
def tap(x: int, y: int) -> None:
|
|
97
|
+
result = _run(["shell", "input", "tap", str(x), str(y)])
|
|
98
|
+
if result.returncode != 0:
|
|
99
|
+
raise RuntimeError(f"tap({x}, {y}) failed: {result.stderr.decode().strip()}")
|
|
100
|
+
|
|
101
|
+
def swipe(x1: int, y1: int, x2: int, y2: int, duration_ms: int | None = None) -> None:
|
|
102
|
+
if duration_ms is None:
|
|
103
|
+
duration_ms = config.ADB_SWIPE_DURATION_MS
|
|
104
|
+
result = _run(["shell", "input", "swipe", str(x1), str(y1), str(x2), str(y2), str(duration_ms)])
|
|
105
|
+
if result.returncode != 0:
|
|
106
|
+
raise RuntimeError(f"swipe failed: {result.stderr.decode().strip()}")
|
|
107
|
+
|
|
108
|
+
def type_text(text: str) -> None:
|
|
109
|
+
# 'adb shell input text' ersetzt Leerzeichen durch %s. Nur Leerzeichen werden behandelt;
|
|
110
|
+
# Sonderzeichen-Escaping ist für den PoC bewusst nicht abgedeckt (Showcase-Strings sind kontrolliert).
|
|
111
|
+
escaped = text.replace(" ", "%s")
|
|
112
|
+
result = _run(["shell", "input", "text", escaped])
|
|
113
|
+
if result.returncode != 0:
|
|
114
|
+
raise RuntimeError(f"type_text failed: {result.stderr.decode().strip()}")
|
|
115
|
+
|
|
116
|
+
def _activity_token(segment: str) -> str | None:
|
|
117
|
+
"""Erstes Token in segment im Format 'com.pkg/.Activity', None wenn keins vorhanden."""
|
|
118
|
+
for tok in segment.split():
|
|
119
|
+
if "/" in tok and "." in tok:
|
|
120
|
+
return tok.strip("{}")
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def current_activity() -> str:
|
|
124
|
+
"""Aktuell sichtbare Activity als 'package/activity' (z. B. 'com.android.settings/.Settings'),
|
|
125
|
+
Leerstring wenn keine gefunden. Quelle: 'dumpsys activity activities', ohne uiautomator2."""
|
|
126
|
+
result = _run(["shell", "dumpsys", "activity", "activities"])
|
|
127
|
+
text = result.stdout.decode(errors="ignore")
|
|
128
|
+
# Feldname variiert je Android-Version.
|
|
129
|
+
for marker in ("topResumedActivity=", "mResumedActivity=", "ResumedActivity:"):
|
|
130
|
+
idx = text.find(marker)
|
|
131
|
+
if idx == -1:
|
|
132
|
+
continue
|
|
133
|
+
# Format: ...ActivityRecord{<hash> u0 com.pkg/.Activity tNNN}
|
|
134
|
+
token = _activity_token(text[idx:idx + 300])
|
|
135
|
+
if token is not None:
|
|
136
|
+
return token
|
|
137
|
+
return ""
|
|
138
|
+
|
|
139
|
+
def app_version(package: str) -> str:
|
|
140
|
+
"""versionName der installierten App. Session-konstant -> memoisiert pro Package."""
|
|
141
|
+
if package in _app_version_cache:
|
|
142
|
+
logger.debug("ADB_CACHE event=hit keyword=app_version package=%r", package)
|
|
143
|
+
return _app_version_cache[package]
|
|
144
|
+
result = _run(["shell", "dumpsys", "package", package])
|
|
145
|
+
version = ""
|
|
146
|
+
for line in result.stdout.decode(errors="ignore").splitlines():
|
|
147
|
+
line = line.strip()
|
|
148
|
+
if line.startswith("versionName="):
|
|
149
|
+
version = line.split("=", 1)[1].strip()
|
|
150
|
+
break
|
|
151
|
+
_app_version_cache[package] = version
|
|
152
|
+
logger.info("ADB_CACHE event=miss keyword=app_version package=%r version=%r", package, version)
|
|
153
|
+
return version
|
|
154
|
+
|
|
155
|
+
def _first_value_after(output: str, marker: str) -> str | None:
|
|
156
|
+
"""Wert der ersten Zeile, die marker enthält (Format 'marker: wert'), None wenn nicht gefunden."""
|
|
157
|
+
for line in output.splitlines():
|
|
158
|
+
if marker in line:
|
|
159
|
+
return line.split(":", 1)[1].strip()
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
def device_fingerprint() -> str:
|
|
163
|
+
"""Geräte-Identität 'id_BxH_density_orientation' für den Screen-Key (damit der Cache nicht
|
|
164
|
+
über Geräte/Auflösungen hinweg falsch trifft). Session-konstant -> memoisiert."""
|
|
165
|
+
global _device_fp_cache
|
|
166
|
+
if _device_fp_cache is not None:
|
|
167
|
+
logger.debug("ADB_CACHE event=hit keyword=device_fingerprint")
|
|
168
|
+
return _device_fp_cache
|
|
169
|
+
size_out = _run(["shell", "wm", "size"]).stdout.decode(errors="ignore")
|
|
170
|
+
dens_out = _run(["shell", "wm", "density"]).stdout.decode(errors="ignore")
|
|
171
|
+
size_val = _first_value_after(size_out, "Physical size:")
|
|
172
|
+
w, h = (int(p) for p in size_val.split("x")[:2]) if size_val and "x" in size_val else (0, 0)
|
|
173
|
+
dens = _first_value_after(dens_out, "Physical density:") or ""
|
|
174
|
+
orientation = "portrait" if h >= w else "landscape"
|
|
175
|
+
_device_fp_cache = f"{_device_id or ''}_{w}x{h}_{dens}_{orientation}"
|
|
176
|
+
logger.info("ADB_CACHE event=miss keyword=device_fingerprint fingerprint=%r", _device_fp_cache)
|
|
177
|
+
return _device_fp_cache
|
|
178
|
+
|
|
179
|
+
def install_app(apk_path: str = "") -> None:
|
|
180
|
+
if not apk_path:
|
|
181
|
+
apk_path = config.APK_PATH
|
|
182
|
+
if not os.path.exists(apk_path):
|
|
183
|
+
raise FileNotFoundError(f"APK not found: {apk_path}")
|
|
184
|
+
|
|
185
|
+
prefix = ["-s", _device_id] if _device_id else []
|
|
186
|
+
result = subprocess.run(
|
|
187
|
+
["adb"] + prefix + ["install", "-r", apk_path],
|
|
188
|
+
capture_output=True,
|
|
189
|
+
)
|
|
190
|
+
if result.returncode != 0:
|
|
191
|
+
raise RuntimeError(f"Installation failed: {result.stderr.decode().strip()}")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class AdbDevice(DeviceInterface):
|
|
195
|
+
"""DeviceInterface-Implementierung für Android über ADB."""
|
|
196
|
+
|
|
197
|
+
def screenshot(self) -> np.ndarray:
|
|
198
|
+
return screenshot()
|
|
199
|
+
|
|
200
|
+
def tap(self, x: int, y: int) -> None:
|
|
201
|
+
tap(x, y)
|
|
202
|
+
|
|
203
|
+
def type_text(self, text: str) -> None:
|
|
204
|
+
type_text(text)
|
|
205
|
+
|
|
206
|
+
def scroll(self, x: int, y: int, amount: int, duration_ms: int | None = None) -> None:
|
|
207
|
+
swipe(x, y + amount // 2, x, y - amount // 2, duration_ms)
|
|
208
|
+
|
|
209
|
+
def connect(self, target: str | None = None) -> None:
|
|
210
|
+
connect(target)
|
|
211
|
+
|
|
212
|
+
def screen_key(self) -> tuple[str, str, str, str] | None:
|
|
213
|
+
try:
|
|
214
|
+
activity = current_activity()
|
|
215
|
+
if not activity or "/" not in activity:
|
|
216
|
+
return None
|
|
217
|
+
package = activity.split("/", 1)[0]
|
|
218
|
+
return package, activity, app_version(package), device_fingerprint()
|
|
219
|
+
except Exception:
|
|
220
|
+
return None
|