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,354 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from automation import config
|
|
11
|
+
from automation.utils.device_interface import DeviceInterface
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WindowsHost(DeviceInterface):
|
|
17
|
+
"""DeviceInterface-Implementierung für Windows Desktop über pywinauto und PIL."""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self._process_name: str | None = None
|
|
21
|
+
self._pid: int | None = None
|
|
22
|
+
self._app = None # pywinauto.Application
|
|
23
|
+
|
|
24
|
+
def connect(self, target: str | None = None, process_name: str | None = None) -> None:
|
|
25
|
+
"""Verbindet mit einem laufenden Windows-Prozess per PID, Prozessname oder Fenstertitel.
|
|
26
|
+
Einziger Ort, der das pywinauto.Application-Handle erzeugt."""
|
|
27
|
+
from pywinauto import Application # lazy: nur auf Windows installierbar
|
|
28
|
+
|
|
29
|
+
if target is None:
|
|
30
|
+
raise ValueError("WindowsHost.connect erwartet Prozessname, PID oder Fenstertitel.")
|
|
31
|
+
|
|
32
|
+
# DPI-Awareness setzen, damit Screenshot-Koordinaten und Tap-Koordinaten übereinstimmen.
|
|
33
|
+
# Bei Display-Scaling (z. B. 150%) weichen PIL-Pixel und pywinauto-Coords sonst ab.
|
|
34
|
+
# Muss auf Hyper-V Windows 11 verifiziert werden.
|
|
35
|
+
try:
|
|
36
|
+
import ctypes
|
|
37
|
+
ctypes.windll.shcore.SetProcessDpiAwareness(2)
|
|
38
|
+
except Exception:
|
|
39
|
+
try:
|
|
40
|
+
import ctypes
|
|
41
|
+
ctypes.windll.user32.SetProcessDPIAware()
|
|
42
|
+
except Exception:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
# PID direkt angegeben
|
|
46
|
+
try:
|
|
47
|
+
pid = int(target)
|
|
48
|
+
try:
|
|
49
|
+
self._app = Application(backend="uia").connect(process=pid)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
raise RuntimeError(f"Prozess mit PID '{pid}' nicht erreichbar: {e}") from e
|
|
52
|
+
self._pid = pid
|
|
53
|
+
self._process_name = process_name or target
|
|
54
|
+
return
|
|
55
|
+
except ValueError:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
# Prozessname (*.exe) → PID via tasklist (Windows Built-in)
|
|
59
|
+
if target.lower().endswith(".exe"):
|
|
60
|
+
result = subprocess.run(
|
|
61
|
+
["tasklist", "/fi", f"IMAGENAME eq {target}", "/fo", "csv", "/nh"],
|
|
62
|
+
capture_output=True,
|
|
63
|
+
encoding="oem",
|
|
64
|
+
errors="replace",
|
|
65
|
+
)
|
|
66
|
+
matches = [ln for ln in result.stdout.strip().splitlines() if target.lower() in ln.lower()]
|
|
67
|
+
if not matches:
|
|
68
|
+
raise RuntimeError(f"Prozess '{target}' nicht gefunden.")
|
|
69
|
+
# CSV-Spalten: "Name","PID","Session","SessionNr","Speicher"
|
|
70
|
+
pid = int(matches[0].split('","')[1])
|
|
71
|
+
try:
|
|
72
|
+
self._app = Application(backend="uia").connect(process=pid)
|
|
73
|
+
except Exception as e:
|
|
74
|
+
raise RuntimeError(f"Prozess '{target}' (PID {pid}) nicht erreichbar: {e}") from e
|
|
75
|
+
self._pid = pid
|
|
76
|
+
self._process_name = process_name or target
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
# Fenstertitel → best_match
|
|
80
|
+
try:
|
|
81
|
+
self._app = Application(backend="uia").connect(best_match=target)
|
|
82
|
+
except Exception as e:
|
|
83
|
+
raise RuntimeError(f"Fenster mit Titel-Match '{target}' nicht gefunden: {e}") from e
|
|
84
|
+
self._process_name = process_name or target
|
|
85
|
+
try:
|
|
86
|
+
self._pid = self._app.top_window().process_id()
|
|
87
|
+
except Exception:
|
|
88
|
+
self._pid = None
|
|
89
|
+
|
|
90
|
+
def close(self) -> None:
|
|
91
|
+
"""Beendet die verbundene App PID-scoped statt per Exe-Name, damit andere laufende
|
|
92
|
+
Instanzen desselben Exe-Namens unangetastet bleiben."""
|
|
93
|
+
if self._pid is None:
|
|
94
|
+
raise RuntimeError("WindowsHost.close: keine verbundene App (keine PID bekannt).")
|
|
95
|
+
subprocess.run(["taskkill", "/f", "/pid", str(self._pid)], capture_output=True)
|
|
96
|
+
self._app = None
|
|
97
|
+
self._pid = None
|
|
98
|
+
self._process_name = None
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def app(self):
|
|
102
|
+
"""Das verbundene pywinauto.Application-Handle, None wenn (noch) nicht verbunden.
|
|
103
|
+
Einzige Stelle, über die WindowsUiaEngine auf die Verbindung zugreift."""
|
|
104
|
+
return self._app
|
|
105
|
+
|
|
106
|
+
def window(self):
|
|
107
|
+
"""Kanonische Fensterauswahl für UIA und Vision: das flächengrößte sichtbare
|
|
108
|
+
Top-Level-Fenster der verbundenen App, robuster als das Z-Order-basierte top_window()."""
|
|
109
|
+
windows = self._app.windows(visible_only=True)
|
|
110
|
+
if not windows:
|
|
111
|
+
return self._app.top_window()
|
|
112
|
+
|
|
113
|
+
return max(windows, key=lambda w: _rect_area(w.rectangle()))
|
|
114
|
+
|
|
115
|
+
def _window_rect(self) -> tuple[int, int, int, int]:
|
|
116
|
+
"""Fensterrechteck des per window() gewählten App-Fensters in Desktop-Koordinaten.
|
|
117
|
+
Loggt nur eine Warnung, wenn das Fenster außerhalb des Primärmonitors liegt."""
|
|
118
|
+
rect = self.window().rectangle()
|
|
119
|
+
left, top, right, bottom = rect.left, rect.top, rect.right, rect.bottom
|
|
120
|
+
|
|
121
|
+
import ctypes
|
|
122
|
+
user32 = ctypes.windll.user32
|
|
123
|
+
primary_w = user32.GetSystemMetrics(0)
|
|
124
|
+
primary_h = user32.GetSystemMetrics(1)
|
|
125
|
+
if left < 0 or top < 0 or right > primary_w or bottom > primary_h:
|
|
126
|
+
logger.warning(
|
|
127
|
+
"WindowsHost: App-Fenster (%s, %s, %s, %s) liegt außerhalb des Primärmonitors "
|
|
128
|
+
"(%sx%s). Screenshot/Koordinaten-Mapping sind nur für den Primärmonitor verifiziert.",
|
|
129
|
+
left, top, right, bottom, primary_w, primary_h,
|
|
130
|
+
)
|
|
131
|
+
return left, top, right, bottom
|
|
132
|
+
|
|
133
|
+
def _to_desktop(self, x: int, y: int) -> tuple[int, int]:
|
|
134
|
+
"""Rechnet Fenster-relative Koordinaten auf absolute Desktop-Koordinaten zurück, ohne
|
|
135
|
+
Verbindung unverändert (Vollbild-Fallback). Live-Lookup pro Aufruf, kein Caching."""
|
|
136
|
+
if self._app is None:
|
|
137
|
+
return x, y
|
|
138
|
+
try:
|
|
139
|
+
left, top, _, _ = self._window_rect()
|
|
140
|
+
except Exception as e:
|
|
141
|
+
raise RuntimeError(f"Fensterrechteck für Koordinaten-Mapping nicht ermittelbar: {e}") from e
|
|
142
|
+
return x + left, y + top
|
|
143
|
+
|
|
144
|
+
def screenshot(self) -> np.ndarray:
|
|
145
|
+
"""Screenshot via PIL.ImageGrab, gecroppt auf das verbundene App-Fenster. Ohne Verbindung
|
|
146
|
+
oder bei nicht ermittelbarem Fensterrechteck: Vollbild-Fallback."""
|
|
147
|
+
import cv2
|
|
148
|
+
import numpy as np
|
|
149
|
+
from PIL import ImageGrab # lazy: kein Display auf Linux-Devcontainer
|
|
150
|
+
|
|
151
|
+
bbox = None
|
|
152
|
+
if self._app is not None:
|
|
153
|
+
try:
|
|
154
|
+
bbox = self._window_rect()
|
|
155
|
+
except Exception:
|
|
156
|
+
bbox = None
|
|
157
|
+
|
|
158
|
+
img = ImageGrab.grab(bbox=bbox, all_screens=True)
|
|
159
|
+
arr = np.array(img)
|
|
160
|
+
return cv2.cvtColor(arr, cv2.COLOR_RGB2BGR)
|
|
161
|
+
|
|
162
|
+
def tap(self, x: int, y: int) -> None:
|
|
163
|
+
"""Linksklick an Fenster-relativen Koordinaten (Ursprung des Screenshots), intern über
|
|
164
|
+
_to_desktop() auf absolute Bildschirmkoordinaten zurückgerechnet."""
|
|
165
|
+
from pywinauto import mouse # lazy: Windows-only
|
|
166
|
+
|
|
167
|
+
dx, dy = self._to_desktop(x, y)
|
|
168
|
+
mouse.click(button="left", coords=(dx, dy))
|
|
169
|
+
|
|
170
|
+
def move_mouse(self, x: int, y: int) -> None:
|
|
171
|
+
"""Bewegt den Mauszeiger an Fenster-relative Koordinaten (Ursprung des Screenshots),
|
|
172
|
+
intern über _to_desktop() zurückgerechnet, ohne zu klicken."""
|
|
173
|
+
from pywinauto import mouse # lazy: Windows-only
|
|
174
|
+
|
|
175
|
+
dx, dy = self._to_desktop(x, y)
|
|
176
|
+
mouse.move(coords=(dx, dy))
|
|
177
|
+
|
|
178
|
+
def type_text(self, text: str) -> None:
|
|
179
|
+
"""Tippt literalen Text in das fokussierte Element via pywinauto. Sonderzeichen der
|
|
180
|
+
send_keys-Syntax werden vor dem Senden escaped, damit sie als Literaltext ankommen."""
|
|
181
|
+
from pywinauto.keyboard import send_keys # lazy: Windows-only
|
|
182
|
+
|
|
183
|
+
send_keys(_escape_send_keys(text), with_spaces=True)
|
|
184
|
+
|
|
185
|
+
def focused_process_id(self) -> int | None:
|
|
186
|
+
"""PID des Fensters mit aktuellem Eingabefokus, None ohne Vordergrundfenster.
|
|
187
|
+
restype/argtypes explizit gesetzt, sonst schneidet ctypes das HWND auf 64-Bit ab."""
|
|
188
|
+
import ctypes
|
|
189
|
+
|
|
190
|
+
user32 = ctypes.windll.user32
|
|
191
|
+
user32.GetForegroundWindow.restype = ctypes.c_void_p
|
|
192
|
+
user32.GetWindowThreadProcessId.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulong)]
|
|
193
|
+
|
|
194
|
+
hwnd = user32.GetForegroundWindow()
|
|
195
|
+
if not hwnd:
|
|
196
|
+
return None
|
|
197
|
+
pid = ctypes.c_ulong()
|
|
198
|
+
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
|
199
|
+
return pid.value or None
|
|
200
|
+
|
|
201
|
+
def verify_focus_in_target(self) -> bool:
|
|
202
|
+
"""Prüft, ob der Eingabefokus im verbundenen Zielprozess liegt. Ohne Verbindung oder
|
|
203
|
+
bei technisch nicht ermittelbarem Foreground-Window bleibt die Rückgabe tolerant (True)."""
|
|
204
|
+
if self._app is None or self._pid is None:
|
|
205
|
+
return True
|
|
206
|
+
foreground_pid = self.focused_process_id()
|
|
207
|
+
if foreground_pid is None:
|
|
208
|
+
return True
|
|
209
|
+
return foreground_pid == self._pid
|
|
210
|
+
|
|
211
|
+
def active_region(self) -> tuple[int, int, int, int] | None:
|
|
212
|
+
"""Ausdehnung des aktuell obersten (Z-Order) App-Fensters in Screenshot-relativen
|
|
213
|
+
Koordinaten, None wenn kein Overlay über dem Hauptfenster liegt oder nicht verbunden."""
|
|
214
|
+
if self._app is None:
|
|
215
|
+
return None
|
|
216
|
+
try:
|
|
217
|
+
main = self.window()
|
|
218
|
+
top = self._app.top_window()
|
|
219
|
+
except Exception:
|
|
220
|
+
return None
|
|
221
|
+
if top.handle == main.handle:
|
|
222
|
+
return None
|
|
223
|
+
main_rect = main.rectangle()
|
|
224
|
+
top_rect = top.rectangle()
|
|
225
|
+
return (
|
|
226
|
+
top_rect.left - main_rect.left,
|
|
227
|
+
top_rect.top - main_rect.top,
|
|
228
|
+
top_rect.right - main_rect.left,
|
|
229
|
+
top_rect.bottom - main_rect.top,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def value_region(self, text: str) -> tuple[int, int, int, int] | None:
|
|
233
|
+
"""Ausdehnung des einen Edit-Elements im Hauptfenster, dessen aktueller Wert exakt
|
|
234
|
+
text entspricht, in window()-relativen Koordinaten. None bei 0/>1 Kandidaten."""
|
|
235
|
+
if self._app is None:
|
|
236
|
+
return None
|
|
237
|
+
try:
|
|
238
|
+
main = self.window()
|
|
239
|
+
edits = main.descendants(control_type="Edit", cache_enable=True)
|
|
240
|
+
matches = []
|
|
241
|
+
for el in edits:
|
|
242
|
+
try:
|
|
243
|
+
value = el.get_value()
|
|
244
|
+
except Exception:
|
|
245
|
+
value = el.window_text()
|
|
246
|
+
if value.strip() == text.strip():
|
|
247
|
+
matches.append(el)
|
|
248
|
+
except Exception:
|
|
249
|
+
return None
|
|
250
|
+
if len(matches) != 1:
|
|
251
|
+
return None
|
|
252
|
+
main_rect = main.rectangle()
|
|
253
|
+
rect = matches[0].rectangle()
|
|
254
|
+
return (
|
|
255
|
+
rect.left - main_rect.left,
|
|
256
|
+
rect.top - main_rect.top,
|
|
257
|
+
rect.right - main_rect.left,
|
|
258
|
+
rect.bottom - main_rect.top,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def clear_focused_field(self, x: int, y: int) -> None:
|
|
262
|
+
"""Leert das Feld an (x, y) und prüft anschließend, ob es wirklich leer ist."""
|
|
263
|
+
from pywinauto.keyboard import send_keys # lazy: Windows-only
|
|
264
|
+
import time
|
|
265
|
+
|
|
266
|
+
send_keys("{HOME}+{END}{DELETE}", with_spaces=True)
|
|
267
|
+
time.sleep(config.WINDOWS_CLEAR_FIELD_DELAY)
|
|
268
|
+
|
|
269
|
+
dx, dy = self._to_desktop(x, y)
|
|
270
|
+
main = self.window()
|
|
271
|
+
candidates = [
|
|
272
|
+
el for el in main.descendants(control_type="Edit", cache_enable=True)
|
|
273
|
+
if _rect_contains(el.rectangle(), dx, dy)
|
|
274
|
+
]
|
|
275
|
+
if not candidates:
|
|
276
|
+
raise RuntimeError(f"Kein Edit-Element an Position ({x}, {y}) für Lösch-Verifikation.")
|
|
277
|
+
el = min(candidates, key=lambda c: _rect_area(c.rectangle()))
|
|
278
|
+
try:
|
|
279
|
+
value = el.get_value()
|
|
280
|
+
except Exception:
|
|
281
|
+
value = el.window_text()
|
|
282
|
+
if value.strip():
|
|
283
|
+
raise RuntimeError(f"Feld an ({x}, {y}) nach Löschversuch nicht leer: '{value}'.")
|
|
284
|
+
|
|
285
|
+
def scroll(self, x: int, y: int, amount: int, duration_ms: int | None = None) -> None:
|
|
286
|
+
"""Mausrad-Scroll an Fenster-relativen Koordinaten (x, y). Skaliert über eine Schleife
|
|
287
|
+
aus Einzel-Notches, nicht über einen großen wheel_dist-Wert."""
|
|
288
|
+
from pywinauto import mouse # lazy: Windows-only
|
|
289
|
+
import time
|
|
290
|
+
|
|
291
|
+
dx, dy = self._to_desktop(x, y)
|
|
292
|
+
direction = -1 if amount > 0 else 1
|
|
293
|
+
for _ in range(config.WINDOWS_SCROLL_NOTCHES):
|
|
294
|
+
mouse.scroll(coords=(dx, dy), wheel_dist=direction)
|
|
295
|
+
time.sleep(config.WINDOWS_SCROLL_EVENT_DELAY)
|
|
296
|
+
|
|
297
|
+
def screen_key(self) -> tuple[str, str, str, str] | None:
|
|
298
|
+
"""Stabile Screen-Identität: Prozessname, Fenstertitel, Exe-Version, Auflösung.
|
|
299
|
+
None bei Fehler → Cache wird übersprungen."""
|
|
300
|
+
try:
|
|
301
|
+
if self._app is None or self._process_name is None:
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
title = self._app.top_window().window_text()
|
|
305
|
+
version = _exe_version(self._pid)
|
|
306
|
+
|
|
307
|
+
import ctypes
|
|
308
|
+
user32 = ctypes.windll.user32
|
|
309
|
+
w = user32.GetSystemMetrics(0)
|
|
310
|
+
h = user32.GetSystemMetrics(1)
|
|
311
|
+
resolution = f"{w}x{h}"
|
|
312
|
+
|
|
313
|
+
return self._process_name, title, version, resolution
|
|
314
|
+
except Exception:
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _rect_contains(rect, x: int, y: int) -> bool:
|
|
319
|
+
"""Prüft, ob (x, y) innerhalb von rect liegt."""
|
|
320
|
+
return rect.left <= x <= rect.right and rect.top <= y <= rect.bottom
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _rect_area(rect) -> int:
|
|
324
|
+
"""Fläche von rect."""
|
|
325
|
+
return max(0, rect.right - rect.left) * max(0, rect.bottom - rect.top)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
_SEND_KEYS_SPECIAL_CHARS = "+^%~(){}"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _escape_send_keys(text: str) -> str:
|
|
332
|
+
"""Escaped die Sonderzeichen der pywinauto-send_keys-Syntax als Literal. Zeichenweise über
|
|
333
|
+
den Original-Text, nicht per verketteten str.replace(), sonst doppeltes Escaping."""
|
|
334
|
+
return "".join(f"{{{ch}}}" if ch in _SEND_KEYS_SPECIAL_CHARS else ch for ch in text)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _exe_version(pid: int | None) -> str:
|
|
338
|
+
"""Exe-Versionsnummer eines Prozesses via PowerShell. Leerstring bei Fehler."""
|
|
339
|
+
if pid is None:
|
|
340
|
+
return ""
|
|
341
|
+
try:
|
|
342
|
+
script = (
|
|
343
|
+
f"$p = Get-Process -Id {pid} -ErrorAction SilentlyContinue; "
|
|
344
|
+
f"if ($p) {{ $p.MainModule.FileVersionInfo.FileVersion }} else {{ '' }}"
|
|
345
|
+
)
|
|
346
|
+
result = subprocess.run(
|
|
347
|
+
["powershell", "-NoProfile", "-Command", script],
|
|
348
|
+
capture_output=True,
|
|
349
|
+
text=True,
|
|
350
|
+
timeout=5,
|
|
351
|
+
)
|
|
352
|
+
return result.stdout.strip()
|
|
353
|
+
except Exception:
|
|
354
|
+
return ""
|