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.
Files changed (64) hide show
  1. cli/__init__.py +0 -0
  2. cli/_version.py +1 -0
  3. cli/dispatch.py +266 -0
  4. cli/doctor.py +487 -0
  5. cli/modes/__init__.py +0 -0
  6. cli/modes/action.py +262 -0
  7. cli/modes/default.py +248 -0
  8. cli/modes/demo.py +162 -0
  9. cli/modes/dry_run.py +237 -0
  10. cli/modes/init.py +133 -0
  11. cli/modes/plan.py +148 -0
  12. cli/modes/workflow.py +354 -0
  13. cli/parser.py +305 -0
  14. cli/reporter.py +207 -0
  15. cli/session.py +146 -0
  16. cli/shared.py +427 -0
  17. cli/shorthand.py +90 -0
  18. cli/tool_protocol_handlers.py +446 -0
  19. common/__init__.py +0 -0
  20. common/adapters/__init__.py +21 -0
  21. common/adapters/android_adapter.py +273 -0
  22. common/adapters/base_adapter.py +24 -0
  23. common/adapters/ios_adapter.py +278 -0
  24. common/adapters/web_adapter.py +271 -0
  25. common/ai.py +277 -0
  26. common/ai_autonomous.py +273 -0
  27. common/ai_heal.py +222 -0
  28. common/cache/__init__.py +15 -0
  29. common/cache/cache_hash.py +57 -0
  30. common/cache/cache_manager.py +300 -0
  31. common/cache/cache_stats.py +133 -0
  32. common/cache/cache_storage.py +79 -0
  33. common/cache/embedding_loader.py +150 -0
  34. common/capabilities.py +121 -0
  35. common/case_memory.py +327 -0
  36. common/error_codes.py +61 -0
  37. common/exceptions.py +18 -0
  38. common/executor.py +1504 -0
  39. common/failure_diagnosis.py +138 -0
  40. common/history_manager.py +75 -0
  41. common/logs.py +168 -0
  42. common/mcp_server.py +467 -0
  43. common/preflight.py +496 -0
  44. common/progress.py +37 -0
  45. common/run_reporter.py +415 -0
  46. common/run_resume.py +149 -0
  47. common/runtime_modes.py +35 -0
  48. common/tool_protocol.py +196 -0
  49. common/visual_fallback.py +71 -0
  50. common/workflow_schema.py +150 -0
  51. config/__init__.py +0 -0
  52. config/config.py +167 -0
  53. config/env_loader.py +76 -0
  54. screenforge-0.4.0.dist-info/METADATA +43 -0
  55. screenforge-0.4.0.dist-info/RECORD +64 -0
  56. screenforge-0.4.0.dist-info/WHEEL +5 -0
  57. screenforge-0.4.0.dist-info/entry_points.txt +2 -0
  58. screenforge-0.4.0.dist-info/licenses/LICENSE +21 -0
  59. screenforge-0.4.0.dist-info/top_level.txt +4 -0
  60. utils/__init__.py +0 -0
  61. utils/screenshot_annotator.py +60 -0
  62. utils/utils_ios.py +195 -0
  63. utils/utils_web.py +304 -0
  64. 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