screenforge 0.4.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.
- cli/__init__.py +0 -0
- cli/_version.py +1 -0
- cli/dispatch.py +266 -0
- cli/doctor.py +487 -0
- cli/modes/__init__.py +0 -0
- cli/modes/action.py +262 -0
- cli/modes/default.py +248 -0
- cli/modes/demo.py +162 -0
- cli/modes/dry_run.py +237 -0
- cli/modes/init.py +133 -0
- cli/modes/plan.py +148 -0
- cli/modes/workflow.py +354 -0
- cli/parser.py +305 -0
- cli/reporter.py +207 -0
- cli/session.py +146 -0
- cli/shared.py +427 -0
- cli/shorthand.py +90 -0
- cli/tool_protocol_handlers.py +446 -0
- common/__init__.py +0 -0
- common/adapters/__init__.py +21 -0
- common/adapters/android_adapter.py +273 -0
- common/adapters/base_adapter.py +24 -0
- common/adapters/ios_adapter.py +278 -0
- common/adapters/web_adapter.py +271 -0
- common/ai.py +277 -0
- common/ai_autonomous.py +273 -0
- common/ai_heal.py +222 -0
- common/cache/__init__.py +15 -0
- common/cache/cache_hash.py +57 -0
- common/cache/cache_manager.py +300 -0
- common/cache/cache_stats.py +133 -0
- common/cache/cache_storage.py +79 -0
- common/cache/embedding_loader.py +150 -0
- common/capabilities.py +121 -0
- common/case_memory.py +327 -0
- common/error_codes.py +61 -0
- common/exceptions.py +18 -0
- common/executor.py +1504 -0
- common/failure_diagnosis.py +138 -0
- common/history_manager.py +75 -0
- common/logs.py +168 -0
- common/mcp_server.py +467 -0
- common/preflight.py +496 -0
- common/progress.py +37 -0
- common/run_reporter.py +415 -0
- common/run_resume.py +149 -0
- common/runtime_modes.py +35 -0
- common/tool_protocol.py +196 -0
- common/visual_fallback.py +71 -0
- common/workflow_schema.py +150 -0
- config/__init__.py +0 -0
- config/config.py +167 -0
- config/env_loader.py +76 -0
- screenforge-0.4.0.dist-info/METADATA +43 -0
- screenforge-0.4.0.dist-info/RECORD +64 -0
- screenforge-0.4.0.dist-info/WHEEL +5 -0
- screenforge-0.4.0.dist-info/entry_points.txt +2 -0
- screenforge-0.4.0.dist-info/licenses/LICENSE +21 -0
- screenforge-0.4.0.dist-info/top_level.txt +4 -0
- utils/__init__.py +0 -0
- utils/screenshot_annotator.py +60 -0
- utils/utils_ios.py +195 -0
- utils/utils_web.py +304 -0
- utils/utils_xml.py +218 -0
common/executor.py
ADDED
|
@@ -0,0 +1,1504 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
import config.config as config
|
|
4
|
+
from common.capabilities import ASSERTION_ACTIONS, GLOBAL_ACTIONS
|
|
5
|
+
from common.error_codes import format_log
|
|
6
|
+
from common.logs import log
|
|
7
|
+
|
|
8
|
+
# NOTE: the web ref cache (@N -> element) lives on the UIExecutor INSTANCE
|
|
9
|
+
# (see UIExecutor.set_ui_elements / resolve_ref), not as a module global. A
|
|
10
|
+
# process-global cache leaked refs across pages/requests under the long-lived
|
|
11
|
+
# MCP server; binding it to the executor the SharedAdapterManager owns per
|
|
12
|
+
# platform keeps each session's @N aligned with the page it inspected. The
|
|
13
|
+
# ref-aware helpers below therefore take an explicit `resolve_ref` callable
|
|
14
|
+
# (default None) instead of reaching for ambient state.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _escape_locator_value(value: str) -> str:
|
|
18
|
+
s = str(value)
|
|
19
|
+
s = s.replace("\\", "\\\\")
|
|
20
|
+
s = s.replace("'", "\\'")
|
|
21
|
+
s = s.replace("\n", "\\n")
|
|
22
|
+
s = s.replace("\r", "\\r")
|
|
23
|
+
s = s.replace("\t", "\\t")
|
|
24
|
+
s = s.replace("\0", "\\x00")
|
|
25
|
+
return s
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _escape_python_string(value: str) -> str:
|
|
29
|
+
return _escape_locator_value(value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _escape_css_ident(value: str) -> str:
|
|
33
|
+
"""Escape a CSS identifier (for an #id selector) per CSS.escape rules: any
|
|
34
|
+
char that isn't [A-Za-z0-9_-] or non-ASCII is backslash-escaped, and a
|
|
35
|
+
leading digit is escaped too. Keeps `locator('#weird.id')` from being parsed
|
|
36
|
+
as id+class."""
|
|
37
|
+
s = str(value)
|
|
38
|
+
out = []
|
|
39
|
+
for ch in s:
|
|
40
|
+
if ch.isalnum() or ch in "_-" or ord(ch) >= 0x80:
|
|
41
|
+
out.append(ch)
|
|
42
|
+
else:
|
|
43
|
+
out.append("\\" + ch)
|
|
44
|
+
result = "".join(out)
|
|
45
|
+
if result and result[0].isdigit():
|
|
46
|
+
result = "\\3" + result[0] + " " + result[1:]
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _normalize_ws(value: str) -> str:
|
|
51
|
+
"""Collapse all whitespace runs to single spaces and trim ends — matches
|
|
52
|
+
Playwright expect().to_have_text / to_contain_text normalization. Keeping the
|
|
53
|
+
live execute() verdict on the SAME normalization as the generated expect()
|
|
54
|
+
code means the autonomous loop never accepts a step the emitted test would
|
|
55
|
+
reject (or vice-versa) over internal whitespace differences."""
|
|
56
|
+
return " ".join(str(value or "").split())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Map a compressed-DOM element (tag + clickable + type) to an ARIA role, so a
|
|
60
|
+
# coordinate fallback can emit a stable get_by_role locator instead of a pixel.
|
|
61
|
+
def _infer_web_role(el: dict) -> str | None:
|
|
62
|
+
tag = str(el.get("class", "")).lower()
|
|
63
|
+
el_type = str(el.get("type", "")).lower()
|
|
64
|
+
if tag == "a":
|
|
65
|
+
return "link"
|
|
66
|
+
if tag == "button" or el_type in ("submit", "button", "reset"):
|
|
67
|
+
return "button"
|
|
68
|
+
if el_type == "checkbox":
|
|
69
|
+
return "checkbox"
|
|
70
|
+
if el_type == "radio":
|
|
71
|
+
return "radio"
|
|
72
|
+
if tag == "select":
|
|
73
|
+
return "combobox"
|
|
74
|
+
if tag == "textarea" or (tag == "input" and el_type in ("", "text", "email", "search", "url", "tel", "password")):
|
|
75
|
+
return "textbox"
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _inner_strategy(el: dict) -> tuple | None:
|
|
80
|
+
"""The role/accessible-name/text part of the stability ranking, WITHOUT the
|
|
81
|
+
unique-by-spec id/name attrs and WITHOUT scope. Factored out so the scoped
|
|
82
|
+
and unscoped paths share one source of truth for "how do I name this control"
|
|
83
|
+
— the scoped locator wraps exactly this inner strategy."""
|
|
84
|
+
role = _infer_web_role(el)
|
|
85
|
+
accessible_name = el.get("desc") or el.get("text")
|
|
86
|
+
if role and accessible_name:
|
|
87
|
+
return ("role", role, accessible_name)
|
|
88
|
+
if el.get("desc"):
|
|
89
|
+
return ("label", el["desc"])
|
|
90
|
+
if el.get("placeholder"):
|
|
91
|
+
return ("placeholder", el["placeholder"])
|
|
92
|
+
if el.get("text"):
|
|
93
|
+
return ("text", el["text"])
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _fallback_strategy(el: dict) -> tuple | None:
|
|
98
|
+
"""Decide the single most-stable locator strategy for a web element, by
|
|
99
|
+
uniqueness/stability (architect adc8c2c): id > name attr > scope+inner >
|
|
100
|
+
role+accessible-name > aria-label(desc) > placeholder > text. Returns a
|
|
101
|
+
(kind, *args) tuple or None when nothing locatable exists. Shared by
|
|
102
|
+
build_fallback_locator (codegen string) and get_fallback_element (live
|
|
103
|
+
handle) so the EMITTED locator and the one we actually click can never
|
|
104
|
+
diverge.
|
|
105
|
+
|
|
106
|
+
`name`/`id` rank above role+name because they're unique by spec; a role+name
|
|
107
|
+
match can be duplicated on a page (the "wrong element" trap). When the
|
|
108
|
+
compressor flagged the element as ambiguous (`scope` = its row's identifying
|
|
109
|
+
text), we wrap the inner role/text strategy in that scope instead of falling
|
|
110
|
+
into the silent `.first` trap — Playwright strict-mode then fails loud if the
|
|
111
|
+
scope still isn't unique."""
|
|
112
|
+
if not el:
|
|
113
|
+
return None
|
|
114
|
+
if el.get("id"):
|
|
115
|
+
# CSS.escape-equivalent for an #id selector; bare ids with special chars
|
|
116
|
+
# would otherwise build a malformed selector.
|
|
117
|
+
return ("css", f"#{_escape_css_ident(el['id'])}")
|
|
118
|
+
if el.get("name"):
|
|
119
|
+
# Escape backslash+double-quote so a name containing " can't prematurely
|
|
120
|
+
# close the [name="..."] attribute selector.
|
|
121
|
+
safe_name = str(el["name"]).replace("\\", "\\\\").replace('"', '\\"')
|
|
122
|
+
return ("css", f'[name="{safe_name}"]')
|
|
123
|
+
inner = _inner_strategy(el)
|
|
124
|
+
# Ambiguous control (one of N same-named) → scope by its row's identifying
|
|
125
|
+
# text. Only meaningful when there IS an inner strategy to scope.
|
|
126
|
+
scope = el.get("scope")
|
|
127
|
+
if scope and inner:
|
|
128
|
+
return ("scoped", str(scope), *inner)
|
|
129
|
+
# Known-ambiguous (compressor set dup_index → it was in a >=2 collision group)
|
|
130
|
+
# but no usable scope: refuse to emit a flat inner locator, which would carry a
|
|
131
|
+
# silent `.first` and always hit row 1 (the lie). Return None so the caller
|
|
132
|
+
# takes the honest pytest.skip path instead of persisting a wrong-row test.
|
|
133
|
+
if el.get("dup_index") is not None and inner:
|
|
134
|
+
return None
|
|
135
|
+
return inner
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _inner_locator_frag(strat: tuple) -> str | None:
|
|
139
|
+
"""Render an inner (role/label/placeholder/text) strategy tuple to a
|
|
140
|
+
Playwright locator fragment WITHOUT a trailing `.first` — so it can be either
|
|
141
|
+
suffixed with `.first` (flat, unscoped) or chained after a scope (no `.first`,
|
|
142
|
+
strict-mode enforces uniqueness within the row)."""
|
|
143
|
+
kind = strat[0]
|
|
144
|
+
if kind == "css":
|
|
145
|
+
return f"locator('{_escape_locator_value(strat[1])}')"
|
|
146
|
+
if kind == "role":
|
|
147
|
+
return f"get_by_role('{strat[1]}', name='{_escape_locator_value(strat[2])}')"
|
|
148
|
+
if kind == "label":
|
|
149
|
+
return f"get_by_label('{_escape_locator_value(strat[1])}')"
|
|
150
|
+
if kind == "placeholder":
|
|
151
|
+
return f"get_by_placeholder('{_escape_locator_value(strat[1])}')"
|
|
152
|
+
if kind == "text":
|
|
153
|
+
return f"get_by_text('{_escape_locator_value(strat[1])}')"
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_fallback_locator(el: dict) -> str | None:
|
|
158
|
+
"""Best-effort STABLE Playwright locator EXPRESSION for a web element when
|
|
159
|
+
the @N ref chain (id → text) didn't resolve. Returns e.g.
|
|
160
|
+
"get_by_role('button', name='Save').first", or None when the element has no
|
|
161
|
+
locatable attribute (the pure-coordinate / visual-fallback shape). A
|
|
162
|
+
coordinate is NEVER returned — callers that get None must skip, not click a
|
|
163
|
+
pixel.
|
|
164
|
+
|
|
165
|
+
For an ambiguous control (one of N same-named, carrying `scope`), returns a
|
|
166
|
+
SCOPED locator — get_by_text('<row label>').locator('..').<inner> with NO
|
|
167
|
+
`.first` — so the persisted test targets the right row instead of silently
|
|
168
|
+
clicking row 1."""
|
|
169
|
+
strat = _fallback_strategy(el)
|
|
170
|
+
if strat is None:
|
|
171
|
+
return None
|
|
172
|
+
if strat[0] == "scoped":
|
|
173
|
+
# ("scoped", scope_text, *inner_strategy)
|
|
174
|
+
scope_text, inner = strat[1], strat[2:]
|
|
175
|
+
inner_frag = _inner_locator_frag(inner)
|
|
176
|
+
if inner_frag is None:
|
|
177
|
+
return None
|
|
178
|
+
# Scope to the row by its identifying text, hop to the row container
|
|
179
|
+
# (`..`), then locate the control within it. exact=True so a scope that is
|
|
180
|
+
# a SUBSTRING of another row's label (e.g. "Bob" vs "Bob Jones") doesn't
|
|
181
|
+
# match both — Playwright get_by_text defaults to substring matching. No
|
|
182
|
+
# `.first`: if the scope still isn't unique, strict-mode fails loud, never
|
|
183
|
+
# silently row 1.
|
|
184
|
+
return f"get_by_text('{_escape_locator_value(scope_text)}', exact=True).locator('..').{inner_frag}"
|
|
185
|
+
inner_frag = _inner_locator_frag(strat)
|
|
186
|
+
return f"{inner_frag}.first" if inner_frag is not None else None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _inner_locator_handle(scope, strat: tuple):
|
|
190
|
+
"""Live Playwright handle for an inner strategy tuple, rooted at `scope` (a
|
|
191
|
+
locator/frame). Twin of _inner_locator_frag — same strategy, live handle —
|
|
192
|
+
so the emitted string and the clicked handle can never diverge."""
|
|
193
|
+
kind = strat[0]
|
|
194
|
+
if kind == "css":
|
|
195
|
+
return scope.locator(strat[1])
|
|
196
|
+
if kind == "role":
|
|
197
|
+
return scope.get_by_role(strat[1], name=strat[2])
|
|
198
|
+
if kind == "label":
|
|
199
|
+
return scope.get_by_label(strat[1])
|
|
200
|
+
if kind == "placeholder":
|
|
201
|
+
return scope.get_by_placeholder(strat[1])
|
|
202
|
+
if kind == "text":
|
|
203
|
+
return scope.get_by_text(strat[1])
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def get_fallback_element(d, el: dict):
|
|
208
|
+
"""Live Playwright locator for the SAME strategy build_fallback_locator
|
|
209
|
+
emits, so the runtime block can click the element it will write into the
|
|
210
|
+
test (not a pixel) and prove the locator actually resolves. Returns a
|
|
211
|
+
locator handle or None."""
|
|
212
|
+
strat = _fallback_strategy(el)
|
|
213
|
+
if strat is None:
|
|
214
|
+
return None
|
|
215
|
+
if strat[0] == "scoped":
|
|
216
|
+
scope_text, inner = strat[1], strat[2:]
|
|
217
|
+
# exact=True mirrors the emitted string — substring scope ("Bob" in
|
|
218
|
+
# "Bob Jones") must not match both. No `.first`: strict-mode enforces it.
|
|
219
|
+
row = d.get_by_text(scope_text, exact=True).locator("..")
|
|
220
|
+
handle = _inner_locator_handle(row, inner)
|
|
221
|
+
return handle
|
|
222
|
+
handle = _inner_locator_handle(d, strat)
|
|
223
|
+
return handle.first if handle is not None else None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def readable_ref_target(el: dict | None) -> str:
|
|
227
|
+
"""Human-readable label for a web @N element (text → desc → name → id),
|
|
228
|
+
falling back to the ref token itself so a label is never empty."""
|
|
229
|
+
if not el:
|
|
230
|
+
return ""
|
|
231
|
+
return el.get("text") or el.get("desc") or el.get("name") or el.get("id") or el.get("ref", "")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def humanize_step_labels(code_lines: list, ref_token: str, readable: str) -> list:
|
|
235
|
+
"""Replace a raw `[@N]` ref token with a human-readable target inside the
|
|
236
|
+
generated allure.step / log lines. Pure transform: only rewrites the bracket
|
|
237
|
+
token `[@N]`, never the resolved locator call (which already uses real
|
|
238
|
+
text/id). No-op when the token isn't present or `readable` is empty."""
|
|
239
|
+
if not ref_token or not readable or f"[{ref_token}]" not in "".join(code_lines):
|
|
240
|
+
return code_lines
|
|
241
|
+
return [line.replace(f"[{ref_token}]", f"[{readable}]") for line in code_lines]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class LocatorBuilder:
|
|
245
|
+
@staticmethod
|
|
246
|
+
def build_code(platform: str, u2_key: str, l_value: str, resolve_ref=None) -> str:
|
|
247
|
+
safe_val = _escape_locator_value(l_value)
|
|
248
|
+
if platform == "web":
|
|
249
|
+
if u2_key == "ref":
|
|
250
|
+
el_data = resolve_ref(l_value) if resolve_ref else None
|
|
251
|
+
if el_data:
|
|
252
|
+
# Stable-locator chain (id → name → scope → role → label →
|
|
253
|
+
# placeholder → text). NEVER a coordinate: a pixel click baked
|
|
254
|
+
# into a persisted test rots silently on any layout shift.
|
|
255
|
+
fragment = build_fallback_locator(el_data)
|
|
256
|
+
if fragment:
|
|
257
|
+
return fragment
|
|
258
|
+
# build_fallback_locator returned None. For a KNOWN-AMBIGUOUS
|
|
259
|
+
# ref (dup_index set, no usable scope) emit the bare name WITHOUT
|
|
260
|
+
# `.first` — strict-mode then fails loud on the ambiguity instead
|
|
261
|
+
# of silently clicking row 1. (In normal flow the live side
|
|
262
|
+
# resolves to None first and the caller emits pytest.skip; this
|
|
263
|
+
# is the honest string if build_code is reached directly.)
|
|
264
|
+
if el_data.get("dup_index") is not None:
|
|
265
|
+
name = el_data.get("desc") or el_data.get("text") or ""
|
|
266
|
+
if name:
|
|
267
|
+
return f"get_by_text('{_escape_locator_value(name)}')"
|
|
268
|
+
# No locatable attribute at all → a literal that fails loud
|
|
269
|
+
# at replay (better than a silently-rotting coordinate).
|
|
270
|
+
return f"locator('{safe_val}').first"
|
|
271
|
+
return f"locator('{safe_val}').first"
|
|
272
|
+
elif u2_key in ["resourceId", "id"]:
|
|
273
|
+
return f"locator('#{safe_val}').first"
|
|
274
|
+
elif u2_key == "text":
|
|
275
|
+
return f"get_by_text('{safe_val}').first"
|
|
276
|
+
elif u2_key == "description":
|
|
277
|
+
return f"locator('[aria-label=\"{safe_val}\"]').first"
|
|
278
|
+
else:
|
|
279
|
+
return f"locator('{safe_val}').first"
|
|
280
|
+
else:
|
|
281
|
+
return f"{u2_key}='{safe_val}'"
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def get_element(d, platform: str, u2_key: str, l_value: str, resolve_ref=None):
|
|
285
|
+
if platform == "web":
|
|
286
|
+
if u2_key == "ref":
|
|
287
|
+
el_data = resolve_ref(l_value) if resolve_ref else None
|
|
288
|
+
if not el_data:
|
|
289
|
+
log.warning(f"[E030] Ref {l_value} not found in cache. Fix: run inspect_ui first to refresh the element cache")
|
|
290
|
+
return None
|
|
291
|
+
if el_data.get("id"):
|
|
292
|
+
return d.locator(f"#{el_data['id']}").first
|
|
293
|
+
# Defer to the SHARED strategy (the same one build_fallback_locator
|
|
294
|
+
# emits) instead of a private get_by_text(...).first chain — so a
|
|
295
|
+
# scoped/ambiguous ref resolves live to the SAME element we persist,
|
|
296
|
+
# and an unscopable-ambiguous ref resolves to None (→ honest skip)
|
|
297
|
+
# rather than silently clicking row 1.
|
|
298
|
+
return get_fallback_element(d, el_data)
|
|
299
|
+
elif u2_key in ["resourceId", "id"]:
|
|
300
|
+
return d.locator(f"#{l_value}").first
|
|
301
|
+
elif u2_key == "text":
|
|
302
|
+
return d.get_by_text(l_value).first
|
|
303
|
+
elif u2_key == "description":
|
|
304
|
+
return d.locator(f"[aria-label='{l_value}']").first
|
|
305
|
+
else:
|
|
306
|
+
return d.locator(l_value).first
|
|
307
|
+
elif platform == "ios":
|
|
308
|
+
ios_key_map = {
|
|
309
|
+
"description": "label",
|
|
310
|
+
"resourceId": "name",
|
|
311
|
+
"text": "label",
|
|
312
|
+
"css": "classChain",
|
|
313
|
+
}
|
|
314
|
+
mapped_key = ios_key_map.get(u2_key, u2_key)
|
|
315
|
+
return d(**{mapped_key: l_value})
|
|
316
|
+
else:
|
|
317
|
+
return d(**{u2_key: l_value})
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def build_locator_code(platform: str, u2_key: str, l_value: str, resolve_ref=None) -> str:
|
|
321
|
+
return LocatorBuilder.build_code(platform, u2_key, l_value, resolve_ref=resolve_ref)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def get_actual_element(d, platform: str, u2_key: str, l_value: str, resolve_ref=None):
|
|
325
|
+
return LocatorBuilder.get_element(d, platform, u2_key, l_value, resolve_ref=resolve_ref)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class ActionHandler(ABC):
|
|
329
|
+
@abstractmethod
|
|
330
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
@abstractmethod
|
|
334
|
+
def generate_code(
|
|
335
|
+
self, platform: str, u2_key: str, l_value: str, extra_value: str, timeout: float,
|
|
336
|
+
resolve_ref=None,
|
|
337
|
+
) -> list:
|
|
338
|
+
pass
|
|
339
|
+
|
|
340
|
+
def get_log_message(self, l_type: str, l_value: str, extra_value: str) -> str:
|
|
341
|
+
pass
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class HoverHandler(ActionHandler):
|
|
345
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
346
|
+
if platform == "web":
|
|
347
|
+
element.wait_for(state="visible", timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
348
|
+
element.hover()
|
|
349
|
+
return True
|
|
350
|
+
else:
|
|
351
|
+
log.warning("⚠️ [Warning] Hover not supported on mobile, skipping")
|
|
352
|
+
return True
|
|
353
|
+
|
|
354
|
+
def generate_code(
|
|
355
|
+
self, platform: str, u2_key: str, l_value: str, extra_value: str, timeout: float,
|
|
356
|
+
resolve_ref=None,
|
|
357
|
+
) -> list:
|
|
358
|
+
loc_str = build_locator_code(platform, u2_key, l_value, resolve_ref=resolve_ref)
|
|
359
|
+
if platform == "web":
|
|
360
|
+
return [
|
|
361
|
+
f" with allure.step('Hover: [{l_value}]'):\n",
|
|
362
|
+
f" log.info('Action: hover [{l_value}]')\n",
|
|
363
|
+
f" d.{loc_str}.hover(timeout={timeout * 1000})\n",
|
|
364
|
+
]
|
|
365
|
+
else:
|
|
366
|
+
return [
|
|
367
|
+
f" with allure.step('Hover (mobile skip): [{l_value}]'):\n",
|
|
368
|
+
f" log.warning('Hover not supported on mobile [{l_value}]')\n",
|
|
369
|
+
]
|
|
370
|
+
|
|
371
|
+
def get_log_message(self, l_type: str, l_value: str, extra_value: str) -> str:
|
|
372
|
+
return f"✅ [Action] Hover: {l_type}='{l_value}'"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class ClickHandler(ActionHandler):
|
|
376
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
377
|
+
if platform == "web":
|
|
378
|
+
element.wait_for(state="visible", timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
379
|
+
element.click()
|
|
380
|
+
return True
|
|
381
|
+
else:
|
|
382
|
+
if not element.wait(timeout=config.DEFAULT_TIMEOUT):
|
|
383
|
+
return False
|
|
384
|
+
element.click()
|
|
385
|
+
return True
|
|
386
|
+
|
|
387
|
+
def generate_code(
|
|
388
|
+
self, platform: str, u2_key: str, l_value: str, extra_value: str, timeout: float,
|
|
389
|
+
resolve_ref=None,
|
|
390
|
+
) -> list:
|
|
391
|
+
loc_str = build_locator_code(platform, u2_key, l_value, resolve_ref=resolve_ref)
|
|
392
|
+
if platform == "web":
|
|
393
|
+
return [
|
|
394
|
+
f" with allure.step('Click: [{l_value}]'):\n",
|
|
395
|
+
f" log.info('Action: click [{l_value}]')\n",
|
|
396
|
+
f" d.{loc_str}.click(timeout={timeout * 1000})\n",
|
|
397
|
+
]
|
|
398
|
+
else:
|
|
399
|
+
return [
|
|
400
|
+
f" with allure.step('Click: [{l_value}]'):\n",
|
|
401
|
+
f" log.info('Action: click [{l_value}]')\n",
|
|
402
|
+
f" d({loc_str}).wait(timeout={timeout})\n",
|
|
403
|
+
f" d({loc_str}).click()\n",
|
|
404
|
+
]
|
|
405
|
+
|
|
406
|
+
def get_log_message(self, l_type: str, l_value: str, extra_value: str) -> str:
|
|
407
|
+
return f"✅ [Action] Click: {l_type}='{l_value}'"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class LongClickHandler(ActionHandler):
|
|
411
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
412
|
+
if not element:
|
|
413
|
+
return False
|
|
414
|
+
if platform == "web":
|
|
415
|
+
element.wait_for(state="visible", timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
416
|
+
element.click(delay=1000)
|
|
417
|
+
return True
|
|
418
|
+
else:
|
|
419
|
+
if not element.wait(timeout=config.DEFAULT_TIMEOUT):
|
|
420
|
+
return False
|
|
421
|
+
element.long_click()
|
|
422
|
+
return True
|
|
423
|
+
|
|
424
|
+
def generate_code(
|
|
425
|
+
self, platform: str, u2_key: str, l_value: str, extra_value: str, timeout: float,
|
|
426
|
+
resolve_ref=None,
|
|
427
|
+
) -> list:
|
|
428
|
+
loc_str = build_locator_code(platform, u2_key, l_value, resolve_ref=resolve_ref)
|
|
429
|
+
if platform == "web":
|
|
430
|
+
return [
|
|
431
|
+
f" with allure.step('Long click: [{l_value}]'):\n",
|
|
432
|
+
f" log.info('Action: long click [{l_value}]')\n",
|
|
433
|
+
f" d.{loc_str}.click(delay=1000, timeout={timeout * 1000})\n",
|
|
434
|
+
]
|
|
435
|
+
else:
|
|
436
|
+
return [
|
|
437
|
+
f" with allure.step('Long click: [{l_value}]'):\n",
|
|
438
|
+
f" log.info('Action: long click [{l_value}]')\n",
|
|
439
|
+
f" d({loc_str}).wait(timeout={timeout})\n",
|
|
440
|
+
f" d({loc_str}).long_click()\n",
|
|
441
|
+
]
|
|
442
|
+
|
|
443
|
+
def get_log_message(self, l_type: str, l_value: str, extra_value: str) -> str:
|
|
444
|
+
return f"✅ [Action] Long click: {l_type}='{l_value}'"
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
class InputHandler(ActionHandler):
|
|
448
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
449
|
+
if platform == "web":
|
|
450
|
+
element.wait_for(state="visible", timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
451
|
+
element.fill(extra_value)
|
|
452
|
+
return True
|
|
453
|
+
else:
|
|
454
|
+
if not element.wait(timeout=config.DEFAULT_TIMEOUT):
|
|
455
|
+
return False
|
|
456
|
+
element.set_text(extra_value)
|
|
457
|
+
return True
|
|
458
|
+
|
|
459
|
+
def generate_code(
|
|
460
|
+
self, platform: str, u2_key: str, l_value: str, extra_value: str, timeout: float,
|
|
461
|
+
resolve_ref=None,
|
|
462
|
+
) -> list:
|
|
463
|
+
safe_extra = _escape_python_string(extra_value)
|
|
464
|
+
loc_str = build_locator_code(platform, u2_key, l_value, resolve_ref=resolve_ref)
|
|
465
|
+
if platform == "web":
|
|
466
|
+
return [
|
|
467
|
+
f" with allure.step('Input: [{safe_extra}] into [{l_value}]'):\n",
|
|
468
|
+
f" log.info('Action: input [{safe_extra}] into [{l_value}]')\n",
|
|
469
|
+
f" d.{loc_str}.fill('{safe_extra}', timeout={timeout * 1000})\n",
|
|
470
|
+
]
|
|
471
|
+
else:
|
|
472
|
+
return [
|
|
473
|
+
f" with allure.step('Input: [{safe_extra}] into [{l_value}]'):\n",
|
|
474
|
+
f" log.info('Action: input [{safe_extra}] into [{l_value}]')\n",
|
|
475
|
+
f" d({loc_str}).wait(timeout={timeout})\n",
|
|
476
|
+
f" d({loc_str}).set_text('{safe_extra}')\n",
|
|
477
|
+
]
|
|
478
|
+
|
|
479
|
+
def get_log_message(self, l_type: str, l_value: str, extra_value: str) -> str:
|
|
480
|
+
return f"[Action] Input: {l_type}='{l_value}', value='{extra_value}'"
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
class SwipeHandler(ActionHandler):
|
|
484
|
+
_DIRECTIONS = ("up", "down", "left", "right")
|
|
485
|
+
|
|
486
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
487
|
+
direction = extra_value.lower() if extra_value else "down"
|
|
488
|
+
if direction not in self._DIRECTIONS:
|
|
489
|
+
direction = "down"
|
|
490
|
+
if platform == "web":
|
|
491
|
+
if direction == "up":
|
|
492
|
+
d.mouse.wheel(0, -600)
|
|
493
|
+
elif direction == "left":
|
|
494
|
+
d.mouse.wheel(-600, 0)
|
|
495
|
+
elif direction == "right":
|
|
496
|
+
d.mouse.wheel(600, 0)
|
|
497
|
+
else:
|
|
498
|
+
d.mouse.wheel(0, 600)
|
|
499
|
+
d.wait_for_timeout(1000)
|
|
500
|
+
elif platform == "ios":
|
|
501
|
+
# facebook-wda has no swipe_ext (that's uiautomator2/Android). It
|
|
502
|
+
# exposes directional swipe_up/down/left/right() on the client.
|
|
503
|
+
getattr(d, f"swipe_{direction}")()
|
|
504
|
+
else:
|
|
505
|
+
d.swipe_ext(direction)
|
|
506
|
+
return True
|
|
507
|
+
|
|
508
|
+
def generate_code(
|
|
509
|
+
self, platform: str, u2_key: str, l_value: str, extra_value: str, timeout: float,
|
|
510
|
+
resolve_ref=None,
|
|
511
|
+
) -> list:
|
|
512
|
+
direction = extra_value.lower() if extra_value else "down"
|
|
513
|
+
if direction not in self._DIRECTIONS:
|
|
514
|
+
direction = "down"
|
|
515
|
+
if platform == "web":
|
|
516
|
+
scroll_code = "d.mouse.wheel(0, 600)"
|
|
517
|
+
if direction == "up":
|
|
518
|
+
scroll_code = "d.mouse.wheel(0, -600)"
|
|
519
|
+
elif direction == "left":
|
|
520
|
+
scroll_code = "d.mouse.wheel(-600, 0)"
|
|
521
|
+
elif direction == "right":
|
|
522
|
+
scroll_code = "d.mouse.wheel(600, 0)"
|
|
523
|
+
|
|
524
|
+
return [
|
|
525
|
+
f" with allure.step('Swipe: [{direction}]'):\n",
|
|
526
|
+
f" log.info('Action: swipe [{direction}]')\n",
|
|
527
|
+
f" {scroll_code}\n",
|
|
528
|
+
]
|
|
529
|
+
elif platform == "ios":
|
|
530
|
+
return [
|
|
531
|
+
f" with allure.step('Swipe: [{direction}]'):\n",
|
|
532
|
+
f" log.info('Action: swipe [{direction}]')\n",
|
|
533
|
+
f" d.swipe_{direction}()\n",
|
|
534
|
+
]
|
|
535
|
+
else:
|
|
536
|
+
return [
|
|
537
|
+
f" with allure.step('Swipe: [{direction}]'):\n",
|
|
538
|
+
f" log.info('Action: swipe [{direction}]')\n",
|
|
539
|
+
f" d.swipe_ext('{direction}')\n",
|
|
540
|
+
]
|
|
541
|
+
|
|
542
|
+
def get_log_message(self, l_type: str, l_value: str, extra_value: str) -> str:
|
|
543
|
+
return f"[Action] Swipe: direction='{extra_value}'"
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class PressHandler(ActionHandler):
|
|
547
|
+
_IOS_KEY_MAP = {
|
|
548
|
+
"enter": ["前往", "Go", "go", "Search", "搜索", "return", "Return"],
|
|
549
|
+
"return": ["前往", "Go", "go", "Search", "搜索", "return", "Return"],
|
|
550
|
+
"search": ["搜索", "Search", "前往", "Go"],
|
|
551
|
+
"done": ["完成", "Done"],
|
|
552
|
+
"next": ["下一个", "Next"],
|
|
553
|
+
"send": ["发送", "Send"],
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
557
|
+
key = extra_value if extra_value else "Enter"
|
|
558
|
+
if platform == "web":
|
|
559
|
+
d.keyboard.press(key)
|
|
560
|
+
d.wait_for_timeout(500)
|
|
561
|
+
elif platform == "ios":
|
|
562
|
+
key_lower = key.lower()
|
|
563
|
+
if key_lower in ("home", "volumeup", "volumedown"):
|
|
564
|
+
d.press(key_lower)
|
|
565
|
+
else:
|
|
566
|
+
candidates = self._IOS_KEY_MAP.get(key_lower, [key])
|
|
567
|
+
for label in candidates:
|
|
568
|
+
try:
|
|
569
|
+
btn = d(label=label)
|
|
570
|
+
if btn.exists:
|
|
571
|
+
btn.click()
|
|
572
|
+
return True
|
|
573
|
+
except Exception:
|
|
574
|
+
continue
|
|
575
|
+
d.press_key(0x28)
|
|
576
|
+
else:
|
|
577
|
+
d.press(key.lower())
|
|
578
|
+
return True
|
|
579
|
+
|
|
580
|
+
def generate_code(
|
|
581
|
+
self, platform: str, u2_key: str, l_value: str, extra_value: str, timeout: float,
|
|
582
|
+
resolve_ref=None,
|
|
583
|
+
) -> list:
|
|
584
|
+
key = extra_value if extra_value else "Enter"
|
|
585
|
+
safe_key = _escape_python_string(key)
|
|
586
|
+
if platform == "web":
|
|
587
|
+
# No trailing sleep in generated code: the next action's locator
|
|
588
|
+
# auto-waits. (The live execute() path keeps a brief settle because
|
|
589
|
+
# it feeds a screenshot to the LLM before any next action exists.)
|
|
590
|
+
return [
|
|
591
|
+
f" with allure.step('Press key: [{safe_key}]'):\n",
|
|
592
|
+
f" log.info('Action: press key [{safe_key}]')\n",
|
|
593
|
+
f" d.keyboard.press('{safe_key}')\n",
|
|
594
|
+
]
|
|
595
|
+
elif platform == "ios":
|
|
596
|
+
key_lower = key.lower()
|
|
597
|
+
if key_lower in ("home", "volumeup", "volumedown"):
|
|
598
|
+
return [
|
|
599
|
+
f" with allure.step('Press key: [{safe_key}]'):\n",
|
|
600
|
+
f" log.info('Action: press key [{safe_key}]')\n",
|
|
601
|
+
f" d.press('{safe_key.lower()}')\n",
|
|
602
|
+
]
|
|
603
|
+
candidates = self._IOS_KEY_MAP.get(key_lower, [key])
|
|
604
|
+
first_label = _escape_python_string(candidates[0])
|
|
605
|
+
return [
|
|
606
|
+
f" with allure.step('Press key: [{safe_key}]'):\n",
|
|
607
|
+
f" log.info('Action: press key [{safe_key}]')\n",
|
|
608
|
+
f" d(label='{first_label}').click()\n",
|
|
609
|
+
]
|
|
610
|
+
else:
|
|
611
|
+
return [
|
|
612
|
+
f" with allure.step('Press key: [{safe_key}]'):\n",
|
|
613
|
+
f" log.info('Action: press key [{safe_key}]')\n",
|
|
614
|
+
f" d.press('{safe_key.lower()}')\n",
|
|
615
|
+
]
|
|
616
|
+
|
|
617
|
+
def get_log_message(self, l_type: str, l_value: str, extra_value: str) -> str:
|
|
618
|
+
return f"[Action] Press key: '{extra_value}'"
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
class AssertExistHandler(ActionHandler):
|
|
622
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
623
|
+
try:
|
|
624
|
+
if platform == "web":
|
|
625
|
+
element.wait_for(state="visible", timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
626
|
+
is_exist = element.is_visible()
|
|
627
|
+
else:
|
|
628
|
+
is_exist = element.wait(timeout=config.DEFAULT_TIMEOUT)
|
|
629
|
+
except Exception:
|
|
630
|
+
log.warning("❌ [Assert] Element not found / wait timed out — assertion FAILED")
|
|
631
|
+
return False
|
|
632
|
+
|
|
633
|
+
if is_exist:
|
|
634
|
+
log.info("[Assert] Passed")
|
|
635
|
+
else:
|
|
636
|
+
log.warning("❌ [Assert] Element not visible — assertion FAILED")
|
|
637
|
+
return bool(is_exist)
|
|
638
|
+
|
|
639
|
+
def generate_code(
|
|
640
|
+
self, platform: str, u2_key: str, l_value: str, extra_value: str, timeout: float,
|
|
641
|
+
resolve_ref=None,
|
|
642
|
+
) -> list:
|
|
643
|
+
loc_str = build_locator_code(platform, u2_key, l_value, resolve_ref=resolve_ref)
|
|
644
|
+
if platform == "web":
|
|
645
|
+
return [
|
|
646
|
+
f" with allure.step('Assert: element [{l_value}] exists'):\n",
|
|
647
|
+
f" log.info('Assert: check element [{l_value}] exists')\n",
|
|
648
|
+
" import playwright.sync_api\n",
|
|
649
|
+
" try:\n",
|
|
650
|
+
f" d.{loc_str}.wait_for(state='visible', timeout={timeout * 1000})\n",
|
|
651
|
+
" is_exist = True\n",
|
|
652
|
+
" except playwright.sync_api.TimeoutError:\n",
|
|
653
|
+
" is_exist = False\n",
|
|
654
|
+
" if not is_exist:\n",
|
|
655
|
+
f" log.error('Assertion failed: element [{l_value}] not found')\n",
|
|
656
|
+
f" assert is_exist, 'Assertion failed: element {l_value} not found'\n",
|
|
657
|
+
" log.info('Assertion passed: element exists')\n",
|
|
658
|
+
]
|
|
659
|
+
else:
|
|
660
|
+
return [
|
|
661
|
+
f" with allure.step('Assert: element [{l_value}] exists'):\n",
|
|
662
|
+
f" log.info('Assert: check element [{l_value}] exists')\n",
|
|
663
|
+
f" is_exist = d({loc_str}).wait(timeout={timeout})\n",
|
|
664
|
+
" if not is_exist:\n",
|
|
665
|
+
f" log.error('Assertion failed: element [{l_value}] not found')\n",
|
|
666
|
+
f" assert is_exist, 'Assertion failed: element {l_value} not found'\n",
|
|
667
|
+
" log.info('Assertion passed: element exists')\n",
|
|
668
|
+
]
|
|
669
|
+
|
|
670
|
+
def get_log_message(self, l_type: str, l_value: str, extra_value: str) -> str:
|
|
671
|
+
return f"[Assert] Element exists: {l_type}='{l_value}'"
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
class AssertTextEqualsHandler(ActionHandler):
|
|
675
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
676
|
+
try:
|
|
677
|
+
if platform == "web":
|
|
678
|
+
element.wait_for(state="visible", timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
679
|
+
actual_text = element.inner_text()
|
|
680
|
+
else:
|
|
681
|
+
if not element.wait(timeout=config.DEFAULT_TIMEOUT):
|
|
682
|
+
log.warning("❌ [Assert] Element not found — text assertion FAILED")
|
|
683
|
+
return False
|
|
684
|
+
actual_text = element.get_text()
|
|
685
|
+
except Exception:
|
|
686
|
+
log.warning("❌ [Assert] Failed to read element text — assertion FAILED")
|
|
687
|
+
return False
|
|
688
|
+
|
|
689
|
+
# Normalize whitespace so this live verdict matches the generated
|
|
690
|
+
# expect().to_have_text (which normalizes) — see _normalize_ws.
|
|
691
|
+
if _normalize_ws(actual_text) != _normalize_ws(extra_value):
|
|
692
|
+
log.warning(f"❌ [Assert] Expected '{extra_value}', got '{actual_text}' — assertion FAILED")
|
|
693
|
+
return False
|
|
694
|
+
log.info("[Assert] Passed")
|
|
695
|
+
return True
|
|
696
|
+
|
|
697
|
+
def generate_code(
|
|
698
|
+
self, platform: str, u2_key: str, l_value: str, extra_value: str, timeout: float,
|
|
699
|
+
resolve_ref=None,
|
|
700
|
+
) -> list:
|
|
701
|
+
safe_expected = _escape_python_string(extra_value)
|
|
702
|
+
loc_str = build_locator_code(platform, u2_key, l_value, resolve_ref=resolve_ref)
|
|
703
|
+
if platform == "web":
|
|
704
|
+
# Playwright's expect(...).to_have_text auto-retries until the text
|
|
705
|
+
# matches or the timeout elapses — eliminates the read-once race the
|
|
706
|
+
# old inner_text() comparison had on async-updating UIs.
|
|
707
|
+
return [
|
|
708
|
+
f" with allure.step('Assert: text equals [{safe_expected}]'):\n",
|
|
709
|
+
f" log.info('Assert: check [{l_value}] text == [{safe_expected}]')\n",
|
|
710
|
+
" from playwright.sync_api import expect\n",
|
|
711
|
+
f" expect(d.{loc_str}).to_have_text('{safe_expected}', timeout={timeout * 1000})\n",
|
|
712
|
+
f" log.info('Assertion passed: text is [{safe_expected}]')\n",
|
|
713
|
+
]
|
|
714
|
+
else:
|
|
715
|
+
return [
|
|
716
|
+
f" with allure.step('Assert: text equals [{safe_expected}]'):\n",
|
|
717
|
+
f" log.info('Assert: check [{l_value}] text == [{safe_expected}]')\n",
|
|
718
|
+
f" assert d({loc_str}).wait(timeout={timeout}), 'Assertion failed: element {l_value} not found'\n",
|
|
719
|
+
f" actual_text = d({loc_str}).get_text()\n",
|
|
720
|
+
f" assert actual_text == '{safe_expected}', f'Assertion failed: expected {safe_expected}, got {{actual_text}}'\n",
|
|
721
|
+
f" log.info(f'Assertion passed: text is [{safe_expected}]')\n",
|
|
722
|
+
]
|
|
723
|
+
|
|
724
|
+
def get_log_message(self, l_type: str, l_value: str, extra_value: str) -> str:
|
|
725
|
+
return f"[Assert] Text equals: {l_type}='{l_value}', expected='{extra_value}'"
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
class AssertTextContainsHandler(ActionHandler):
|
|
729
|
+
"""Assert an element's text CONTAINS a substring (not exact equality)."""
|
|
730
|
+
|
|
731
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
732
|
+
try:
|
|
733
|
+
if platform == "web":
|
|
734
|
+
element.wait_for(state="visible", timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
735
|
+
actual_text = element.inner_text()
|
|
736
|
+
else:
|
|
737
|
+
if not element.wait(timeout=config.DEFAULT_TIMEOUT):
|
|
738
|
+
log.warning("❌ [Assert] Element not found — text-contains assertion FAILED")
|
|
739
|
+
return False
|
|
740
|
+
actual_text = element.get_text()
|
|
741
|
+
except Exception:
|
|
742
|
+
log.warning("❌ [Assert] Failed to read element text — assertion FAILED")
|
|
743
|
+
return False
|
|
744
|
+
|
|
745
|
+
# Normalize whitespace on both sides so this matches the generated
|
|
746
|
+
# expect().to_contain_text (which normalizes) — see _normalize_ws.
|
|
747
|
+
if _normalize_ws(extra_value) not in _normalize_ws(actual_text):
|
|
748
|
+
log.warning(f"❌ [Assert] '{extra_value}' not found in '{actual_text}' — assertion FAILED")
|
|
749
|
+
return False
|
|
750
|
+
log.info("[Assert] Passed")
|
|
751
|
+
return True
|
|
752
|
+
|
|
753
|
+
def generate_code(
|
|
754
|
+
self, platform: str, u2_key: str, l_value: str, extra_value: str, timeout: float,
|
|
755
|
+
resolve_ref=None,
|
|
756
|
+
) -> list:
|
|
757
|
+
safe_expected = _escape_python_string(extra_value)
|
|
758
|
+
loc_str = build_locator_code(platform, u2_key, l_value, resolve_ref=resolve_ref)
|
|
759
|
+
if platform == "web":
|
|
760
|
+
return [
|
|
761
|
+
f" with allure.step('Assert: text contains [{safe_expected}]'):\n",
|
|
762
|
+
f" log.info('Assert: check [{l_value}] text contains [{safe_expected}]')\n",
|
|
763
|
+
" from playwright.sync_api import expect\n",
|
|
764
|
+
f" expect(d.{loc_str}).to_contain_text('{safe_expected}', timeout={timeout * 1000})\n",
|
|
765
|
+
f" log.info('Assertion passed: text contains [{safe_expected}]')\n",
|
|
766
|
+
]
|
|
767
|
+
else:
|
|
768
|
+
return [
|
|
769
|
+
f" with allure.step('Assert: text contains [{safe_expected}]'):\n",
|
|
770
|
+
f" log.info('Assert: check [{l_value}] text contains [{safe_expected}]')\n",
|
|
771
|
+
f" assert d({loc_str}).wait(timeout={timeout}), 'Assertion failed: element {l_value} not found'\n",
|
|
772
|
+
f" actual_text = d({loc_str}).get_text()\n",
|
|
773
|
+
f" assert '{safe_expected}' in (actual_text or ''), f'Assertion failed: [{safe_expected}] not in [{{actual_text}}]'\n",
|
|
774
|
+
f" log.info('Assertion passed: text contains [{safe_expected}]')\n",
|
|
775
|
+
]
|
|
776
|
+
|
|
777
|
+
def get_log_message(self, l_type: str, l_value: str, extra_value: str) -> str:
|
|
778
|
+
return f"[Assert] Text contains: {l_type}='{l_value}', substring='{extra_value}'"
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
class AssertNotExistHandler(ActionHandler):
|
|
782
|
+
"""Assert an element is absent / hidden (the negative of assert_exist)."""
|
|
783
|
+
|
|
784
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
785
|
+
if platform == "web":
|
|
786
|
+
try:
|
|
787
|
+
element.wait_for(state="hidden", timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
788
|
+
log.info("[Assert] Passed (element hidden/absent)")
|
|
789
|
+
return True
|
|
790
|
+
except Exception:
|
|
791
|
+
log.warning("❌ [Assert] Element still visible — assert_not_exist FAILED")
|
|
792
|
+
return False
|
|
793
|
+
else:
|
|
794
|
+
# uiautomator2's UiObject exposes wait_gone (verified present).
|
|
795
|
+
if element.wait_gone(timeout=config.DEFAULT_TIMEOUT):
|
|
796
|
+
log.info("[Assert] Passed (element gone)")
|
|
797
|
+
return True
|
|
798
|
+
log.warning("❌ [Assert] Element still present — assert_not_exist FAILED")
|
|
799
|
+
return False
|
|
800
|
+
|
|
801
|
+
def generate_code(
|
|
802
|
+
self, platform: str, u2_key: str, l_value: str, extra_value: str, timeout: float,
|
|
803
|
+
resolve_ref=None,
|
|
804
|
+
) -> list:
|
|
805
|
+
loc_str = build_locator_code(platform, u2_key, l_value, resolve_ref=resolve_ref)
|
|
806
|
+
if platform == "web":
|
|
807
|
+
return [
|
|
808
|
+
f" with allure.step('Assert: element [{l_value}] absent'):\n",
|
|
809
|
+
f" log.info('Assert: check element [{l_value}] is hidden/absent')\n",
|
|
810
|
+
" from playwright.sync_api import expect\n",
|
|
811
|
+
f" expect(d.{loc_str}).to_be_hidden(timeout={timeout * 1000})\n",
|
|
812
|
+
f" log.info('Assertion passed: element [{l_value}] absent')\n",
|
|
813
|
+
]
|
|
814
|
+
else:
|
|
815
|
+
return [
|
|
816
|
+
f" with allure.step('Assert: element [{l_value}] absent'):\n",
|
|
817
|
+
f" log.info('Assert: check element [{l_value}] is gone')\n",
|
|
818
|
+
f" gone = d({loc_str}).wait_gone(timeout={timeout})\n",
|
|
819
|
+
f" assert gone, 'Assertion failed: element {l_value} still present'\n",
|
|
820
|
+
f" log.info('Assertion passed: element [{l_value}] absent')\n",
|
|
821
|
+
]
|
|
822
|
+
|
|
823
|
+
def get_log_message(self, l_type: str, l_value: str, extra_value: str) -> str:
|
|
824
|
+
return f"[Assert] Element absent: {l_type}='{l_value}'"
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
class AssertValueHandler(ActionHandler):
|
|
828
|
+
"""Assert a form field's value.
|
|
829
|
+
|
|
830
|
+
Web reads the true field value via Playwright `input_value()`. On mobile
|
|
831
|
+
there is no generic "field value" accessor, so this asserts the element's
|
|
832
|
+
`text` — which for an Android `EditText` IS the entered value, but for other
|
|
833
|
+
widgets (or an empty field showing a hint) may be the placeholder/label. Use
|
|
834
|
+
`assert_value` for input fields on mobile; for arbitrary text use
|
|
835
|
+
`assert_text_equals` / `assert_text_contains`."""
|
|
836
|
+
|
|
837
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
838
|
+
try:
|
|
839
|
+
if platform == "web":
|
|
840
|
+
element.wait_for(state="visible", timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
841
|
+
actual = element.input_value()
|
|
842
|
+
else:
|
|
843
|
+
if not element.wait(timeout=config.DEFAULT_TIMEOUT):
|
|
844
|
+
log.warning("❌ [Assert] Element not found — value assertion FAILED")
|
|
845
|
+
return False
|
|
846
|
+
actual = element.get_text()
|
|
847
|
+
except Exception:
|
|
848
|
+
log.warning("❌ [Assert] Failed to read element value — assertion FAILED")
|
|
849
|
+
return False
|
|
850
|
+
|
|
851
|
+
if actual != extra_value:
|
|
852
|
+
log.warning(f"❌ [Assert] Expected value '{extra_value}', got '{actual}' — assertion FAILED")
|
|
853
|
+
return False
|
|
854
|
+
log.info("[Assert] Passed")
|
|
855
|
+
return True
|
|
856
|
+
|
|
857
|
+
def generate_code(
|
|
858
|
+
self, platform: str, u2_key: str, l_value: str, extra_value: str, timeout: float,
|
|
859
|
+
resolve_ref=None,
|
|
860
|
+
) -> list:
|
|
861
|
+
safe_expected = _escape_python_string(extra_value)
|
|
862
|
+
loc_str = build_locator_code(platform, u2_key, l_value, resolve_ref=resolve_ref)
|
|
863
|
+
if platform == "web":
|
|
864
|
+
return [
|
|
865
|
+
f" with allure.step('Assert: value equals [{safe_expected}]'):\n",
|
|
866
|
+
f" log.info('Assert: check [{l_value}] value == [{safe_expected}]')\n",
|
|
867
|
+
" from playwright.sync_api import expect\n",
|
|
868
|
+
f" expect(d.{loc_str}).to_have_value('{safe_expected}', timeout={timeout * 1000})\n",
|
|
869
|
+
f" log.info('Assertion passed: value is [{safe_expected}]')\n",
|
|
870
|
+
]
|
|
871
|
+
else:
|
|
872
|
+
return [
|
|
873
|
+
f" with allure.step('Assert: value equals [{safe_expected}]'):\n",
|
|
874
|
+
f" log.info('Assert: check [{l_value}] value == [{safe_expected}]')\n",
|
|
875
|
+
f" assert d({loc_str}).wait(timeout={timeout}), 'Assertion failed: element {l_value} not found'\n",
|
|
876
|
+
f" actual_value = d({loc_str}).get_text()\n",
|
|
877
|
+
f" assert actual_value == '{safe_expected}', f'Assertion failed: expected {safe_expected}, got {{actual_value}}'\n",
|
|
878
|
+
f" log.info(f'Assertion passed: value is [{safe_expected}]')\n",
|
|
879
|
+
]
|
|
880
|
+
|
|
881
|
+
def get_log_message(self, l_type: str, l_value: str, extra_value: str) -> str:
|
|
882
|
+
return f"[Assert] Value equals: {l_type}='{l_value}', expected='{extra_value}'"
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
class AssertUrlHandler(ActionHandler):
|
|
886
|
+
"""Global web assertion: the page URL contains the expected substring."""
|
|
887
|
+
|
|
888
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
889
|
+
if platform != "web":
|
|
890
|
+
log.warning("[Assert] assert_url is only supported on Web platform")
|
|
891
|
+
return False
|
|
892
|
+
try:
|
|
893
|
+
actual_url = d.url
|
|
894
|
+
except Exception:
|
|
895
|
+
log.warning("❌ [Assert] Failed to read page URL — assertion FAILED")
|
|
896
|
+
return False
|
|
897
|
+
if extra_value not in (actual_url or ""):
|
|
898
|
+
log.warning(f"❌ [Assert] '{extra_value}' not in URL '{actual_url}' — assertion FAILED")
|
|
899
|
+
return False
|
|
900
|
+
log.info("[Assert] Passed")
|
|
901
|
+
return True
|
|
902
|
+
|
|
903
|
+
def generate_code(
|
|
904
|
+
self, platform: str, u2_key: str, l_value: str, extra_value: str, timeout: float,
|
|
905
|
+
resolve_ref=None,
|
|
906
|
+
) -> list:
|
|
907
|
+
import re as _re
|
|
908
|
+
safe_expected = _escape_python_string(extra_value)
|
|
909
|
+
# to_have_url accepts a regex; embed the substring as an escaped pattern
|
|
910
|
+
# so it matches anywhere in the URL and auto-retries until navigation lands.
|
|
911
|
+
pattern = _re.escape(extra_value)
|
|
912
|
+
safe_pattern = _escape_python_string(pattern)
|
|
913
|
+
return [
|
|
914
|
+
f" with allure.step('Assert: URL contains [{safe_expected}]'):\n",
|
|
915
|
+
f" log.info('Assert: check URL contains [{safe_expected}]')\n",
|
|
916
|
+
" import re\n",
|
|
917
|
+
" from playwright.sync_api import expect\n",
|
|
918
|
+
f" expect(d).to_have_url(re.compile('{safe_pattern}'), timeout={timeout * 1000})\n",
|
|
919
|
+
f" log.info('Assertion passed: URL contains [{safe_expected}]')\n",
|
|
920
|
+
]
|
|
921
|
+
|
|
922
|
+
def get_log_message(self, l_type: str, l_value: str, extra_value: str) -> str:
|
|
923
|
+
return f"[Assert] URL contains: '{extra_value}'"
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
class WaitForHandler(ActionHandler):
|
|
927
|
+
"""Explicit synchronization: wait until an element is visible (default) or
|
|
928
|
+
hidden. extra_value selects the state: "" / "visible" / "appear" → visible;
|
|
929
|
+
"hidden" / "gone" / "disappear" → hidden. Replaces magic sleeps."""
|
|
930
|
+
|
|
931
|
+
_HIDDEN_WORDS = ("hidden", "gone", "disappear", "absent")
|
|
932
|
+
_VISIBLE_WORDS = ("", "visible", "appear", "shown", "present")
|
|
933
|
+
|
|
934
|
+
def _wants_hidden(self, extra_value: str) -> bool:
|
|
935
|
+
token = str(extra_value or "").strip().lower()
|
|
936
|
+
if token in self._HIDDEN_WORDS:
|
|
937
|
+
return True
|
|
938
|
+
if token not in self._VISIBLE_WORDS:
|
|
939
|
+
log.debug(
|
|
940
|
+
f"[Wait] Unrecognized state '{extra_value}', defaulting to 'visible' "
|
|
941
|
+
f"(use one of {self._HIDDEN_WORDS} for a hidden-wait)"
|
|
942
|
+
)
|
|
943
|
+
return False
|
|
944
|
+
|
|
945
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
946
|
+
hidden = self._wants_hidden(extra_value)
|
|
947
|
+
if platform == "web":
|
|
948
|
+
state = "hidden" if hidden else "visible"
|
|
949
|
+
try:
|
|
950
|
+
element.wait_for(state=state, timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
951
|
+
log.info(f"[Wait] Condition met (state={state})")
|
|
952
|
+
return True
|
|
953
|
+
except Exception:
|
|
954
|
+
log.warning(f"❌ [Wait] Timed out waiting for state={state}")
|
|
955
|
+
return False
|
|
956
|
+
else:
|
|
957
|
+
if hidden:
|
|
958
|
+
ok = element.wait_gone(timeout=config.DEFAULT_TIMEOUT)
|
|
959
|
+
else:
|
|
960
|
+
ok = element.wait(timeout=config.DEFAULT_TIMEOUT)
|
|
961
|
+
if ok:
|
|
962
|
+
log.info("[Wait] Condition met")
|
|
963
|
+
return True
|
|
964
|
+
log.warning("❌ [Wait] Timed out")
|
|
965
|
+
return False
|
|
966
|
+
|
|
967
|
+
def generate_code(
|
|
968
|
+
self, platform: str, u2_key: str, l_value: str, extra_value: str, timeout: float,
|
|
969
|
+
resolve_ref=None,
|
|
970
|
+
) -> list:
|
|
971
|
+
hidden = self._wants_hidden(extra_value)
|
|
972
|
+
loc_str = build_locator_code(platform, u2_key, l_value, resolve_ref=resolve_ref)
|
|
973
|
+
if platform == "web":
|
|
974
|
+
state = "hidden" if hidden else "visible"
|
|
975
|
+
return [
|
|
976
|
+
f" with allure.step('Wait for [{l_value}] ({state})'):\n",
|
|
977
|
+
f" log.info('Wait: [{l_value}] -> {state}')\n",
|
|
978
|
+
f" d.{loc_str}.wait_for(state='{state}', timeout={timeout * 1000})\n",
|
|
979
|
+
]
|
|
980
|
+
else:
|
|
981
|
+
call = f"wait_gone(timeout={timeout})" if hidden else f"wait(timeout={timeout})"
|
|
982
|
+
return [
|
|
983
|
+
f" with allure.step('Wait for [{l_value}]'):\n",
|
|
984
|
+
f" log.info('Wait: [{l_value}]')\n",
|
|
985
|
+
f" assert d({loc_str}).{call}, 'Wait condition not met for {l_value}'\n",
|
|
986
|
+
]
|
|
987
|
+
|
|
988
|
+
def get_log_message(self, l_type: str, l_value: str, extra_value: str) -> str:
|
|
989
|
+
return f"[Wait] {l_type}='{l_value}', state='{extra_value or 'visible'}'"
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
class GotoHandler(ActionHandler):
|
|
993
|
+
|
|
994
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
995
|
+
if platform != "web":
|
|
996
|
+
log.warning("[E034] 'goto' action is only supported on Web platform. Fix: use --platform web")
|
|
997
|
+
return False
|
|
998
|
+
url = extra_value.strip()
|
|
999
|
+
if not url.startswith(("http://", "https://")):
|
|
1000
|
+
url = "https://" + url
|
|
1001
|
+
d.goto(url, wait_until="load", timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
1002
|
+
return True
|
|
1003
|
+
|
|
1004
|
+
def generate_code(
|
|
1005
|
+
self, platform: str, u2_key: str, l_value: str, extra_value: str, timeout: float,
|
|
1006
|
+
resolve_ref=None,
|
|
1007
|
+
) -> list:
|
|
1008
|
+
url = extra_value.strip()
|
|
1009
|
+
if not url.startswith(("http://", "https://")):
|
|
1010
|
+
url = "https://" + url
|
|
1011
|
+
safe_url = _escape_python_string(url)
|
|
1012
|
+
# goto(wait_until='load') is itself a synchronization point (it waits for
|
|
1013
|
+
# the load event). No trailing sleep: the NEXT action's locator
|
|
1014
|
+
# auto-waits, which is Playwright's recommended readiness strategy.
|
|
1015
|
+
# (networkidle is explicitly discouraged by Playwright for testing.)
|
|
1016
|
+
return [
|
|
1017
|
+
f" with allure.step('Navigate to: [{safe_url}]'):\n",
|
|
1018
|
+
f" log.info('Action: navigate to [{safe_url}]')\n",
|
|
1019
|
+
f" d.goto('{safe_url}', wait_until='load')\n",
|
|
1020
|
+
]
|
|
1021
|
+
|
|
1022
|
+
def get_log_message(self, l_type: str, l_value: str, extra_value: str) -> str:
|
|
1023
|
+
return f"[Action] Navigate to: {extra_value}"
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _web_only(action_label: str, platform: str) -> bool:
|
|
1027
|
+
"""These interactions have a clean, stable Playwright API but no robust
|
|
1028
|
+
coordinate-free equivalent on uiautomator2/wda, and P2 deliberately stopped
|
|
1029
|
+
emitting coordinate-based code. Engage only on web; elsewhere fail honestly
|
|
1030
|
+
(not a silent skip) so the caller knows the action wasn't performed."""
|
|
1031
|
+
if platform == "web":
|
|
1032
|
+
return True
|
|
1033
|
+
log.warning(f"⚠️ [Action] '{action_label}' is web-only; not supported on {platform}")
|
|
1034
|
+
return False
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _autodetect_web_target(d, value: str):
|
|
1038
|
+
"""Resolve a secondary target (e.g. a drag destination) from a bare string:
|
|
1039
|
+
a value starting with #/./[ is treated as a css selector, anything else as
|
|
1040
|
+
visible text. (Mirrors _target_locator_code so live and codegen agree.)"""
|
|
1041
|
+
v = str(value).strip()
|
|
1042
|
+
if v.startswith(("#", ".", "[")):
|
|
1043
|
+
return d.locator(v).first
|
|
1044
|
+
return d.get_by_text(v).first
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _target_locator_code(value: str) -> str:
|
|
1048
|
+
v = str(value).strip()
|
|
1049
|
+
if v.startswith(("#", ".", "[")):
|
|
1050
|
+
return f"locator('{_escape_locator_value(v)}').first"
|
|
1051
|
+
return f"get_by_text('{_escape_locator_value(v)}').first"
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
class ScrollIntoViewHandler(ActionHandler):
|
|
1055
|
+
"""Scroll an element into the viewport (element-targeted, not blind swipe)."""
|
|
1056
|
+
|
|
1057
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
1058
|
+
if not _web_only("scroll_into_view", platform):
|
|
1059
|
+
return False
|
|
1060
|
+
try:
|
|
1061
|
+
element.scroll_into_view_if_needed(timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
1062
|
+
return True
|
|
1063
|
+
except Exception:
|
|
1064
|
+
log.warning("❌ [Action] scroll_into_view timed out")
|
|
1065
|
+
return False
|
|
1066
|
+
|
|
1067
|
+
def generate_code(self, platform, u2_key, l_value, extra_value, timeout, resolve_ref=None) -> list:
|
|
1068
|
+
loc = build_locator_code(platform, u2_key, l_value, resolve_ref=resolve_ref)
|
|
1069
|
+
return [
|
|
1070
|
+
f" with allure.step('Scroll into view: [{l_value}]'):\n",
|
|
1071
|
+
f" log.info('Action: scroll [{l_value}] into view')\n",
|
|
1072
|
+
f" d.{loc}.scroll_into_view_if_needed(timeout={timeout * 1000})\n",
|
|
1073
|
+
]
|
|
1074
|
+
|
|
1075
|
+
def get_log_message(self, l_type, l_value, extra_value) -> str:
|
|
1076
|
+
return f"[Action] Scroll into view: {l_type}='{l_value}'"
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
class SelectHandler(ActionHandler):
|
|
1080
|
+
"""Select an <option> in a native <select> by its label/value."""
|
|
1081
|
+
|
|
1082
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
1083
|
+
if not _web_only("select", platform):
|
|
1084
|
+
return False
|
|
1085
|
+
try:
|
|
1086
|
+
element.wait_for(state="visible", timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
1087
|
+
element.select_option(extra_value, timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
1088
|
+
return True
|
|
1089
|
+
except Exception as e:
|
|
1090
|
+
log.warning(f"❌ [Action] select_option('{extra_value}') failed: {e}")
|
|
1091
|
+
return False
|
|
1092
|
+
|
|
1093
|
+
def generate_code(self, platform, u2_key, l_value, extra_value, timeout, resolve_ref=None) -> list:
|
|
1094
|
+
loc = build_locator_code(platform, u2_key, l_value, resolve_ref=resolve_ref)
|
|
1095
|
+
safe = _escape_python_string(extra_value)
|
|
1096
|
+
return [
|
|
1097
|
+
f" with allure.step('Select [{safe}] in [{l_value}]'):\n",
|
|
1098
|
+
f" log.info('Action: select [{safe}] in [{l_value}]')\n",
|
|
1099
|
+
f" d.{loc}.select_option('{safe}', timeout={timeout * 1000})\n",
|
|
1100
|
+
]
|
|
1101
|
+
|
|
1102
|
+
def get_log_message(self, l_type, l_value, extra_value) -> str:
|
|
1103
|
+
return f"[Action] Select: {l_type}='{l_value}', option='{extra_value}'"
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
class UploadHandler(ActionHandler):
|
|
1107
|
+
"""Set a file <input>'s files (file upload). extra_value is the file path."""
|
|
1108
|
+
|
|
1109
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
1110
|
+
if not _web_only("upload", platform):
|
|
1111
|
+
return False
|
|
1112
|
+
try:
|
|
1113
|
+
# No wait_for(visible): file <input>s are routinely display:none
|
|
1114
|
+
# (styled label over a hidden input); set_input_files targets hidden
|
|
1115
|
+
# inputs by design, so a visibility wait would wrongly break it.
|
|
1116
|
+
element.set_input_files(extra_value, timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
1117
|
+
return True
|
|
1118
|
+
except Exception as e:
|
|
1119
|
+
log.warning(f"❌ [Action] set_input_files('{extra_value}') failed: {e}")
|
|
1120
|
+
return False
|
|
1121
|
+
|
|
1122
|
+
def generate_code(self, platform, u2_key, l_value, extra_value, timeout, resolve_ref=None) -> list:
|
|
1123
|
+
loc = build_locator_code(platform, u2_key, l_value, resolve_ref=resolve_ref)
|
|
1124
|
+
safe = _escape_python_string(extra_value)
|
|
1125
|
+
return [
|
|
1126
|
+
f" with allure.step('Upload [{safe}] to [{l_value}]'):\n",
|
|
1127
|
+
f" log.info('Action: upload [{safe}] to [{l_value}]')\n",
|
|
1128
|
+
f" d.{loc}.set_input_files('{safe}', timeout={timeout * 1000})\n",
|
|
1129
|
+
]
|
|
1130
|
+
|
|
1131
|
+
def get_log_message(self, l_type, l_value, extra_value) -> str:
|
|
1132
|
+
return f"[Action] Upload: {l_type}='{l_value}', file='{extra_value}'"
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
class DoubleClickHandler(ActionHandler):
|
|
1136
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
1137
|
+
if not _web_only("double_click", platform):
|
|
1138
|
+
return False
|
|
1139
|
+
try:
|
|
1140
|
+
element.wait_for(state="visible", timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
1141
|
+
element.dblclick(timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
1142
|
+
return True
|
|
1143
|
+
except Exception as e:
|
|
1144
|
+
log.warning(f"❌ [Action] dblclick failed: {e}")
|
|
1145
|
+
return False
|
|
1146
|
+
|
|
1147
|
+
def generate_code(self, platform, u2_key, l_value, extra_value, timeout, resolve_ref=None) -> list:
|
|
1148
|
+
loc = build_locator_code(platform, u2_key, l_value, resolve_ref=resolve_ref)
|
|
1149
|
+
return [
|
|
1150
|
+
f" with allure.step('Double click: [{l_value}]'):\n",
|
|
1151
|
+
f" log.info('Action: double click [{l_value}]')\n",
|
|
1152
|
+
f" d.{loc}.dblclick(timeout={timeout * 1000})\n",
|
|
1153
|
+
]
|
|
1154
|
+
|
|
1155
|
+
def get_log_message(self, l_type, l_value, extra_value) -> str:
|
|
1156
|
+
return f"[Action] Double click: {l_type}='{l_value}'"
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
class RightClickHandler(ActionHandler):
|
|
1160
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
1161
|
+
if not _web_only("right_click", platform):
|
|
1162
|
+
return False
|
|
1163
|
+
try:
|
|
1164
|
+
element.wait_for(state="visible", timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
1165
|
+
element.click(button="right", timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
1166
|
+
return True
|
|
1167
|
+
except Exception as e:
|
|
1168
|
+
log.warning(f"❌ [Action] right click failed: {e}")
|
|
1169
|
+
return False
|
|
1170
|
+
|
|
1171
|
+
def generate_code(self, platform, u2_key, l_value, extra_value, timeout, resolve_ref=None) -> list:
|
|
1172
|
+
loc = build_locator_code(platform, u2_key, l_value, resolve_ref=resolve_ref)
|
|
1173
|
+
return [
|
|
1174
|
+
f" with allure.step('Right click: [{l_value}]'):\n",
|
|
1175
|
+
f" log.info('Action: right click [{l_value}]')\n",
|
|
1176
|
+
f" d.{loc}.click(button='right', timeout={timeout * 1000})\n",
|
|
1177
|
+
]
|
|
1178
|
+
|
|
1179
|
+
def get_log_message(self, l_type, l_value, extra_value) -> str:
|
|
1180
|
+
return f"[Action] Right click: {l_type}='{l_value}'"
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
class DragHandler(ActionHandler):
|
|
1184
|
+
"""Drag the source element onto a target. The source is the action's
|
|
1185
|
+
locator; the target is extra_value, auto-detected (css if #/./[ else text)."""
|
|
1186
|
+
|
|
1187
|
+
def execute(self, d, element, platform: str, extra_value: str) -> bool:
|
|
1188
|
+
if not _web_only("drag", platform):
|
|
1189
|
+
return False
|
|
1190
|
+
if not str(extra_value).strip():
|
|
1191
|
+
log.warning("❌ [Action] drag requires a target in extra_value")
|
|
1192
|
+
return False
|
|
1193
|
+
try:
|
|
1194
|
+
target = _autodetect_web_target(d, extra_value)
|
|
1195
|
+
element.drag_to(target, timeout=config.DEFAULT_TIMEOUT * 1000)
|
|
1196
|
+
return True
|
|
1197
|
+
except Exception as e:
|
|
1198
|
+
log.warning(f"❌ [Action] drag_to failed: {e}")
|
|
1199
|
+
return False
|
|
1200
|
+
|
|
1201
|
+
def generate_code(self, platform, u2_key, l_value, extra_value, timeout, resolve_ref=None) -> list:
|
|
1202
|
+
loc = build_locator_code(platform, u2_key, l_value, resolve_ref=resolve_ref)
|
|
1203
|
+
target_loc = _target_locator_code(extra_value)
|
|
1204
|
+
safe_target = _escape_python_string(extra_value)
|
|
1205
|
+
return [
|
|
1206
|
+
f" with allure.step('Drag [{l_value}] to [{safe_target}]'):\n",
|
|
1207
|
+
f" log.info('Action: drag [{l_value}] to [{safe_target}]')\n",
|
|
1208
|
+
f" d.{loc}.drag_to(d.{target_loc}, timeout={timeout * 1000})\n",
|
|
1209
|
+
]
|
|
1210
|
+
|
|
1211
|
+
def get_log_message(self, l_type, l_value, extra_value) -> str:
|
|
1212
|
+
return f"[Action] Drag: {l_type}='{l_value}' to '{extra_value}'"
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
class UIExecutor:
|
|
1216
|
+
_handlers = {}
|
|
1217
|
+
|
|
1218
|
+
def __init__(self, device, platform="android"):
|
|
1219
|
+
self.d = device
|
|
1220
|
+
self.platform = platform
|
|
1221
|
+
# Web ref cache (@N -> element), bound to THIS instance. The
|
|
1222
|
+
# SharedAdapterManager keeps one executor per platform, so an inspect_ui
|
|
1223
|
+
# and a follow-up `ref @N` action in the same MCP session share this
|
|
1224
|
+
# cache while separate sessions/pages can never leak into each other.
|
|
1225
|
+
self._cached_ui_elements: list[dict] = []
|
|
1226
|
+
if not self._handlers:
|
|
1227
|
+
self._handlers = {
|
|
1228
|
+
"click": ClickHandler(),
|
|
1229
|
+
"long_click": LongClickHandler(),
|
|
1230
|
+
"hover": HoverHandler(),
|
|
1231
|
+
"input": InputHandler(),
|
|
1232
|
+
"swipe": SwipeHandler(),
|
|
1233
|
+
"press": PressHandler(),
|
|
1234
|
+
"goto": GotoHandler(),
|
|
1235
|
+
"scroll_into_view": ScrollIntoViewHandler(),
|
|
1236
|
+
"select": SelectHandler(),
|
|
1237
|
+
"upload": UploadHandler(),
|
|
1238
|
+
"double_click": DoubleClickHandler(),
|
|
1239
|
+
"right_click": RightClickHandler(),
|
|
1240
|
+
"drag": DragHandler(),
|
|
1241
|
+
"wait_for": WaitForHandler(),
|
|
1242
|
+
"assert_exist": AssertExistHandler(),
|
|
1243
|
+
"assert_not_exist": AssertNotExistHandler(),
|
|
1244
|
+
"assert_text_equals": AssertTextEqualsHandler(),
|
|
1245
|
+
"assert_text_contains": AssertTextContainsHandler(),
|
|
1246
|
+
"assert_value": AssertValueHandler(),
|
|
1247
|
+
"assert_url": AssertUrlHandler(),
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
@classmethod
|
|
1251
|
+
def register_handler(cls, action_type: str, handler: ActionHandler):
|
|
1252
|
+
cls._handlers[action_type] = handler
|
|
1253
|
+
|
|
1254
|
+
def set_ui_elements(self, elements: list[dict]) -> None:
|
|
1255
|
+
self._cached_ui_elements = list(elements) if elements else []
|
|
1256
|
+
|
|
1257
|
+
def resolve_ref(self, ref_value: str) -> dict | None:
|
|
1258
|
+
for el in self._cached_ui_elements:
|
|
1259
|
+
if el.get("ref") == ref_value:
|
|
1260
|
+
return el
|
|
1261
|
+
return None
|
|
1262
|
+
|
|
1263
|
+
def execute_and_record(self, action_data: dict, file_obj=None) -> dict:
|
|
1264
|
+
action = action_data.get("action")
|
|
1265
|
+
l_type = action_data.get("locator_type", "global")
|
|
1266
|
+
l_value = action_data.get("locator_value", "global")
|
|
1267
|
+
extra_value = action_data.get("extra_value", "")
|
|
1268
|
+
|
|
1269
|
+
result = {
|
|
1270
|
+
"success": False,
|
|
1271
|
+
"code_lines": [],
|
|
1272
|
+
"action_description": "",
|
|
1273
|
+
"error_code": "",
|
|
1274
|
+
"action_info": {
|
|
1275
|
+
"action_type": action,
|
|
1276
|
+
"locator_type": l_type,
|
|
1277
|
+
"locator_value": l_value,
|
|
1278
|
+
"extra_value": extra_value,
|
|
1279
|
+
},
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
if not action:
|
|
1283
|
+
log.warning(format_log("E035"))
|
|
1284
|
+
result["error_code"] = "E035"
|
|
1285
|
+
return result
|
|
1286
|
+
|
|
1287
|
+
handler = self._handlers.get(action)
|
|
1288
|
+
if not handler:
|
|
1289
|
+
log.error(format_log("E031"))
|
|
1290
|
+
result["error_code"] = "E031"
|
|
1291
|
+
return result
|
|
1292
|
+
|
|
1293
|
+
element = None
|
|
1294
|
+
u2_key = ""
|
|
1295
|
+
needs_locator = (
|
|
1296
|
+
l_value
|
|
1297
|
+
and str(l_value).lower() != "global"
|
|
1298
|
+
and str(l_type).lower() != "global"
|
|
1299
|
+
)
|
|
1300
|
+
if needs_locator:
|
|
1301
|
+
if not str(l_type).strip():
|
|
1302
|
+
log.error(format_log("E032"))
|
|
1303
|
+
result["error_code"] = "E032"
|
|
1304
|
+
return result
|
|
1305
|
+
|
|
1306
|
+
u2_locator_map = {
|
|
1307
|
+
"resourceId": "resourceId",
|
|
1308
|
+
"text": "text",
|
|
1309
|
+
"description": "description",
|
|
1310
|
+
"id": "resourceId",
|
|
1311
|
+
"ref": "ref",
|
|
1312
|
+
}
|
|
1313
|
+
u2_key = u2_locator_map.get(l_type, l_type)
|
|
1314
|
+
|
|
1315
|
+
if u2_key == "ref" and self.platform == "web":
|
|
1316
|
+
# Always re-inspect the live page before resolving a web ref.
|
|
1317
|
+
# compress_web_dom assigns @N by ordinal, so a fresh inspect
|
|
1318
|
+
# keeps @N aligned with the page the agent is actually looking
|
|
1319
|
+
# at. The cache is bound to this executor instance (not a
|
|
1320
|
+
# process-global), so the refresh can't bleed into another
|
|
1321
|
+
# session.
|
|
1322
|
+
try:
|
|
1323
|
+
import json as _json
|
|
1324
|
+
|
|
1325
|
+
from utils.utils_web import compress_web_dom
|
|
1326
|
+
ui_json = compress_web_dom(self.d)
|
|
1327
|
+
tree = _json.loads(ui_json)
|
|
1328
|
+
self.set_ui_elements(tree.get("ui_elements", []))
|
|
1329
|
+
log.info(f"🔍 [Ref] Refreshed ref cache from live page: {len(self._cached_ui_elements)} elements")
|
|
1330
|
+
except Exception as e:
|
|
1331
|
+
log.warning(f"⚠️ [Ref] Live re-inspect failed, using existing cache: {e}")
|
|
1332
|
+
|
|
1333
|
+
try:
|
|
1334
|
+
element = get_actual_element(
|
|
1335
|
+
self.d, self.platform, u2_key, l_value, resolve_ref=self.resolve_ref
|
|
1336
|
+
)
|
|
1337
|
+
except Exception as e:
|
|
1338
|
+
log.warning(f"⚠️ [Warning] Element locator resolution failed: {e}")
|
|
1339
|
+
return result
|
|
1340
|
+
|
|
1341
|
+
if element is None and u2_key == "ref" and self.platform == "web":
|
|
1342
|
+
el_data = self.resolve_ref(l_value)
|
|
1343
|
+
# The id→text chain in get_actual_element missed this ref, but the
|
|
1344
|
+
# element dict may still carry name/role/desc/placeholder. Recover
|
|
1345
|
+
# the SAME stable locator we'd emit (build_fallback_locator) as a
|
|
1346
|
+
# LIVE handle and re-drive the action's own handler on it — so an
|
|
1347
|
+
# input stays an input, a hover stays a hover, etc. The handler's
|
|
1348
|
+
# execute() validates the locator resolves (discharges the "locator
|
|
1349
|
+
# points at a different element than the pixel" trap) and its
|
|
1350
|
+
# generate_code() persists that stable locator — never a pixel.
|
|
1351
|
+
fb_element = get_fallback_element(self.d, el_data) if el_data else None
|
|
1352
|
+
if fb_element is not None and build_fallback_locator(el_data):
|
|
1353
|
+
try:
|
|
1354
|
+
if handler.execute(self.d, fb_element, self.platform, extra_value):
|
|
1355
|
+
# generate_code with u2_key="ref" + the live resolve_ref
|
|
1356
|
+
# rebuilds the SAME stable fragment build_fallback_locator
|
|
1357
|
+
# produced (build_code's ref branch calls it) — so the
|
|
1358
|
+
# persisted locator exactly matches the handle we just
|
|
1359
|
+
# acted on. Then humanize the [@N] labels to readable.
|
|
1360
|
+
code_lines = handler.generate_code(
|
|
1361
|
+
self.platform, "ref", l_value, extra_value,
|
|
1362
|
+
config.DEFAULT_TIMEOUT, resolve_ref=self.resolve_ref,
|
|
1363
|
+
)
|
|
1364
|
+
readable = readable_ref_target(el_data)
|
|
1365
|
+
if readable and readable != l_value:
|
|
1366
|
+
code_lines = humanize_step_labels(code_lines, l_value, readable)
|
|
1367
|
+
result["success"] = True
|
|
1368
|
+
result["code_lines"] = code_lines
|
|
1369
|
+
result["action_description"] = (
|
|
1370
|
+
f"🔁 [Ref] {action} {l_value} via recovered locator"
|
|
1371
|
+
)
|
|
1372
|
+
return result
|
|
1373
|
+
log.warning(f"⚠️ [Ref] Recovered locator did not satisfy {action}")
|
|
1374
|
+
except Exception as e:
|
|
1375
|
+
log.warning(f"⚠️ [Ref] Recovered locator failed to act: {e}")
|
|
1376
|
+
# No stable locator. Act LIVE via coordinate ONLY for click (the
|
|
1377
|
+
# sole action a bare coordinate can perform) so a recording session
|
|
1378
|
+
# still advances; persist an honest, non-passing skip rather than a
|
|
1379
|
+
# silently-rotting coordinate. For non-click actions there is
|
|
1380
|
+
# nothing a coordinate can do — report a real engine failure.
|
|
1381
|
+
if action == "click" and el_data and el_data.get("w", 0) > 0:
|
|
1382
|
+
cx = el_data["x"] + el_data["w"] // 2
|
|
1383
|
+
cy = el_data["y"] + el_data["h"] // 2
|
|
1384
|
+
try:
|
|
1385
|
+
self.d.mouse.click(cx, cy)
|
|
1386
|
+
log.info(f"🎯 [Ref] {l_value} clicked at ({cx}, {cy}) live (not persisted)")
|
|
1387
|
+
except Exception as e:
|
|
1388
|
+
log.error(f"❌ [Error] Coordinate click failed: {e}")
|
|
1389
|
+
return result
|
|
1390
|
+
log.warning(
|
|
1391
|
+
f"[E036] Ref {l_value} has no stable locator (only coordinates). "
|
|
1392
|
+
f"Emitting pytest.skip so the test isn't silently green."
|
|
1393
|
+
)
|
|
1394
|
+
code_lines = [
|
|
1395
|
+
f" with allure.step('Click: [{l_value}] (UNREPLAYABLE)'):\n",
|
|
1396
|
+
" import pytest\n",
|
|
1397
|
+
f" # Ref {l_value} could only be located by coordinate at record time;\n",
|
|
1398
|
+
" # no durable selector exists. Provide one before treating this as real.\n",
|
|
1399
|
+
f" pytest.skip('Ref {l_value} has no durable locator (coordinate-only at record time)')\n",
|
|
1400
|
+
]
|
|
1401
|
+
result["success"] = True
|
|
1402
|
+
result["code_lines"] = code_lines
|
|
1403
|
+
result["action_description"] = f"⏭️ [Ref] {l_value} unreplayable — skip emitted"
|
|
1404
|
+
return result
|
|
1405
|
+
log.error(format_log("E037") + f" (ref={l_value}, action='{action}')")
|
|
1406
|
+
result["error_code"] = "E037"
|
|
1407
|
+
return result
|
|
1408
|
+
|
|
1409
|
+
# Visual fallback can only CLICK (a VLM returns a point, nothing
|
|
1410
|
+
# else) — never engage it for input/hover/assert/etc.
|
|
1411
|
+
if element is None and self.platform == "web" and action == "click":
|
|
1412
|
+
try:
|
|
1413
|
+
from common.visual_fallback import visual_locate
|
|
1414
|
+
screenshot_bytes = self.d.screenshot()
|
|
1415
|
+
coords = visual_locate(
|
|
1416
|
+
screenshot_bytes,
|
|
1417
|
+
f"{l_type}={l_value}",
|
|
1418
|
+
)
|
|
1419
|
+
if coords:
|
|
1420
|
+
cx, cy = coords
|
|
1421
|
+
log.info(f"👁️ [Visual Fallback] Using VLM coordinates: ({cx}, {cy})")
|
|
1422
|
+
# Click live so the session advances, but the visual
|
|
1423
|
+
# fallback has NO DOM node — only a VLM screenshot hit —
|
|
1424
|
+
# so there is no durable locator to emit. Persist a skip
|
|
1425
|
+
# (with the coordinate kept as a comment hint) rather than
|
|
1426
|
+
# a coordinate that will rot and pass green against the
|
|
1427
|
+
# wrong pixel later.
|
|
1428
|
+
self.d.mouse.click(cx, cy)
|
|
1429
|
+
code_lines = [
|
|
1430
|
+
f" with allure.step('Click: [{l_value}] (UNREPLAYABLE: visual)'):\n",
|
|
1431
|
+
" import pytest\n",
|
|
1432
|
+
f" # Located only via VLM screenshot at ({cx}, {cy}); no DOM locator exists.\n",
|
|
1433
|
+
f" # d.mouse.click({cx}, {cy}) # original visual hit, reference only\n",
|
|
1434
|
+
f" pytest.skip('Visual-fallback step for [{l_value}] has no durable locator; add a selector to make it replayable')\n",
|
|
1435
|
+
]
|
|
1436
|
+
result["success"] = True
|
|
1437
|
+
result["code_lines"] = code_lines
|
|
1438
|
+
result["action_description"] = f"⏭️ [Visual Fallback] {l_value} unreplayable — skip emitted"
|
|
1439
|
+
return result
|
|
1440
|
+
except Exception as e:
|
|
1441
|
+
log.warning(f"⚠️ [Visual Fallback] Attempt failed: {e}")
|
|
1442
|
+
|
|
1443
|
+
if element is None:
|
|
1444
|
+
log.error(format_log("E033"))
|
|
1445
|
+
result["error_code"] = "E033"
|
|
1446
|
+
return result
|
|
1447
|
+
|
|
1448
|
+
timeout = config.DEFAULT_TIMEOUT
|
|
1449
|
+
|
|
1450
|
+
try:
|
|
1451
|
+
log.info(handler.get_log_message(l_type, l_value, extra_value))
|
|
1452
|
+
|
|
1453
|
+
# Use `is not None`, NOT truthiness: android's UiObject is falsy when
|
|
1454
|
+
# it currently matches 0 elements, but it's a valid resolved handle —
|
|
1455
|
+
# the handler must still run (e.g. assert_exist needs to wait and then
|
|
1456
|
+
# report a real failure). Keying on `element` (truthy) would skip
|
|
1457
|
+
# execution for an absent android element and wrongly report success.
|
|
1458
|
+
if element is not None or action in GLOBAL_ACTIONS:
|
|
1459
|
+
if not handler.execute(self.d, element, self.platform, extra_value):
|
|
1460
|
+
if action in ASSERTION_ACTIONS:
|
|
1461
|
+
# A failed assertion is a verification verdict (the SUT
|
|
1462
|
+
# did not meet the assertion), NOT an engine error. Tag
|
|
1463
|
+
# it so callers / --json can tell the two apart.
|
|
1464
|
+
result["assertion_failed"] = True
|
|
1465
|
+
log.error(
|
|
1466
|
+
f"❌ [Assert] Assertion failed: {action} "
|
|
1467
|
+
f"{l_type}='{l_value}'"
|
|
1468
|
+
)
|
|
1469
|
+
else:
|
|
1470
|
+
log.error(format_log("E038"))
|
|
1471
|
+
result["error_code"] = "E038"
|
|
1472
|
+
return result
|
|
1473
|
+
|
|
1474
|
+
safe_u2_key = u2_key if needs_locator else ""
|
|
1475
|
+
code_lines = handler.generate_code(
|
|
1476
|
+
self.platform, safe_u2_key, l_value, extra_value, timeout,
|
|
1477
|
+
resolve_ref=self.resolve_ref,
|
|
1478
|
+
)
|
|
1479
|
+
|
|
1480
|
+
# Single choke point for readable allure.step labels: when a web ref
|
|
1481
|
+
# (@N) was used, the locator line already carries the resolved
|
|
1482
|
+
# text/id, but the step/log labels still say "[@N]". Swap the bracket
|
|
1483
|
+
# token for the human-readable target so reports read cleanly.
|
|
1484
|
+
if self.platform == "web" and safe_u2_key == "ref":
|
|
1485
|
+
readable = readable_ref_target(self.resolve_ref(l_value))
|
|
1486
|
+
if readable and readable != l_value:
|
|
1487
|
+
code_lines = humanize_step_labels(code_lines, l_value, readable)
|
|
1488
|
+
|
|
1489
|
+
result["success"] = True
|
|
1490
|
+
result["code_lines"] = code_lines
|
|
1491
|
+
result["action_description"] = handler.get_log_message(
|
|
1492
|
+
l_type, l_value, extra_value
|
|
1493
|
+
)
|
|
1494
|
+
|
|
1495
|
+
if file_obj is not None:
|
|
1496
|
+
for line in code_lines:
|
|
1497
|
+
file_obj.write(line)
|
|
1498
|
+
file_obj.flush()
|
|
1499
|
+
|
|
1500
|
+
return result
|
|
1501
|
+
|
|
1502
|
+
except Exception as e:
|
|
1503
|
+
log.error(f"❌ [Execute Error] Exception during execution: {e}")
|
|
1504
|
+
return result
|