persona-dsl 26.1.21.44__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 (86) hide show
  1. persona_dsl/__init__.py +35 -0
  2. persona_dsl/components/action.py +10 -0
  3. persona_dsl/components/base_step.py +251 -0
  4. persona_dsl/components/combined_step.py +68 -0
  5. persona_dsl/components/expectation.py +10 -0
  6. persona_dsl/components/fact.py +10 -0
  7. persona_dsl/components/goal.py +10 -0
  8. persona_dsl/components/ops.py +7 -0
  9. persona_dsl/components/step.py +75 -0
  10. persona_dsl/expectations/generic/__init__.py +15 -0
  11. persona_dsl/expectations/generic/contains_item.py +19 -0
  12. persona_dsl/expectations/generic/contains_the_text.py +15 -0
  13. persona_dsl/expectations/generic/has_entries.py +21 -0
  14. persona_dsl/expectations/generic/is_equal.py +24 -0
  15. persona_dsl/expectations/generic/is_greater_than.py +18 -0
  16. persona_dsl/expectations/generic/path_equal.py +27 -0
  17. persona_dsl/expectations/web/__init__.py +5 -0
  18. persona_dsl/expectations/web/is_displayed.py +13 -0
  19. persona_dsl/expectations/web/matches_aria_snapshot.py +222 -0
  20. persona_dsl/expectations/web/matches_screenshot.py +160 -0
  21. persona_dsl/generators/__init__.py +5 -0
  22. persona_dsl/generators/api_generator.py +423 -0
  23. persona_dsl/generators/cli.py +431 -0
  24. persona_dsl/generators/page_generator.py +1140 -0
  25. persona_dsl/ops/api/__init__.py +5 -0
  26. persona_dsl/ops/api/json_as.py +104 -0
  27. persona_dsl/ops/api/json_response.py +48 -0
  28. persona_dsl/ops/api/send_request.py +41 -0
  29. persona_dsl/ops/db/__init__.py +5 -0
  30. persona_dsl/ops/db/execute_sql.py +22 -0
  31. persona_dsl/ops/db/fetch_all.py +29 -0
  32. persona_dsl/ops/db/fetch_one.py +22 -0
  33. persona_dsl/ops/kafka/__init__.py +4 -0
  34. persona_dsl/ops/kafka/message_in_topic.py +89 -0
  35. persona_dsl/ops/kafka/send_message.py +35 -0
  36. persona_dsl/ops/soap/__init__.py +4 -0
  37. persona_dsl/ops/soap/call_operation.py +24 -0
  38. persona_dsl/ops/soap/operation_result.py +24 -0
  39. persona_dsl/ops/web/__init__.py +37 -0
  40. persona_dsl/ops/web/aria_snapshot.py +87 -0
  41. persona_dsl/ops/web/click.py +30 -0
  42. persona_dsl/ops/web/current_path.py +17 -0
  43. persona_dsl/ops/web/element_attribute.py +24 -0
  44. persona_dsl/ops/web/element_is_visible.py +27 -0
  45. persona_dsl/ops/web/element_text.py +28 -0
  46. persona_dsl/ops/web/elements_count.py +42 -0
  47. persona_dsl/ops/web/fill.py +41 -0
  48. persona_dsl/ops/web/generate_page_object.py +118 -0
  49. persona_dsl/ops/web/input_value.py +23 -0
  50. persona_dsl/ops/web/navigate.py +52 -0
  51. persona_dsl/ops/web/press_key.py +37 -0
  52. persona_dsl/ops/web/rich_aria_snapshot.py +159 -0
  53. persona_dsl/ops/web/screenshot.py +68 -0
  54. persona_dsl/ops/web/table_data.py +43 -0
  55. persona_dsl/ops/web/wait_for_navigation.py +23 -0
  56. persona_dsl/pages/__init__.py +133 -0
  57. persona_dsl/pages/elements.py +998 -0
  58. persona_dsl/pages/page.py +44 -0
  59. persona_dsl/pages/virtual_page.py +94 -0
  60. persona_dsl/persona.py +125 -0
  61. persona_dsl/pytest_plugin.py +1230 -0
  62. persona_dsl/runtime/dist/persona_bundle.js +1077 -0
  63. persona_dsl/skills/__init__.py +7 -0
  64. persona_dsl/skills/core/base.py +41 -0
  65. persona_dsl/skills/core/skill_definition.py +30 -0
  66. persona_dsl/skills/use_api.py +251 -0
  67. persona_dsl/skills/use_browser.py +78 -0
  68. persona_dsl/skills/use_database.py +129 -0
  69. persona_dsl/skills/use_kafka.py +135 -0
  70. persona_dsl/skills/use_soap.py +66 -0
  71. persona_dsl/utils/__init__.py +0 -0
  72. persona_dsl/utils/artifacts.py +22 -0
  73. persona_dsl/utils/config.py +54 -0
  74. persona_dsl/utils/data_providers.py +159 -0
  75. persona_dsl/utils/decorators.py +80 -0
  76. persona_dsl/utils/metrics.py +69 -0
  77. persona_dsl/utils/naming.py +14 -0
  78. persona_dsl/utils/path.py +202 -0
  79. persona_dsl/utils/retry.py +51 -0
  80. persona_dsl/utils/taas_integration.py +124 -0
  81. persona_dsl/utils/waits.py +112 -0
  82. persona_dsl-26.1.21.44.dist-info/METADATA +233 -0
  83. persona_dsl-26.1.21.44.dist-info/RECORD +86 -0
  84. persona_dsl-26.1.21.44.dist-info/WHEEL +5 -0
  85. persona_dsl-26.1.21.44.dist-info/entry_points.txt +6 -0
  86. persona_dsl-26.1.21.44.dist-info/top_level.txt +1 -0
@@ -0,0 +1,998 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum
6
+ from typing import Any, Dict, List, Optional, Type, Iterator
7
+
8
+
9
+ class Strategy(str, Enum):
10
+ """Стратегии поиска элемента."""
11
+
12
+ TEST_ID = "test_id"
13
+ ROLE = "role"
14
+ TEXT = "text"
15
+ PLACEHOLDER = "placeholder"
16
+ ALT_TEXT = "alt_text"
17
+ TITLE = "title"
18
+ LOCATOR = "locator" # css/xpath
19
+ REF = "ref" # data-persona-id (Highest Priority with Runtime)
20
+ LINK = "link" # href
21
+ ALL = "all" # Использовать все доступные
22
+
23
+
24
+ @dataclass
25
+ class Element:
26
+ """
27
+ Базовый класс для элементов страницы с поддержкой Multi-Strategy Resolution и Hybrid Navigation.
28
+ """
29
+
30
+ name: str
31
+ role: Optional[str] = None
32
+ accessible_name: Optional[str] = None
33
+ text: Optional[str | Any] = None
34
+ label: Optional[str] = None
35
+ placeholder: Optional[str] = None
36
+ test_id: Optional[str] = None
37
+ alt_text: Optional[str] = None
38
+ title: Optional[str] = None
39
+ locator: Optional[str] = None
40
+ index: Optional[int] = None
41
+ exact: bool = False
42
+ description: Optional[str] = None
43
+ static_screenshot_path: Optional[str] = None
44
+ static_aria_snapshot_path: Optional[str] = None
45
+ aria_ref: Optional[str] = None
46
+ url: Optional[str] = None # Для Strategy.LINK
47
+
48
+ _filters: List[Dict[str, Any]] = field(default_factory=list, repr=False, init=False)
49
+ # Список стратегий поиска (если None — используется стандартный приоритет)
50
+ _strategies: Optional[List[Strategy]] = field(default=None, repr=False, init=False)
51
+ parent: Optional["Element"] = field(default=None, repr=False, init=False)
52
+
53
+ # Навигационные поля
54
+ _nav_source: Optional["Element"] = field(default=None, repr=False, init=False)
55
+ _nav_type: Optional[str] = field(
56
+ default=None, repr=False, init=False
57
+ ) # 'child', 'next', 'prev', 'parent'
58
+ _nav_arg: Optional[Any] = field(default=None, repr=False, init=False)
59
+
60
+ # Кэш для Smart Shortcuts
61
+ _shortcut_cache: Dict[str, "Element"] = field(
62
+ default_factory=dict, repr=False, init=False
63
+ )
64
+
65
+ def __post_init__(self) -> None:
66
+ self._elements: Optional[Dict[str, Element | ElementList]] = None
67
+
68
+ @property
69
+ def is_container(self) -> bool:
70
+ return self._elements is not None
71
+
72
+ def using(self, strategies: List[Strategy]) -> "Element":
73
+ """
74
+ Возвращает копию элемента с заданным набором стратегий поиска.
75
+ """
76
+ new_element = copy.copy(self)
77
+ new_element._strategies = strategies
78
+ new_element._filters = list(self._filters)
79
+ return new_element
80
+
81
+ def robust(self) -> "Element":
82
+ """Алиас для использования всех доступных стратегий (Strategy.ALL)."""
83
+ return self.using([Strategy.ALL])
84
+
85
+ def __call__(self, text: str | None = None, **kwargs: Any) -> "Element":
86
+ """
87
+ Возвращает копию элемента с модифицированными атрибутами.
88
+ """
89
+ new_element = copy.copy(self)
90
+ new_element._filters = list(self._filters)
91
+
92
+ for k, v in kwargs.items():
93
+ if hasattr(new_element, k):
94
+ setattr(new_element, k, v)
95
+
96
+ if text is not None:
97
+ if self.role and self.role != "generic":
98
+ new_element.accessible_name = text
99
+ else:
100
+ new_element.text = text
101
+
102
+ return new_element
103
+
104
+ # --- Structural Navigation Methods ---
105
+
106
+ def _create_nav_element(self, nav_type: str, arg: Any = None) -> "Element":
107
+ """Создает новый элемент, привязанный к текущему через навигацию."""
108
+ # Создаем generic элемент, так как мы не знаем роль цели заранее
109
+ name_suffix = f"_{nav_type}"
110
+ if arg is not None:
111
+ name_suffix += f"_{arg}"
112
+
113
+ new_el = Element(name=f"{self.name}{name_suffix}", role="generic")
114
+ new_el.parent = self # Важно для цепочки resolve
115
+ new_el._nav_source = self
116
+ new_el._nav_type = nav_type
117
+ new_el._nav_arg = arg
118
+ return new_el
119
+
120
+ def child(self, index: int = 0) -> "Element":
121
+ """Возвращает n-го ребенка элемента."""
122
+ return self._create_nav_element("child", index)
123
+
124
+ def first_child(self) -> "Element":
125
+ """Возвращает первого ребенка."""
126
+ return self.child(0)
127
+
128
+ def last_child(self) -> "Element":
129
+ """Возвращает последнего ребенка."""
130
+ return self._create_nav_element("last_child")
131
+
132
+ def next_sibling(self) -> "Element":
133
+ """Возвращает следующий элемент на том же уровне."""
134
+ return self._create_nav_element("next")
135
+
136
+ def prev_sibling(self) -> "Element":
137
+ """Возвращает предыдущий элемент на том же уровне."""
138
+ return self._create_nav_element("prev")
139
+
140
+ def parent_element(self) -> "Element":
141
+ """Возвращает родительский элемент (DOM parent)."""
142
+ return self._create_nav_element("parent")
143
+
144
+ # --- Resolution Logic ---
145
+
146
+ def _resolve_single_strategy(self, base: Any, strategy: Strategy) -> Any:
147
+ """Строит локатор для одной конкретной стратегии."""
148
+ if strategy == Strategy.REF and self.aria_ref:
149
+ return base.locator(f'[data-persona-id="{self.aria_ref}"]')
150
+
151
+ if strategy == Strategy.LINK and self.url:
152
+ # Ищем ссылку с частичным совпадением href
153
+ return base.locator(f'a[href*="{self.url}"]')
154
+
155
+ if strategy == Strategy.TEST_ID and self.test_id:
156
+ return base.get_by_test_id(self.test_id)
157
+
158
+ if strategy == Strategy.ROLE and self.role:
159
+ if self.accessible_name:
160
+ return base.get_by_role(
161
+ self.role, name=self.accessible_name, exact=self.exact
162
+ )
163
+ return base.get_by_role(self.role, exact=self.exact)
164
+
165
+ if strategy == Strategy.TEXT and self.text:
166
+ return base.get_by_text(self.text, exact=self.exact)
167
+
168
+ if strategy == Strategy.PLACEHOLDER and self.placeholder:
169
+ return base.get_by_placeholder(self.placeholder, exact=self.exact)
170
+
171
+ if strategy == Strategy.ALT_TEXT and self.alt_text:
172
+ return base.get_by_alt_text(self.alt_text, exact=self.exact)
173
+
174
+ if strategy == Strategy.TITLE and self.title:
175
+ return base.get_by_title(self.title, exact=self.exact)
176
+
177
+ if strategy == Strategy.LOCATOR and self.locator:
178
+ return base.locator(self.locator)
179
+
180
+ return None
181
+
182
+ def _resolve_navigation_step(self, base: Any) -> Any:
183
+ """
184
+ Гибридный резолвер навигации.
185
+ Пытается использовать Runtime JS, иначе падает в CSS/XPath.
186
+ """
187
+ page = base.page
188
+
189
+ # 1. Hybrid Path: Try JS Runtime if parent has ref
190
+ if self._nav_source and self._nav_source.aria_ref:
191
+ # Проверяем наличие Runtime (быстрая проверка через evaluate handle или просто try/catch в JS)
192
+ # Используем evaluate для атомарности
193
+ js_script = """
194
+ ([ref, type, arg]) => {
195
+ if (!window.__PERSONA__) return null;
196
+ const el = window.__PERSONA__.navigate(ref, type, arg);
197
+ return el ? el.getAttribute('data-persona-id') : null;
198
+ }
199
+ """
200
+ try:
201
+ target_id = page.evaluate(
202
+ js_script,
203
+ [self._nav_source.aria_ref, self._nav_type, self._nav_arg],
204
+ )
205
+
206
+ if target_id:
207
+ # Если JS нашел элемент и вернул ID -> строим прямой локатор
208
+ # Важно: ищем от корня страницы, так как ID уникален
209
+ return page.locator(f'[data-persona-id="{target_id}"]')
210
+ except Exception:
211
+ # Если JS упал или Runtime нет - игнорируем и идем в fallback
212
+ pass
213
+
214
+ # 2. Pure Path: CSS/XPath Fallback
215
+ if self._nav_type == "child":
216
+ # Playwright/CSS nth-child is 1-based
217
+ # :scope > * выбирает всех детей
218
+ return base.locator(":scope > *").nth(self._nav_arg)
219
+
220
+ if self._nav_type == "last_child":
221
+ return base.locator(":scope > *").last
222
+
223
+ if self._nav_type == "next":
224
+ return base.locator("xpath=following-sibling::*[1]")
225
+
226
+ if self._nav_type == "prev":
227
+ return base.locator("xpath=preceding-sibling::*[1]")
228
+
229
+ if self._nav_type == "parent":
230
+ return base.locator("xpath=..")
231
+
232
+ raise ValueError(f"Unknown navigation type: {self._nav_type}")
233
+
234
+ def _get_playwright_locator(self, base: Any) -> Any:
235
+ """Получение локатора с поддержкой Multi-Strategy и Navigation."""
236
+
237
+ # 0. Navigation Step
238
+ if self._nav_type:
239
+ return self._resolve_navigation_step(base)
240
+
241
+ # 1. Сбор локаторов
242
+ locators = []
243
+
244
+ # Приоритет REF стратегии, если есть aria_ref (LCL-47)
245
+ # Если стратегии не заданы явно, добавляем REF в начало списка приоритетов
246
+ strategies_to_check = []
247
+
248
+ if self._strategies:
249
+ if Strategy.ALL in self._strategies:
250
+ strategies_to_check = [
251
+ Strategy.REF,
252
+ Strategy.TEST_ID,
253
+ Strategy.ROLE,
254
+ Strategy.PLACEHOLDER,
255
+ Strategy.ALT_TEXT,
256
+ Strategy.TITLE,
257
+ Strategy.TEXT,
258
+ Strategy.LINK,
259
+ Strategy.LOCATOR,
260
+ ]
261
+ else:
262
+ strategies_to_check = self._strategies
263
+ else:
264
+ # Стандартный приоритет
265
+ # REF > TEST_ID > ROLE ...
266
+ strategies_to_check = []
267
+ if self.aria_ref:
268
+ strategies_to_check.append(Strategy.REF)
269
+ if self.test_id:
270
+ strategies_to_check.append(Strategy.TEST_ID)
271
+ if self.role:
272
+ strategies_to_check.append(Strategy.ROLE)
273
+ if self.label:
274
+ # Label strategy is not in Enum yet, handled via get_by_label below if needed,
275
+ # or we can add it. For now keeping legacy fallback logic structure if strategies not explicit.
276
+ pass
277
+ if self.placeholder:
278
+ strategies_to_check.append(Strategy.PLACEHOLDER)
279
+ if self.alt_text:
280
+ strategies_to_check.append(Strategy.ALT_TEXT)
281
+ if self.title:
282
+ strategies_to_check.append(Strategy.TITLE)
283
+ if self.text:
284
+ strategies_to_check.append(Strategy.TEXT)
285
+ if self.url:
286
+ strategies_to_check.append(Strategy.LINK)
287
+ if self.locator:
288
+ strategies_to_check.append(Strategy.LOCATOR)
289
+
290
+ # Если список стратегий сформирован (явный или неявный)
291
+ if strategies_to_check:
292
+ for strat in strategies_to_check:
293
+ loc = self._resolve_single_strategy(base, strat)
294
+ if loc is not None:
295
+ locators.append(loc)
296
+ else:
297
+ # Fallback для label (так как его нет в Strategy Enum пока) и дефолтов
298
+ if self.label:
299
+ locators.append(base.get_by_label(self.label, exact=self.exact))
300
+
301
+ # Если совсем ничего нет
302
+ if not locators:
303
+ if self.parent is None:
304
+ return base
305
+ raise ValueError(f"Элемент '{self.name}' не имеет стратегии поиска.")
306
+
307
+ # 2. Объединение локаторов (OR)
308
+ if not locators:
309
+ raise ValueError(f"Element '{self.name}': не удалось построить локатор.")
310
+
311
+ final_locator = locators[0]
312
+ for loc in locators[1:]:
313
+ final_locator = final_locator.or_(loc)
314
+
315
+ # 3. Применение фильтров
316
+ if hasattr(self, "_filters") and self._filters:
317
+ for filter_kwargs in self._filters:
318
+ f_kwargs = filter_kwargs.copy()
319
+ if "has" in f_kwargs and isinstance(f_kwargs["has"], Element):
320
+ f_kwargs["has"] = f_kwargs["has"].resolve(final_locator.page)
321
+ if "has_not" in f_kwargs and isinstance(f_kwargs["has_not"], Element):
322
+ f_kwargs["has_not"] = f_kwargs["has_not"].resolve(
323
+ final_locator.page
324
+ )
325
+ final_locator = final_locator.filter(**f_kwargs)
326
+
327
+ # 4. Индекс
328
+ if self.index is not None:
329
+ idx = self.index
330
+ if idx < 0:
331
+ total = final_locator.count()
332
+ idx = total + idx
333
+ # Note: Playwright nth() handles negatives? No, it throws if out of bounds usually or wraps?
334
+ # Playwright nth() argument is 0-based index. Negative values like -1 mean last.
335
+ # So we can pass negative directly to nth()!
336
+ # But let's keep explicit logic if we want safety.
337
+ # Actually playwright .nth(-1) works.
338
+ final_locator = final_locator.nth(idx)
339
+
340
+ return final_locator
341
+
342
+ def resolve(self, page: Any) -> Any:
343
+ """Вернуть Playwright Locator."""
344
+ chain: List[Element] = [self]
345
+ curr = self.parent
346
+ while curr is not None:
347
+ chain.append(curr)
348
+ curr = curr.parent
349
+
350
+ locator = page
351
+ for element in reversed(chain):
352
+ # Removed: if element.parent is None: continue
353
+ # Logic handled in _get_playwright_locator (returns base if no strategy & parent is None)
354
+ locator = element._get_playwright_locator(locator)
355
+
356
+ return locator
357
+
358
+ def resolve_or(self, page: Any, *alternatives: "Element") -> Any:
359
+ """
360
+ Построить локатор как OR от self и альтернативных элементов.
361
+ """
362
+ loc = self.resolve(page)
363
+ for alt in alternatives:
364
+ loc = loc.or_(alt.resolve(page))
365
+ return loc
366
+
367
+ def filter(
368
+ self,
369
+ has_text: Optional[str | Any] = None,
370
+ has_not_text: Optional[str | Any] = None,
371
+ has: Optional["Element" | Any] = None,
372
+ has_not: Optional["Element" | Any] = None,
373
+ ) -> "Element":
374
+ new_element = copy.copy(self)
375
+ new_element._filters = list(self._filters)
376
+
377
+ filter_kwargs = {}
378
+ if has_text is not None:
379
+ filter_kwargs["has_text"] = has_text
380
+ if has_not_text is not None:
381
+ filter_kwargs["has_not_text"] = has_not_text
382
+ if has is not None:
383
+ filter_kwargs["has"] = has
384
+ if has_not is not None:
385
+ filter_kwargs["has_not"] = has_not
386
+
387
+ if filter_kwargs:
388
+ new_element._filters.append(filter_kwargs)
389
+
390
+ return new_element
391
+
392
+ # --- Container methods ---
393
+
394
+ def add_element(
395
+ self, element: "Element", *, alias: str | None = None, overwrite: bool = False
396
+ ) -> "Element":
397
+ if self._elements is None:
398
+ self._elements = {}
399
+
400
+ name = alias or element.name
401
+ if not overwrite and (
402
+ (self._elements is not None and name in self._elements)
403
+ or hasattr(self, name)
404
+ ):
405
+ raise ValueError(
406
+ f"Элемент/атрибут '{name}' уже существует в контейнере '{self.name}'"
407
+ )
408
+
409
+ element.parent = self
410
+ self._elements[name] = element
411
+ setattr(self, name, element)
412
+ return element
413
+
414
+ def add_element_list(
415
+ self, prototype: "Element", *, alias: str | None = None, overwrite: bool = False
416
+ ) -> "ElementList":
417
+ if self._elements is None:
418
+ self._elements = {}
419
+
420
+ list_name = alias or f"{prototype.name}s"
421
+ if not overwrite and (
422
+ (self._elements is not None and list_name in self._elements)
423
+ or hasattr(self, list_name)
424
+ ):
425
+ raise ValueError(
426
+ f"Элемент/атрибут '{list_name}' уже существует в контейнере '{self.name}'"
427
+ )
428
+
429
+ el_list = ElementList(owner=self, prototype=prototype, name=list_name)
430
+ self._elements[list_name] = el_list
431
+ setattr(self, list_name, el_list)
432
+ return el_list
433
+
434
+ def get_element(self, name: str) -> "Element":
435
+ if self._elements is None or name not in self._elements:
436
+ raise AttributeError(
437
+ f"Элемент '{name}' не найден в контейнере '{self.name}'"
438
+ )
439
+ el = self._elements[name]
440
+ if isinstance(el, ElementList):
441
+ raise AttributeError(
442
+ f"'{name}' является коллекцией. Используйте индекс или вызов."
443
+ )
444
+ return el
445
+
446
+ def _find_element_by_name_recursive(self, name: str) -> Optional["Element"]:
447
+ """Рекурсивный поиск элемента по имени (Smart Shortcut)."""
448
+ if not self._elements:
449
+ return None
450
+
451
+ for el in self._elements.values():
452
+ if isinstance(el, Element):
453
+ if el.name == name:
454
+ return el
455
+ # Recurse
456
+ found = el._find_element_by_name_recursive(name)
457
+ # FIX: Explicit check for None because Element might be Falsy if empty (calls __len__)
458
+ if found is not None:
459
+ return found
460
+ return None
461
+
462
+ def __getattr__(self, name: str) -> "Element":
463
+ if name.startswith("_"):
464
+ raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
465
+
466
+ # 1. Try direct child
467
+ try:
468
+ return self.get_element(name)
469
+ except AttributeError:
470
+ pass
471
+
472
+ # 2. Check cache
473
+ if name in self._shortcut_cache:
474
+ return self._shortcut_cache[name]
475
+
476
+ # 3. Recursive search (Smart Shortcut)
477
+ found = self._find_element_by_name_recursive(name)
478
+ if found is not None:
479
+ self._shortcut_cache[name] = found
480
+ return found
481
+
482
+ raise AttributeError(
483
+ f"Element '{name}' not found in '{self.name}' or its descendants."
484
+ )
485
+
486
+ def _children_in_order(self) -> List["Element"]:
487
+ if not self._elements:
488
+ return []
489
+ items: List[Element] = []
490
+ for v in self._elements.values():
491
+ if isinstance(v, Element):
492
+ items.append(v)
493
+ return items
494
+
495
+ def __len__(self) -> int:
496
+ return len(self._children_in_order())
497
+
498
+ def __iter__(self) -> Iterator["Element"]:
499
+ yield from self._children_in_order()
500
+
501
+ def __getitem__(self, key: int | str) -> "Element":
502
+ if isinstance(key, int):
503
+ children = self._children_in_order()
504
+ try:
505
+ return children[key]
506
+ except IndexError:
507
+ raise IndexError(f"Index {key} out of range")
508
+ if isinstance(key, str):
509
+ # Support dict-like access via shortcuts too?
510
+ # Usually __getitem__ string access is direct.
511
+ # But let's use getattr logic to be consistent with shortcuts.
512
+ return getattr(self, key)
513
+ raise TypeError("Index must be int or str")
514
+
515
+
516
+ class ElementList:
517
+ """Коллекция однотипных элементов."""
518
+
519
+ def __init__(self, owner: Element, prototype: Element, name: str) -> None:
520
+ self._owner = owner
521
+ self._prototype = prototype
522
+ self.name = name
523
+ self._prototype.parent = owner
524
+
525
+ def __call__(self, text: str | None = None, **kwargs: Any) -> Element:
526
+ """
527
+ Возвращает ЭЛЕМЕНТ (не список), найденный по тексту.
528
+ """
529
+ item = self._make_item(None) # index=None -> ищем среди всех
530
+
531
+ if text:
532
+ item = item.filter(has_text=text)
533
+
534
+ return item
535
+
536
+ def _make_item(self, index: int | None) -> Element:
537
+ cls: Type[Element] = type(self._prototype)
538
+
539
+ # Get field definitions to check init=False
540
+ from dataclasses import fields
541
+
542
+ cls_fields = {f.name: f for f in fields(cls)}
543
+
544
+ kwargs = {
545
+ "name": (
546
+ f"{self._prototype.name}_{index}"
547
+ if index is not None
548
+ else self._prototype.name
549
+ ),
550
+ "accessible_name": self._prototype.accessible_name,
551
+ "locator": self._prototype.locator,
552
+ "index": index,
553
+ "exact": self._prototype.exact,
554
+ "description": self._prototype.description,
555
+ "static_screenshot_path": self._prototype.static_screenshot_path,
556
+ "static_aria_snapshot_path": self._prototype.static_aria_snapshot_path,
557
+ "aria_ref": self._prototype.aria_ref,
558
+ "test_id": self._prototype.test_id,
559
+ "placeholder": self._prototype.placeholder,
560
+ "text": self._prototype.text,
561
+ "role": self._prototype.role,
562
+ "url": self._prototype.url,
563
+ }
564
+
565
+ # Filter out fields that have init=False in the target class
566
+ filtered_kwargs = {
567
+ k: v for k, v in kwargs.items() if k not in cls_fields or cls_fields[k].init
568
+ }
569
+
570
+ item = cls(**filtered_kwargs) # type: ignore[arg-type]
571
+ item.parent = self._owner
572
+ if self._prototype._strategies:
573
+ item._strategies = list(self._prototype._strategies)
574
+
575
+ # FIX: Copy filters from prototype!
576
+ if hasattr(self._prototype, "_filters") and self._prototype._filters:
577
+ item._filters = list(self._prototype._filters)
578
+
579
+ return item
580
+
581
+ def __getitem__(self, index: int) -> Element:
582
+ if not isinstance(index, int):
583
+ raise IndexError("Index must be int")
584
+ return self._make_item(index)
585
+
586
+ def get(self, index: int) -> Element:
587
+ return self.__getitem__(index)
588
+
589
+ def first(self) -> Element:
590
+ return self._make_item(0)
591
+
592
+ def last(self) -> Element:
593
+ return self._make_item(-1)
594
+
595
+ def nth(self, index: int) -> Element:
596
+ return self.__getitem__(index)
597
+
598
+ def with_text(self, text: str, *, exact: bool = True) -> Element:
599
+ item = self._make_item(None)
600
+ return item.filter(has_text=text)
601
+
602
+ def filter(
603
+ self,
604
+ has_text: Optional[str | Any] = None,
605
+ has_not_text: Optional[str | Any] = None,
606
+ has: Optional["Element" | Any] = None,
607
+ has_not: Optional["Element" | Any] = None,
608
+ ) -> ElementList:
609
+ new_prototype = self._prototype.filter(
610
+ has_text=has_text, has_not_text=has_not_text, has=has, has_not=has_not
611
+ )
612
+ return ElementList(owner=self._owner, prototype=new_prototype, name=self.name)
613
+
614
+
615
+ # --- Примитивные элементы ---
616
+
617
+
618
+ @dataclass
619
+ class Button(Element):
620
+ role: str = field(default="button", init=False)
621
+
622
+
623
+ @dataclass
624
+ class TextField(Element):
625
+ role: str = field(default="textbox", init=False)
626
+
627
+
628
+ @dataclass
629
+ class Link(Element):
630
+ role: str = field(default="link", init=False)
631
+
632
+
633
+ @dataclass
634
+ class Checkbox(Element):
635
+ role: str = field(default="checkbox", init=False)
636
+
637
+
638
+ @dataclass
639
+ class Radio(Element):
640
+ role: str = field(default="radio", init=False)
641
+
642
+
643
+ @dataclass
644
+ class Heading(Element):
645
+ role: str = field(default="heading", init=False)
646
+
647
+
648
+ @dataclass
649
+ class Paragraph(Element):
650
+ role: str = field(default="paragraph", init=False)
651
+
652
+
653
+ @dataclass
654
+ class Strong(Element):
655
+ role: str = field(default="strong", init=False)
656
+
657
+
658
+ @dataclass
659
+ class ListElement(Element):
660
+ role: str = field(default="list", init=False)
661
+
662
+
663
+ @dataclass
664
+ class ListItem(Element):
665
+ role: str = field(default="listitem", init=False)
666
+
667
+
668
+ @dataclass
669
+ class Table(Element):
670
+ role: str = field(default="table", init=False)
671
+
672
+ def rows(self) -> ElementList:
673
+ if hasattr(self, "rows") and isinstance(getattr(self, "rows"), ElementList):
674
+ return getattr(self, "rows")
675
+ return self.add_element_list(
676
+ TableRow(name="row", accessible_name=""), alias="rows", overwrite=True
677
+ )
678
+
679
+ def headers(self) -> ElementList:
680
+ if hasattr(self, "headers") and isinstance(
681
+ getattr(self, "headers"), ElementList
682
+ ):
683
+ return getattr(self, "headers")
684
+ return self.add_element_list(
685
+ ColumnHeader(name="header", accessible_name=""),
686
+ alias="headers",
687
+ overwrite=True,
688
+ )
689
+
690
+ def row(
691
+ self, index: int | None = None, *, where: dict[str, str] | None = None
692
+ ) -> "TableRow":
693
+ if index is not None and where is None:
694
+ item = TableRow(name=f"row_{index}", accessible_name="", index=index)
695
+ item.parent = self
696
+ return item
697
+ if where is not None and index is None:
698
+ item = TableRow(name="row_where", accessible_name="")
699
+ setattr(item, "_where_criteria", where)
700
+ item.parent = self
701
+ return item
702
+ raise ValueError("Table.row ожидает либо index, либо where.")
703
+
704
+ def header(self, index: int) -> "ColumnHeader":
705
+ item = ColumnHeader(name=f"header_{index}", accessible_name="", index=index)
706
+ item.parent = self
707
+ return item
708
+
709
+ def cell(self, row_index: int, column_index: int) -> "TableCell":
710
+ row_el = self.row(index=row_index)
711
+ cell_el = TableCell(
712
+ name=f"cell_{row_index}_{column_index}",
713
+ accessible_name="",
714
+ index=column_index,
715
+ )
716
+ cell_el.parent = row_el
717
+ return cell_el
718
+
719
+
720
+ @dataclass
721
+ class ComboBox(Element):
722
+ role: str = field(default="combobox", init=False)
723
+
724
+
725
+ @dataclass
726
+ class ListBox(Element):
727
+ role: str = field(default="listbox", init=False)
728
+
729
+
730
+ @dataclass
731
+ class Slider(Element):
732
+ role: str = field(default="slider", init=False)
733
+
734
+
735
+ @dataclass
736
+ class ProgressBar(Element):
737
+ role: str = field(default="progressbar", init=False)
738
+
739
+
740
+ @dataclass
741
+ class SearchBox(Element):
742
+ role: str = field(default="searchbox", init=False)
743
+
744
+
745
+ @dataclass
746
+ class SpinButton(Element):
747
+ role: str = field(default="spinbutton", init=False)
748
+
749
+
750
+ @dataclass
751
+ class Switch(Element):
752
+ role: str = field(default="switch", init=False)
753
+
754
+
755
+ @dataclass
756
+ class Code(Element):
757
+ role: str = field(default="code", init=False)
758
+
759
+
760
+ @dataclass
761
+ class BlockQuote(Element):
762
+ role: str = field(default="blockquote", init=False)
763
+
764
+
765
+ @dataclass
766
+ class TableRow(Element):
767
+ role: str = field(default="row", init=False)
768
+ _where_criteria: dict[str, str] | None = field(default=None, repr=False, init=False)
769
+
770
+ def _get_playwright_locator(self, base: Any) -> Any:
771
+ # Если это навигационный элемент, используем базовую логику
772
+ if self._nav_type:
773
+ return super()._get_playwright_locator(base)
774
+
775
+ locator = super()._get_playwright_locator(base)
776
+ if not self._where_criteria:
777
+ return locator
778
+
779
+ header_locs = base.locator("th, [role=columnheader]").all()
780
+ headers = [(h.text_content() or "").strip() for h in header_locs]
781
+ if not headers:
782
+ raise ValueError(
783
+ "TableRow: невозможно отфильтровать по where — нет заголовков."
784
+ )
785
+
786
+ filtered = locator
787
+ cell_sel = "td, [role=cell], th, [role=rowheader]"
788
+ for col_name, value in (self._where_criteria or {}).items():
789
+ if col_name not in headers:
790
+ raise ValueError(f"TableRow: не найден заголовок '{col_name}'.")
791
+ col_index = headers.index(col_name)
792
+ cell_in_row = (
793
+ filtered.locator(cell_sel).nth(col_index).filter(has_text=str(value))
794
+ )
795
+ filtered = filtered.filter(has=cell_in_row)
796
+ return filtered
797
+
798
+ def cell(self, column_index: int) -> "TableCell":
799
+ item = TableCell(
800
+ name=f"cell_{column_index}", accessible_name="", index=column_index
801
+ )
802
+ item.parent = self
803
+ return item
804
+
805
+
806
+ @dataclass
807
+ class TableCell(Element):
808
+ role: str = field(default="cell", init=False)
809
+
810
+
811
+ @dataclass
812
+ class ColumnHeader(Element):
813
+ role: str = field(default="columnheader", init=False)
814
+
815
+
816
+ @dataclass
817
+ class RowHeader(Element):
818
+ role: str = field(default="rowheader", init=False)
819
+
820
+
821
+ @dataclass
822
+ class Grid(Element):
823
+ role: str = field(default="grid", init=False)
824
+
825
+
826
+ @dataclass
827
+ class GridCell(Element):
828
+ role: str = field(default="gridcell", init=False)
829
+
830
+
831
+ @dataclass
832
+ class Navigation(Element):
833
+ role: str = field(default="navigation", init=False)
834
+
835
+
836
+ @dataclass
837
+ class Main(Element):
838
+ role: str = field(default="main", init=False)
839
+
840
+
841
+ @dataclass
842
+ class Banner(Element):
843
+ role: str = field(default="banner", init=False)
844
+
845
+
846
+ @dataclass
847
+ class ContentInfo(Element):
848
+ role: str = field(default="contentinfo", init=False)
849
+
850
+
851
+ @dataclass
852
+ class Region(Element):
853
+ role: str = field(default="region", init=False)
854
+
855
+
856
+ @dataclass
857
+ class Search(Element):
858
+ role: str = field(default="search", init=False)
859
+
860
+
861
+ @dataclass
862
+ class Complementary(Element):
863
+ role: str = field(default="complementary", init=False)
864
+
865
+
866
+ @dataclass
867
+ class Article(Element):
868
+ role: str = field(default="article", init=False)
869
+
870
+
871
+ @dataclass
872
+ class Image(Element):
873
+ role: str = field(default="img", init=False)
874
+
875
+
876
+ @dataclass
877
+ class Figure(Element):
878
+ role: str = field(default="figure", init=False)
879
+
880
+
881
+ @dataclass
882
+ class Form(Element):
883
+ role: str = field(default="form", init=False)
884
+
885
+
886
+ @dataclass
887
+ class Group(Element):
888
+ role: str = field(default="group", init=False)
889
+
890
+
891
+ @dataclass
892
+ class RadioGroup(Element):
893
+ role: str = field(default="radiogroup", init=False)
894
+
895
+
896
+ @dataclass
897
+ class Dialog(Element):
898
+ role: str = field(default="dialog", init=False)
899
+
900
+
901
+ @dataclass
902
+ class AlertDialog(Element):
903
+ role: str = field(default="alertdialog", init=False)
904
+
905
+
906
+ @dataclass
907
+ class Alert(Element):
908
+ role: str = field(default="alert", init=False)
909
+
910
+
911
+ @dataclass
912
+ class Status(Element):
913
+ role: str = field(default="status", init=False)
914
+
915
+
916
+ @dataclass
917
+ class Log(Element):
918
+ role: str = field(default="log", init=False)
919
+
920
+
921
+ @dataclass
922
+ class Timer(Element):
923
+ role: str = field(default="timer", init=False)
924
+
925
+
926
+ @dataclass
927
+ class Menu(Element):
928
+ role: str = field(default="menu", init=False)
929
+
930
+
931
+ @dataclass
932
+ class MenuBar(Element):
933
+ role: str = field(default="menubar", init=False)
934
+
935
+
936
+ @dataclass
937
+ class MenuItem(Element):
938
+ role: str = field(default="menuitem", init=False)
939
+
940
+
941
+ @dataclass
942
+ class MenuItemCheckbox(Element):
943
+ role: str = field(default="menuitemcheckbox", init=False)
944
+
945
+
946
+ @dataclass
947
+ class MenuItemRadio(Element):
948
+ role: str = field(default="menuitemradio", init=False)
949
+
950
+
951
+ @dataclass
952
+ class TabList(Element):
953
+ role: str = field(default="tablist", init=False)
954
+
955
+
956
+ @dataclass
957
+ class Tab(Element):
958
+ role: str = field(default="tab", init=False)
959
+
960
+
961
+ @dataclass
962
+ class TabPanel(Element):
963
+ role: str = field(default="tabpanel", init=False)
964
+
965
+
966
+ @dataclass
967
+ class Tree(Element):
968
+ role: str = field(default="tree", init=False)
969
+
970
+
971
+ @dataclass
972
+ class TreeItem(Element):
973
+ role: str = field(default="treeitem", init=False)
974
+
975
+
976
+ @dataclass
977
+ class Meter(Element):
978
+ role: str = field(default="meter", init=False)
979
+
980
+
981
+ @dataclass
982
+ class ScrollBar(Element):
983
+ role: str = field(default="scrollbar", init=False)
984
+
985
+
986
+ @dataclass
987
+ class Separator(Element):
988
+ role: str = field(default="separator", init=False)
989
+
990
+
991
+ @dataclass
992
+ class Toolbar(Element):
993
+ role: str = field(default="toolbar", init=False)
994
+
995
+
996
+ @dataclass
997
+ class Tooltip(Element):
998
+ role: str = field(default="tooltip", init=False)