testzombie-windows 0.2.25__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.
- testzombie_windows/__init__.py +6 -0
- testzombie_windows/api.py +152 -0
- testzombie_windows/code_update.py +343 -0
- testzombie_windows/config.py +38 -0
- testzombie_windows/discovery.py +741 -0
- testzombie_windows/prerun.py +164 -0
- testzombie_windows/resource_listener.py +170 -0
- testzombie_windows/robot_library.py +980 -0
- testzombie_windows/run_python.py +63 -0
- testzombie_windows/run_robot.py +40 -0
- testzombie_windows/transparent.py +467 -0
- testzombie_windows/uia.py +636 -0
- testzombie_windows/uia_actions.py +438 -0
- testzombie_windows/uiautomation_adapter.py +398 -0
- testzombie_windows-0.2.25.dist-info/METADATA +202 -0
- testzombie_windows-0.2.25.dist-info/RECORD +19 -0
- testzombie_windows-0.2.25.dist-info/WHEEL +5 -0
- testzombie_windows-0.2.25.dist-info/licenses/LICENSE.txt +5 -0
- testzombie_windows-0.2.25.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Network contract for the additive Windows API endpoints (Phase 2.2)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import socket
|
|
6
|
+
from .config import api_base_url, api_header_names
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestZombieApiError(RuntimeError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# The InitController supplies these project settings in InitZombieResult.
|
|
16
|
+
# Keep the allowlist explicit: no tokens, credentials or project secrets in logs.
|
|
17
|
+
_HEALING_FIELDS = (
|
|
18
|
+
('healingLocatorFallbackEnabled', 'Locator-Fallback'),
|
|
19
|
+
('healingVisualPositionEnabled', 'Visual-Position'),
|
|
20
|
+
('healingWaitEnabled', 'Wait'),
|
|
21
|
+
('healingClickEnabled', 'Click'),
|
|
22
|
+
('healingAiEnabled', 'AI'),
|
|
23
|
+
('healingStrictness', 'Strictness'),
|
|
24
|
+
('healingAuditModeEnabled', 'Audit'),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def format_healing_configuration(config):
|
|
29
|
+
"""Render only settings actually supplied by initZombie, never infer defaults."""
|
|
30
|
+
if not isinstance(config, dict):
|
|
31
|
+
return ''
|
|
32
|
+
rendered = []
|
|
33
|
+
for key, label in _HEALING_FIELDS:
|
|
34
|
+
if key not in config or config[key] is None:
|
|
35
|
+
continue
|
|
36
|
+
value = config[key]
|
|
37
|
+
if isinstance(value, bool):
|
|
38
|
+
value = 'AN' if value else 'AUS'
|
|
39
|
+
else:
|
|
40
|
+
value = str(value)
|
|
41
|
+
rendered.append(f'{label}={value}')
|
|
42
|
+
return ' | '.join(rendered)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def report_healing_configuration(api, *, emitter=print):
|
|
46
|
+
"""Print project-controlled healing flags after a successful Init."""
|
|
47
|
+
summary = format_healing_configuration(getattr(api, 'healing_config', None))
|
|
48
|
+
if summary:
|
|
49
|
+
emitter('[TestZombie Windows] Healing-Konfiguration: ' + summary)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class WindowsApiClient:
|
|
53
|
+
def __init__(self, url=None, api_key=None, email=None, timeout=None):
|
|
54
|
+
try:
|
|
55
|
+
self.url = api_base_url(url)
|
|
56
|
+
self.key_header, self.mail_header = api_header_names()
|
|
57
|
+
except ValueError as exc:
|
|
58
|
+
raise TestZombieApiError(str(exc)) from exc
|
|
59
|
+
self.healing_config = {}
|
|
60
|
+
self.api_key = api_key or os.environ.get('TZ_API_KEY', '')
|
|
61
|
+
self.email = email or os.environ.get('TZ_API_EMAIL', '')
|
|
62
|
+
try:
|
|
63
|
+
self.timeout = float(timeout if timeout is not None else os.environ.get('TZ_API_TIMEOUT', '3'))
|
|
64
|
+
# findWindowsElement may execute a synchronous AI provider request.
|
|
65
|
+
# Its deadline must not inherit the short normal API timeout.
|
|
66
|
+
self.heal_timeout = float(os.environ.get('TZ_API_HEAL_TIMEOUT', '120'))
|
|
67
|
+
except (TypeError, ValueError) as exc:
|
|
68
|
+
raise TestZombieApiError('TZ_API_TIMEOUT / TZ_API_HEAL_TIMEOUT muss eine Sekundenzahl sein.') from exc
|
|
69
|
+
if not (0 < self.timeout <= 60):
|
|
70
|
+
raise TestZombieApiError('TZ_API_TIMEOUT muss groesser 0 und maximal 60 Sekunden sein.')
|
|
71
|
+
if not (0 < self.heal_timeout <= 300):
|
|
72
|
+
raise TestZombieApiError('TZ_API_HEAL_TIMEOUT muss groesser 0 und maximal 300 Sekunden sein.')
|
|
73
|
+
if not self.api_key:
|
|
74
|
+
raise TestZombieApiError('TZ_API_KEY fehlt (Projekt-API-Key konfigurieren).')
|
|
75
|
+
|
|
76
|
+
def request_timeout(self, endpoint):
|
|
77
|
+
return self.heal_timeout if endpoint.strip('/').lower() == 'findwindowselement' else self.timeout
|
|
78
|
+
|
|
79
|
+
def post(self, endpoint, payload):
|
|
80
|
+
timeout = self.request_timeout(endpoint)
|
|
81
|
+
url = self.url + '/api/' + endpoint.lstrip('/')
|
|
82
|
+
body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
|
|
83
|
+
headers = {
|
|
84
|
+
'Content-Type': 'application/json',
|
|
85
|
+
self.key_header: self.api_key,
|
|
86
|
+
self.mail_header: self.email,
|
|
87
|
+
}
|
|
88
|
+
# This marks a single Windows demo request. The server additionally
|
|
89
|
+
# requires a local/test Spring profile, explicit property and exact
|
|
90
|
+
# demo identity; a client-controlled header can never force AI alone.
|
|
91
|
+
if endpoint.strip('/').lower() == 'findwindowselement' and os.environ.get(
|
|
92
|
+
'TZ_WINDOWS_TEST_FORCE_AI', '').strip().lower() in ('1', 'true', 'yes', 'on'):
|
|
93
|
+
from urllib.parse import urlsplit
|
|
94
|
+
if urlsplit(self.url).hostname not in ('localhost', '127.0.0.1', '::1'):
|
|
95
|
+
raise TestZombieApiError('Force-AI test mode requires a localhost API URL')
|
|
96
|
+
headers['X-TestZombie-Windows-Test-Force-AI'] = 'true'
|
|
97
|
+
req = urllib.request.Request(url, body, method='POST', headers=headers)
|
|
98
|
+
try:
|
|
99
|
+
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
100
|
+
result = json.load(response)
|
|
101
|
+
except urllib.error.HTTPError as exc:
|
|
102
|
+
# Avoid echoing response bodies potentially containing user data.
|
|
103
|
+
raise TestZombieApiError(f'{endpoint}: HTTP {exc.code} (API response rejected)') from exc
|
|
104
|
+
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
|
|
105
|
+
# urllib can wrap a socket timeout in URLError.reason. Do not
|
|
106
|
+
# include raw response bodies, credentials or user input in logs.
|
|
107
|
+
reason = getattr(exc, 'reason', exc)
|
|
108
|
+
if isinstance(reason, (TimeoutError, socket.timeout)) or 'timed out' in str(reason).lower():
|
|
109
|
+
detail = f'timeout after {timeout:g}s'
|
|
110
|
+
elif isinstance(exc, ValueError):
|
|
111
|
+
detail = 'invalid JSON response'
|
|
112
|
+
else:
|
|
113
|
+
detail = 'network error: ' + type(exc).__name__
|
|
114
|
+
raise TestZombieApiError(f'{endpoint}: {detail}') from exc
|
|
115
|
+
if not isinstance(result, dict) or result.get('success') is False:
|
|
116
|
+
raise TestZombieApiError(f'{endpoint}: unsuccessful API response')
|
|
117
|
+
data = result.get('data')
|
|
118
|
+
if isinstance(data, dict) and data.get('error'):
|
|
119
|
+
raise TestZombieApiError(f'{endpoint}: {data["error"]}')
|
|
120
|
+
return data if isinstance(data, dict) else {}
|
|
121
|
+
|
|
122
|
+
def put_discovery(self, test_run_id, payload):
|
|
123
|
+
"""PUT to the existing DiscoveryController; success is HTTP 204 (no JSON)."""
|
|
124
|
+
from urllib.parse import quote
|
|
125
|
+
if not isinstance(test_run_id, str) or not test_run_id.strip():
|
|
126
|
+
raise TestZombieApiError('Discovery requires a valid testRunId')
|
|
127
|
+
endpoint = 'test-runs/' + quote(test_run_id, safe='') + '/discovery'
|
|
128
|
+
url = self.url + '/api/' + endpoint
|
|
129
|
+
body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
|
|
130
|
+
req = urllib.request.Request(url, body, method='PUT', headers={
|
|
131
|
+
'Content-Type': 'application/json',
|
|
132
|
+
self.key_header: self.api_key,
|
|
133
|
+
self.mail_header: self.email,
|
|
134
|
+
})
|
|
135
|
+
try:
|
|
136
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
|
137
|
+
if response.status not in (200, 201, 202, 204):
|
|
138
|
+
raise TestZombieApiError('discovery: unexpected HTTP status')
|
|
139
|
+
return True
|
|
140
|
+
except urllib.error.HTTPError as exc:
|
|
141
|
+
raise TestZombieApiError(f'discovery: HTTP {exc.code}') from exc
|
|
142
|
+
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
|
|
143
|
+
raise TestZombieApiError(f'discovery: network error: {type(exc).__name__}') from exc
|
|
144
|
+
|
|
145
|
+
def init_run(self, info='ROBOT_WINDOWS'):
|
|
146
|
+
self.healing_config = {}
|
|
147
|
+
data = self.post('initZombie', {'info': info})
|
|
148
|
+
run_id = data.get('testRunId')
|
|
149
|
+
if not run_id:
|
|
150
|
+
raise TestZombieApiError('initZombie did not return a testRunId')
|
|
151
|
+
self.healing_config = {key: data[key] for key, _ in _HEALING_FIELDS if key in data}
|
|
152
|
+
return run_id
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Opt-in, fail-closed source updates for successful Windows UIA healing.
|
|
2
|
+
|
|
3
|
+
This module never touches the Java backend, a site-packages directory, or a file
|
|
4
|
+
outside the explicitly allowed project root. Changes are literal-only, backed up
|
|
5
|
+
and written atomically. If any source location is ambiguous we do nothing.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import io
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import stat
|
|
17
|
+
import tempfile
|
|
18
|
+
import tokenize
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
_TRUE = {'1', 'true', 'yes', 'on'}
|
|
22
|
+
_ROBOT_SPLIT = re.compile(r'(\t+| {2,})')
|
|
23
|
+
_SKIPPED_DIRS = {'.git', '.venv', '.venv-uia', '__pycache__', 'results',
|
|
24
|
+
'node_modules', 'dist', 'build', 'site-packages', '.tox'}
|
|
25
|
+
_MAX_FILE_BYTES = 2 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def enabled_from_env() -> bool:
|
|
29
|
+
return os.environ.get('TZ_CODE_UPDATE', '').strip().lower() in _TRUE
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class WindowsCodeUpdater:
|
|
33
|
+
"""Source updater: disabled unless TZ_CODE_UPDATE explicitly set to true.
|
|
34
|
+
|
|
35
|
+
root: Only source files INSIDE this directory may be edited. By default the
|
|
36
|
+
current working directory is used. CI/custom runners should explicitly set
|
|
37
|
+
TZ_CODE_UPDATE_ROOT to the checked-out test project directory.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, *, enabled: Optional[bool] = None, root=None):
|
|
41
|
+
self.enabled = (enabled_from_env() if enabled is None else
|
|
42
|
+
enabled.strip().lower() in _TRUE if isinstance(enabled, str) else bool(enabled))
|
|
43
|
+
self.root = Path(root or os.environ.get('TZ_CODE_UPDATE_ROOT') or os.getcwd()).resolve()
|
|
44
|
+
|
|
45
|
+
def _allowed(self, path: Path, suffix: str) -> bool:
|
|
46
|
+
try:
|
|
47
|
+
resolved = path.resolve(strict=True)
|
|
48
|
+
relative = resolved.relative_to(self.root)
|
|
49
|
+
return (resolved.is_file() and resolved.suffix.lower() == suffix
|
|
50
|
+
and not any(part in _SKIPPED_DIRS for part in relative.parts)
|
|
51
|
+
and resolved.stat().st_size <= _MAX_FILE_BYTES)
|
|
52
|
+
except (OSError, ValueError):
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _read(path: Path):
|
|
57
|
+
# Respect PEP-263 encoding declarations and preserve existing newlines.
|
|
58
|
+
raw = path.read_bytes()
|
|
59
|
+
encoding, _ = tokenize.detect_encoding(io.BytesIO(raw).readline)
|
|
60
|
+
return raw.decode(encoding), encoding
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _annotation(old: str, new: str, test_run_id, now=None):
|
|
64
|
+
def clean(value):
|
|
65
|
+
value = str(value if value is not None else '')
|
|
66
|
+
# Never let a malformed remote testRunId inject a second source line.
|
|
67
|
+
return re.sub(r'[\r\n\x00-\x1f\x7f]', ' ', value)[:550]
|
|
68
|
+
|
|
69
|
+
at = now or datetime.now(timezone.utc)
|
|
70
|
+
stamp = at.astimezone(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
71
|
+
return (f'TestZombie CodeUpdate | old={clean(old)} | new={clean(new)} '
|
|
72
|
+
f'| at={stamp} | testRun={clean(test_run_id)}')
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _column_as_char(line, byte_column):
|
|
76
|
+
# Python AST stores UTF-8 byte offsets, even if source text is Unicode.
|
|
77
|
+
return len(line.encode('utf-8')[:byte_column].decode('utf-8'))
|
|
78
|
+
|
|
79
|
+
def _write(self, path: Path, before: str, after: str, encoding: str) -> bool:
|
|
80
|
+
if before == after or not self.enabled:
|
|
81
|
+
return False
|
|
82
|
+
backup = path.with_name(path.name + '.testzombie.bak')
|
|
83
|
+
lock = path.with_name(path.name + '.testzombie.lock')
|
|
84
|
+
try:
|
|
85
|
+
lock_fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
86
|
+
except OSError:
|
|
87
|
+
return False # Someone else is already modifying this source.
|
|
88
|
+
try:
|
|
89
|
+
os.close(lock_fd)
|
|
90
|
+
actual, _ = self._read(path)
|
|
91
|
+
if actual != before: # source changed since we evaluated the edit
|
|
92
|
+
return False
|
|
93
|
+
if backup.is_symlink():
|
|
94
|
+
return False
|
|
95
|
+
if not backup.exists():
|
|
96
|
+
shutil.copy2(path, backup)
|
|
97
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
98
|
+
fd, tmp_name = tempfile.mkstemp(prefix='.tz-update-', suffix='.tmp', dir=path.parent)
|
|
99
|
+
try:
|
|
100
|
+
with os.fdopen(fd, 'w', encoding=encoding, newline='') as f:
|
|
101
|
+
f.write(after)
|
|
102
|
+
f.flush()
|
|
103
|
+
os.fsync(f.fileno())
|
|
104
|
+
os.chmod(tmp_name, mode)
|
|
105
|
+
os.replace(tmp_name, path)
|
|
106
|
+
finally:
|
|
107
|
+
if os.path.exists(tmp_name):
|
|
108
|
+
os.unlink(tmp_name)
|
|
109
|
+
return True
|
|
110
|
+
except (OSError, UnicodeError):
|
|
111
|
+
return False
|
|
112
|
+
finally:
|
|
113
|
+
try:
|
|
114
|
+
lock.unlink()
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
def update_python(self, *, frame, control_type, locator_keyword, old, new, test_run_id) -> bool:
|
|
119
|
+
"""Update ONLY a string literal in this exact find_control call site.
|
|
120
|
+
|
|
121
|
+
A dynamic variable (or a customer wrapper that forwards variables) is
|
|
122
|
+
deliberately not rewritten; there is no safe automatic source location.
|
|
123
|
+
"""
|
|
124
|
+
if not self.enabled or old == new or locator_keyword not in ('automation_id', 'name'):
|
|
125
|
+
return False
|
|
126
|
+
if not (isinstance(old, str) and isinstance(new, str) and new
|
|
127
|
+
and '\n' not in new and '\r' not in new):
|
|
128
|
+
return False
|
|
129
|
+
path = Path(frame.f_code.co_filename)
|
|
130
|
+
if not self._allowed(path, '.py'):
|
|
131
|
+
return False
|
|
132
|
+
try:
|
|
133
|
+
source, encoding = self._read(path)
|
|
134
|
+
tree = ast.parse(source, filename=str(path))
|
|
135
|
+
except (OSError, SyntaxError, UnicodeError):
|
|
136
|
+
return False
|
|
137
|
+
calls = []
|
|
138
|
+
for node in ast.walk(tree):
|
|
139
|
+
if not isinstance(node, ast.Call):
|
|
140
|
+
continue
|
|
141
|
+
func = node.func
|
|
142
|
+
if not isinstance(func, ast.Attribute) or func.attr != 'find_control':
|
|
143
|
+
continue
|
|
144
|
+
if not (node.lineno <= frame.f_lineno <= node.end_lineno):
|
|
145
|
+
continue
|
|
146
|
+
if not node.args or not isinstance(node.args[0], ast.Constant):
|
|
147
|
+
continue
|
|
148
|
+
if node.args[0].value not in (control_type, control_type + 'Control'):
|
|
149
|
+
continue
|
|
150
|
+
candidates = [k.value for k in node.keywords
|
|
151
|
+
if k.arg == locator_keyword and isinstance(k.value, ast.Constant)
|
|
152
|
+
and isinstance(k.value.value, str) and k.value.value == old]
|
|
153
|
+
if len(candidates) == 1:
|
|
154
|
+
calls.append(candidates[0])
|
|
155
|
+
if len(calls) != 1:
|
|
156
|
+
return False
|
|
157
|
+
node = calls[0]
|
|
158
|
+
if node.lineno != node.end_lineno:
|
|
159
|
+
return False # no multiline string literals / implicit concatenation
|
|
160
|
+
lines = source.splitlines(keepends=True)
|
|
161
|
+
idx = node.lineno - 1
|
|
162
|
+
line = lines[idx]
|
|
163
|
+
try:
|
|
164
|
+
start = self._column_as_char(line, node.col_offset)
|
|
165
|
+
end = self._column_as_char(line, node.end_col_offset)
|
|
166
|
+
except (ValueError, UnicodeError):
|
|
167
|
+
return False
|
|
168
|
+
# Explicitly verify that AST token really is just a standalone literal.
|
|
169
|
+
literal = line[start:end]
|
|
170
|
+
try:
|
|
171
|
+
if ast.literal_eval(literal) != old or not isinstance(ast.parse(literal, mode='eval').body, ast.Constant):
|
|
172
|
+
return False
|
|
173
|
+
except (SyntaxError, ValueError):
|
|
174
|
+
return False
|
|
175
|
+
# Preserve the number of lines (otherwise the running Python frame's
|
|
176
|
+
# line numbers would no longer refer to the same source call sites).
|
|
177
|
+
new_line = line[:start] + repr(new) + line[end:]
|
|
178
|
+
no_eol = new_line.rstrip('\r\n')
|
|
179
|
+
eol = new_line[len(no_eol):]
|
|
180
|
+
annotation = self._annotation(old, new, test_run_id)
|
|
181
|
+
new_line = no_eol + ' # ' + annotation + eol
|
|
182
|
+
lines[idx] = new_line
|
|
183
|
+
updated = ''.join(lines)
|
|
184
|
+
try:
|
|
185
|
+
ast.parse(updated, filename=str(path))
|
|
186
|
+
except SyntaxError:
|
|
187
|
+
return False
|
|
188
|
+
return self._write(path, source, updated, encoding)
|
|
189
|
+
|
|
190
|
+
def _suite_sources(self, source_file):
|
|
191
|
+
"""Only files referenced by the CURRENT Robot suite may be edited.
|
|
192
|
+
|
|
193
|
+
For safety, variable-based imports (that cannot be resolved offline) are
|
|
194
|
+
not followed. We never glob unrelated Robot suites to find a match.
|
|
195
|
+
"""
|
|
196
|
+
if not source_file:
|
|
197
|
+
return []
|
|
198
|
+
source_file = Path(source_file)
|
|
199
|
+
if not self._allowed(source_file, source_file.suffix.lower()):
|
|
200
|
+
return []
|
|
201
|
+
pending = [source_file.resolve()]
|
|
202
|
+
visited = set()
|
|
203
|
+
while pending and len(visited) < 100:
|
|
204
|
+
path = pending.pop()
|
|
205
|
+
if path in visited or not self._allowed(path, path.suffix.lower()):
|
|
206
|
+
continue
|
|
207
|
+
visited.add(path)
|
|
208
|
+
try:
|
|
209
|
+
source, _ = self._read(path)
|
|
210
|
+
except (OSError, UnicodeError):
|
|
211
|
+
continue
|
|
212
|
+
in_settings = False
|
|
213
|
+
for line in source.splitlines():
|
|
214
|
+
stripped = line.strip()
|
|
215
|
+
if stripped.startswith('***') and stripped.endswith('***'):
|
|
216
|
+
in_settings = stripped.casefold() == '*** settings ***'
|
|
217
|
+
continue
|
|
218
|
+
if not in_settings or not stripped or stripped.startswith('#'):
|
|
219
|
+
continue
|
|
220
|
+
fields = _ROBOT_SPLIT.split(stripped)
|
|
221
|
+
tokens = fields[::2]
|
|
222
|
+
if len(tokens) < 2 or tokens[0].casefold() != 'resource':
|
|
223
|
+
continue
|
|
224
|
+
entry = tokens[1].replace('${CURDIR}', str(path.parent))
|
|
225
|
+
if '${' in entry or '%{' in entry:
|
|
226
|
+
continue
|
|
227
|
+
child = Path(entry)
|
|
228
|
+
if not child.is_absolute():
|
|
229
|
+
child = path.parent / child
|
|
230
|
+
if self._allowed(child, child.suffix.lower()) and child.suffix.lower() in ('.robot', '.resource'):
|
|
231
|
+
pending.append(child.resolve())
|
|
232
|
+
return sorted(visited)
|
|
233
|
+
|
|
234
|
+
def update_robot(self, *, old, new, test_run_id, source_file=None) -> bool:
|
|
235
|
+
"""Update unique Robot literal in current suite or imported resources.
|
|
236
|
+
|
|
237
|
+
Requires a current suite source. Unrelated files, unresolved variables,
|
|
238
|
+
multiple matching definitions and non-literal locators are not edited.
|
|
239
|
+
"""
|
|
240
|
+
if not self.enabled or not isinstance(old, str) or not isinstance(new, str):
|
|
241
|
+
return False
|
|
242
|
+
if not old or not new or old == new or any(c in new for c in '\r\n\x00'):
|
|
243
|
+
return False
|
|
244
|
+
matches = []
|
|
245
|
+
for path in self._suite_sources(source_file):
|
|
246
|
+
try:
|
|
247
|
+
source, encoding = self._read(path)
|
|
248
|
+
except (OSError, UnicodeError):
|
|
249
|
+
continue
|
|
250
|
+
lines = source.splitlines(keepends=True)
|
|
251
|
+
for index, line in enumerate(lines):
|
|
252
|
+
stripped = line.strip()
|
|
253
|
+
if not stripped or stripped.startswith('#') or stripped.startswith('...'):
|
|
254
|
+
continue
|
|
255
|
+
content = line.rstrip('\r\n')
|
|
256
|
+
chunks = _ROBOT_SPLIT.split(content)
|
|
257
|
+
for position in range(0, len(chunks), 2):
|
|
258
|
+
token = chunks[position]
|
|
259
|
+
if token.strip().startswith('#'):
|
|
260
|
+
break
|
|
261
|
+
if token == old:
|
|
262
|
+
matches.append((path, source, encoding, lines, index, chunks, position))
|
|
263
|
+
if len(matches) > 1:
|
|
264
|
+
return False
|
|
265
|
+
if len(matches) != 1:
|
|
266
|
+
return False
|
|
267
|
+
path, source, encoding, lines, index, chunks, position = matches[0]
|
|
268
|
+
chunks[position] = new
|
|
269
|
+
old_line = lines[index]
|
|
270
|
+
eol = '\r\n' if old_line.endswith('\r\n') else ('\n' if old_line.endswith('\n') else '')
|
|
271
|
+
lines[index] = ''.join(chunks) + ' # ' + self._annotation(old, new, test_run_id) + eol
|
|
272
|
+
updated = ''.join(lines)
|
|
273
|
+
return self._write(path, source, updated, encoding)
|
|
274
|
+
|
|
275
|
+
def update_native_python(self, *, source_file, lineno, control_type,
|
|
276
|
+
locator_keyword, old, new, test_run_id) -> bool:
|
|
277
|
+
"""Update a unique native ``auto.EditControl(AutomationId='...')`` call.
|
|
278
|
+
|
|
279
|
+
The exact constructor source location is captured at instantiation,
|
|
280
|
+
not guessed from the later Exists()/Click() stack. No dynamic strings,
|
|
281
|
+
ambiguous calls or cross-file searches are edited.
|
|
282
|
+
"""
|
|
283
|
+
if not self.enabled or old == new or locator_keyword not in ('AutomationId', 'Name'):
|
|
284
|
+
return False
|
|
285
|
+
if not all(isinstance(x, str) for x in (old, new)) or not new or any(c in new for c in '\r\n\x00'):
|
|
286
|
+
return False
|
|
287
|
+
try:
|
|
288
|
+
path = Path(source_file)
|
|
289
|
+
if not self._allowed(path, '.py'):
|
|
290
|
+
return False
|
|
291
|
+
source, encoding = self._read(path)
|
|
292
|
+
tree = ast.parse(source, filename=str(path))
|
|
293
|
+
except (OSError, SyntaxError, UnicodeError, ValueError):
|
|
294
|
+
return False
|
|
295
|
+
calls = []
|
|
296
|
+
for call in ast.walk(tree):
|
|
297
|
+
if not isinstance(call, ast.Call) or not (call.lineno <= lineno <= call.end_lineno):
|
|
298
|
+
continue
|
|
299
|
+
f = call.func
|
|
300
|
+
if not (isinstance(f, ast.Attribute) and f.attr == control_type + 'Control'):
|
|
301
|
+
continue
|
|
302
|
+
for kw in call.keywords:
|
|
303
|
+
if (kw.arg == locator_keyword and isinstance(kw.value, ast.Constant)
|
|
304
|
+
and isinstance(kw.value.value, str) and kw.value.value == old):
|
|
305
|
+
calls.append(kw.value)
|
|
306
|
+
if len(calls) != 1:
|
|
307
|
+
return False
|
|
308
|
+
literal = calls[0]
|
|
309
|
+
if literal.lineno != literal.end_lineno:
|
|
310
|
+
return False
|
|
311
|
+
lines = source.splitlines(keepends=True)
|
|
312
|
+
idx = literal.lineno - 1
|
|
313
|
+
line = lines[idx]
|
|
314
|
+
try:
|
|
315
|
+
start = self._column_as_char(line, literal.col_offset)
|
|
316
|
+
end = self._column_as_char(line, literal.end_col_offset)
|
|
317
|
+
token = line[start:end]
|
|
318
|
+
if ast.literal_eval(token) != old:
|
|
319
|
+
return False
|
|
320
|
+
except (ValueError, SyntaxError, UnicodeError):
|
|
321
|
+
return False
|
|
322
|
+
changed = line[:start] + repr(new) + line[end:]
|
|
323
|
+
# Line-count stability lets the still-running interpreter update the
|
|
324
|
+
# next native locator in the same test without misaddressed edits.
|
|
325
|
+
annotation = self._annotation(old, new, test_run_id)
|
|
326
|
+
eol = '\r\n' if changed.endswith('\r\n') else ('\n' if changed.endswith('\n') else '')
|
|
327
|
+
body = changed[:-len(eol)] if eol else changed
|
|
328
|
+
# A test can be healed again after a previous CodeUpdate (level 1 ->
|
|
329
|
+
# level 2). Preserve the earlier annotation in the same source line;
|
|
330
|
+
# do not produce a second Python comment or erase its provenance.
|
|
331
|
+
if 'TestZombie CodeUpdate |' in body:
|
|
332
|
+
if body.count('TestZombie CodeUpdate |') >= 12 or len(body) > 12000:
|
|
333
|
+
return False # bounded audit trail, avoid unbounded source growth
|
|
334
|
+
changed_line = body + ' ; ' + annotation
|
|
335
|
+
else:
|
|
336
|
+
changed_line = body + ' # ' + annotation
|
|
337
|
+
lines[idx] = changed_line + eol
|
|
338
|
+
updated = ''.join(lines)
|
|
339
|
+
try:
|
|
340
|
+
ast.parse(updated, filename=str(path))
|
|
341
|
+
except SyntaxError:
|
|
342
|
+
return False
|
|
343
|
+
return self._write(path, source, updated, encoding)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Shared API configuration for FlaUI, RPA.Windows and direct Python UIA.
|
|
2
|
+
|
|
3
|
+
Production defaults to https://testzombie.ai/api. TZ_API_URL overrides it for
|
|
4
|
+
local development, staging and CI. The network client appends /api/<endpoint>,
|
|
5
|
+
so api_base_url() intentionally returns the service root without /api.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
11
|
+
|
|
12
|
+
DEFAULT_LIVE_API_URL = "https://testzombie.ai/api"
|
|
13
|
+
DEFAULT_KEY_HEADER = "X-API-KEY" # Must agree with ApiKeyFilter.API_KEY_HEADER.
|
|
14
|
+
DEFAULT_MAIL_HEADER = "X-API-MAIL" # Must agree with ApiKeyFilter.API_MAIL_HEADER.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def api_base_url(explicit_url: str | None = None) -> str:
|
|
18
|
+
"""Resolve base URL. Accept both .../api and ... (normalize to base)."""
|
|
19
|
+
candidate = (explicit_url or os.environ.get('TZ_API_URL') or DEFAULT_LIVE_API_URL).strip()
|
|
20
|
+
if not candidate:
|
|
21
|
+
raise ValueError(
|
|
22
|
+
'Keine TestZombie API URL konfiguriert; pruefe TZ_API_URL oder den Live-Default.'
|
|
23
|
+
)
|
|
24
|
+
parts = urlsplit(candidate)
|
|
25
|
+
if parts.scheme not in ('http', 'https') or not parts.netloc or parts.query or parts.fragment:
|
|
26
|
+
raise ValueError('TZ_API_URL muss eine absolute http(s)-Basis-URL ohne Query/Fragment sein.')
|
|
27
|
+
path = parts.path.rstrip('/')
|
|
28
|
+
if path.endswith('/api'):
|
|
29
|
+
path = path[:-4]
|
|
30
|
+
return urlunsplit((parts.scheme, parts.netloc, path, '', '')).rstrip('/')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def api_header_names() -> tuple[str, str]:
|
|
34
|
+
key = os.environ.get('TZ_API_KEY_HEADER', DEFAULT_KEY_HEADER).strip()
|
|
35
|
+
mail = os.environ.get('TZ_API_MAIL_HEADER', DEFAULT_MAIL_HEADER).strip()
|
|
36
|
+
if not key or not mail:
|
|
37
|
+
raise ValueError('TZ_API_KEY_HEADER und TZ_API_MAIL_HEADER duerfen nicht leer sein.')
|
|
38
|
+
return key, mail
|