persona-dsl 26.1.20.8__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 +221 -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 +146 -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 +1064 -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.20.8.dist-info/METADATA +35 -0
  83. persona_dsl-26.1.20.8.dist-info/RECORD +86 -0
  84. persona_dsl-26.1.20.8.dist-info/WHEEL +5 -0
  85. persona_dsl-26.1.20.8.dist-info/entry_points.txt +6 -0
  86. persona_dsl-26.1.20.8.dist-info/top_level.txt +1 -0
@@ -0,0 +1,27 @@
1
+ from typing import Any
2
+
3
+ from persona_dsl.components.ops import Ops
4
+ from persona_dsl.skills.core.skill_definition import SkillId
5
+ from persona_dsl.pages.elements import Element
6
+
7
+
8
+ class ElementIsVisible(Ops):
9
+ """
10
+ Бизнес-факт: проверить видимость элемента.
11
+ Приоритет поиска: по ARIA-роли и имени, затем по `locator`.
12
+ """
13
+
14
+ def __init__(self, element: Element):
15
+ if not isinstance(element, Element):
16
+ raise TypeError(
17
+ f"ElementIsVisible ожидает экземпляр Element, получено: {type(element)}"
18
+ )
19
+ self.element = element
20
+
21
+ def _get_step_description(self, persona: Any) -> str:
22
+ return f"{persona} проверяет видимость элемента '{self.element.name}'"
23
+
24
+ def _perform(self, persona: Any, *args: Any, **kwargs: Any) -> bool:
25
+ """Возвращает True, если элемент видим."""
26
+ page = persona.skill(SkillId.BROWSER).page
27
+ return self.element.resolve(page).is_visible()
@@ -0,0 +1,28 @@
1
+ from typing import Any
2
+
3
+ from persona_dsl.components.ops import Ops
4
+ from persona_dsl.skills.core.skill_definition import SkillId
5
+ from persona_dsl.pages.elements import Element
6
+
7
+
8
+ class ElementText(Ops):
9
+ """
10
+ Бизнес-факт: получить текстовое содержимое элемента.
11
+ Приоритет поиска: по ARIA-роли и имени, затем по `locator`.
12
+ """
13
+
14
+ def __init__(self, element: Element):
15
+ if not isinstance(element, Element):
16
+ raise TypeError(
17
+ f"ElementText ожидает экземпляр Element, получено: {type(element)}"
18
+ )
19
+ self.element = element
20
+
21
+ def _get_step_description(self, persona: Any) -> str:
22
+ return f"{persona} запрашивает текст элемента '{self.element.name}'"
23
+
24
+ def _perform(self, persona: Any, *args: Any, **kwargs: Any) -> str:
25
+ """Возвращает текстовое содержимое элемента."""
26
+ page = persona.skill(SkillId.BROWSER).page
27
+ text = self.element.resolve(page).text_content()
28
+ return text or ""
@@ -0,0 +1,42 @@
1
+ from typing import Any, Union
2
+ import copy
3
+
4
+ from persona_dsl.components.ops import Ops
5
+ from persona_dsl.skills.core.skill_definition import SkillId
6
+ from persona_dsl.pages.elements import Element, ElementList
7
+
8
+
9
+ class ElementsCount(Ops):
10
+ """
11
+ Бизнес-операция: подсчитать количество элементов, соответствующих прототипу.
12
+
13
+ Принимает:
14
+ - Element: будет посчитано количество совпадений для данного описания элемента.
15
+ - ElementList: используется его прототип для построения локатора и подсчёта.
16
+ """
17
+
18
+ def __init__(self, element_or_list: Union[Element, ElementList]):
19
+ if isinstance(element_or_list, ElementList):
20
+ self._element = (
21
+ element_or_list._prototype
22
+ ) # noqa: SLF001 (осознанный доступ к прототипу)
23
+ elif isinstance(element_or_list, Element):
24
+ self._element = element_or_list
25
+ else:
26
+ raise TypeError("ElementsCount ожидает Element или ElementList")
27
+
28
+ def _get_step_description(self, persona: Any) -> str:
29
+ return f"{persona} подсчитывает количество элементов для '{self._element.name}'"
30
+
31
+ def _perform(self, persona: Any, *args: Any, **kwargs: Any) -> int:
32
+ page = persona.skill(SkillId.BROWSER).page
33
+ # Базовый локатор — родитель, если он есть; иначе вся страница
34
+ base = self._element.parent.resolve(page) if self._element.parent else page
35
+
36
+ # Строим локатор для всех совпадений (index=None)
37
+ # Using copy and modifying the copy is safer and more robust than manual constructor
38
+ probe = copy.copy(self._element)
39
+ probe.index = None
40
+
41
+ locator = probe._get_playwright_locator(base)
42
+ return locator.count()
@@ -0,0 +1,41 @@
1
+ from typing import Any
2
+
3
+ from persona_dsl.components.ops import Ops
4
+ from persona_dsl.skills.core.skill_definition import SkillId
5
+ from persona_dsl.pages.elements import Element
6
+
7
+
8
+ class Fill(Ops):
9
+ """
10
+ Атомарное действие: заполнить поле текстом.
11
+ Приоритет поиска: по ARIA-роли и имени, затем по `locator`.
12
+ Поддерживает проброс аргументов в Playwright (force, timeout, etc).
13
+ """
14
+
15
+ def __init__(self, element: Element, text: str, **kwargs: Any):
16
+ if not isinstance(element, Element):
17
+ raise TypeError(
18
+ f"Fill ожидает экземпляр Element, получено: {type(element)}"
19
+ )
20
+ self.element = element
21
+ self.text = text
22
+ self.kwargs = kwargs
23
+
24
+ def _get_step_description(self, persona: Any) -> str:
25
+ # Не логируем пароли и другие чувствительные данные
26
+ text_to_log = self.text
27
+ name_lower = self.element.name.lower()
28
+ accessible_name_lower = (self.element.accessible_name or "").lower()
29
+ if (
30
+ "password" in name_lower
31
+ or "pass" in name_lower
32
+ or "парол" in accessible_name_lower
33
+ ):
34
+ text_to_log = "****"
35
+ extra = f" с параметрами {self.kwargs}" if self.kwargs else ""
36
+ return f"{persona} заполняет поле '{self.element.name}' текстом '{text_to_log}'{extra}"
37
+
38
+ def _perform(self, persona: Any, *args: Any, **kwargs: Any) -> None:
39
+ page = persona.skill(SkillId.BROWSER).page
40
+ locator = self.element.resolve(page)
41
+ locator.fill(self.text, **self.kwargs)
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Any, Optional
6
+ from urllib.parse import urlparse
7
+
8
+
9
+ from persona_dsl.components.ops import Ops
10
+ from persona_dsl.skills.core.skill_definition import SkillId
11
+ from persona_dsl.generators.page_generator import PageGenerator
12
+ from persona_dsl.utils.naming import to_snake_case
13
+ from .rich_aria_snapshot import RichAriaSnapshot
14
+
15
+
16
+ class GeneratePageObject(Ops):
17
+ """
18
+ Бизнес-операция: сгенерировать и сохранить PageObject на основе текущего состояния страницы.
19
+ Работает при запуске pytest с флагом --generate-pages (см. pytest_plugin.py).
20
+
21
+ По умолчанию также сохраняет ARIA-снепшот в tests/pages/aria/<stem>_snapshot.yaml
22
+ и прокидывает относительный путь в сгенерированный Page (static_aria_snapshot_path).
23
+
24
+ Параметры ожидания перед генерацией регулируются аргументами:
25
+ - wait_for_state: состояние загрузки Playwright ('load' | 'domcontentloaded' | 'networkidle' | 'commit')
26
+ - wait_timeout: таймаут в миллисекундах; если None — используется дефолтный таймаут Playwright/страницы
27
+ - sleep_before: если задано (в секундах), выполнить простую паузу перед генерацией и не ждать состояние загрузки
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ class_name: str = "GeneratedPage",
33
+ output_path: Optional[str] = None,
34
+ wait_for_state: Optional[str] = "networkidle",
35
+ wait_timeout: Optional[float] = None,
36
+ sleep_before: Optional[float] = None,
37
+ ):
38
+ self.class_name = class_name or "GeneratedPage"
39
+ if output_path is None:
40
+ file_name = f"{to_snake_case(self.class_name)}.py"
41
+ # Дефолт как у клиента: tests/pages/
42
+ self.output_path = Path("tests/pages") / file_name
43
+ else:
44
+ self.output_path = Path(output_path)
45
+ self.wait_for_state = wait_for_state
46
+ self.wait_timeout = wait_timeout
47
+ self.sleep_before = sleep_before
48
+
49
+ def _get_step_description(self, persona: Any) -> str:
50
+ return f"{persona} генерирует PageObject '{self.class_name}'"
51
+
52
+ def _perform(self, persona: Any, *args: Any, **kwargs: Any) -> None:
53
+ # Проверяем флаг в pytest (регистрируется в pytest_plugin.py)
54
+ request = getattr(persona, "_pytest_request", None)
55
+ generate_enabled = False
56
+ if request is not None:
57
+ try:
58
+ generate_enabled = bool(request.config.getoption("--generate-pages"))
59
+ except Exception:
60
+ generate_enabled = False
61
+
62
+ if not generate_enabled:
63
+ # Явный no-op без побочных эффектов
64
+ return
65
+
66
+ browser_skill = persona.skill(SkillId.BROWSER)
67
+ page = browser_skill.page
68
+
69
+ # Варианты ожидания перед генерацией: простая пауза или ожидание состояния
70
+ if self.sleep_before is not None:
71
+ time.sleep(self.sleep_before)
72
+ elif self.wait_for_state:
73
+ if self.wait_timeout is None:
74
+ page.wait_for_load_state(self.wait_for_state)
75
+ else:
76
+ page.wait_for_load_state(self.wait_for_state, timeout=self.wait_timeout)
77
+
78
+ # Получаем относительный путь URL
79
+ page_path = urlparse(page.url).path
80
+
81
+ # Используем rich ARIA snapshot вместо стандартного
82
+ rich_snapshot_op = RichAriaSnapshot()
83
+ snapshot_struct = rich_snapshot_op._perform(persona)
84
+
85
+ # Сохраняем ARIA снепшот на диск как YAML-строку
86
+ assets_dir = self.output_path.parent / "aria"
87
+ assets_dir.mkdir(parents=True, exist_ok=True)
88
+
89
+ snapshot_file = f"{self.output_path.stem}_snapshot.yaml"
90
+ snapshot_abs_path = assets_dir / snapshot_file
91
+
92
+ snapshot_yaml: str = (
93
+ rich_snapshot_op._tree_to_yaml(snapshot_struct) if snapshot_struct else ""
94
+ )
95
+ if snapshot_yaml:
96
+ snapshot_abs_path.write_text(snapshot_yaml, encoding="utf-8")
97
+ aria_snapshot_rel_path = f"aria/{snapshot_file}"
98
+
99
+ generator = PageGenerator()
100
+
101
+ # Если файл уже существует — читаем его и выполняем умное обновление (merge)
102
+ existing_code: Optional[str] = None
103
+ try:
104
+ if self.output_path.exists():
105
+ existing_code = self.output_path.read_text(encoding="utf-8")
106
+ except Exception:
107
+ existing_code = None
108
+
109
+ generated_code = generator.generate_or_update_from_aria_snapshot(
110
+ snapshot=snapshot_struct,
111
+ class_name=self.class_name,
112
+ page_path=page_path,
113
+ aria_snapshot_path=aria_snapshot_rel_path,
114
+ existing_code=existing_code,
115
+ )
116
+
117
+ self.output_path.parent.mkdir(parents=True, exist_ok=True)
118
+ self.output_path.write_text(generated_code, encoding="utf-8")
@@ -0,0 +1,23 @@
1
+ from typing import Any
2
+
3
+ from persona_dsl.components.ops import Ops
4
+ from persona_dsl.skills.core.skill_definition import SkillId
5
+ from persona_dsl.pages.elements import Element
6
+
7
+
8
+ class InputValue(Ops):
9
+ """
10
+ Бизнес-операция: получить текущее значение из поля ввода (input/textarea/select).
11
+ Использует Playwright Locator.input_value() для корректного чтения значения.
12
+ """
13
+
14
+ def __init__(self, element: Element):
15
+ self.element = element
16
+
17
+ def _get_step_description(self, persona: Any) -> str:
18
+ return f"{persona} запрашивает значение поля ввода '{self.element.name}'"
19
+
20
+ def _perform(self, persona: Any, *args: Any, **kwargs: Any) -> str:
21
+ page = persona.skill(SkillId.BROWSER).page
22
+ locator = self.element.resolve(page)
23
+ return locator.input_value()
@@ -0,0 +1,52 @@
1
+ from typing import Any, TYPE_CHECKING, Union
2
+
3
+ from persona_dsl.components.ops import Ops
4
+ from persona_dsl.skills.core.skill_definition import SkillId
5
+
6
+ if TYPE_CHECKING:
7
+ from persona_dsl.pages.page import Page
8
+
9
+
10
+ class NavigateTo(Ops):
11
+ """
12
+ Атомарное действие: перейти по адресу.
13
+ LCL-39: Поддержка build_url для Page.
14
+ """
15
+
16
+ def __init__(self, target: Union[str, "Page"]):
17
+ self._target = target
18
+
19
+ def _resolve_path(self, page_skill: Any) -> str:
20
+ if isinstance(self._target, str):
21
+ return self._target
22
+
23
+ # Duck-typing: ожидаем Page
24
+ page_obj = self._target
25
+
26
+ if hasattr(page_obj, "build_url"):
27
+ # Получаем base_url из контекста браузера (если возможно)
28
+ # Но base_url в playwright хранится в context или page?
29
+ # page.context.base_url?
30
+ # Для простоты пока передаем None, так как goto сам резолвит относительно base_url,
31
+ # если передан относительный путь.
32
+ # Но если build_url возвращает полный URL, то все ок.
33
+ return page_obj.build_url()
34
+
35
+ expected_path = getattr(page_obj, "expected_path", None)
36
+ if not isinstance(expected_path, str) or not expected_path:
37
+ raise ValueError(
38
+ "NavigateTo: у переданной страницы не задан expected_path."
39
+ )
40
+ return expected_path
41
+
42
+ def _get_step_description(self, persona: Any) -> str:
43
+ # Для описания мы не можем резолвить base_url, покажем относительный
44
+ path = str(self._target)
45
+ if hasattr(self._target, "expected_path"):
46
+ path = getattr(self._target, "expected_path")
47
+ return f"{persona} переходит по адресу '{path}'"
48
+
49
+ def _perform(self, persona: Any, *args: Any, **kwargs: Any) -> None:
50
+ browser_skill = persona.skill(SkillId.BROWSER)
51
+ url_or_path = self._resolve_path(browser_skill)
52
+ browser_skill.page.goto(url_or_path)
@@ -0,0 +1,37 @@
1
+ from typing import Any
2
+
3
+ from persona_dsl.components.ops import Ops
4
+ from persona_dsl.skills.core.skill_definition import SkillId
5
+ from persona_dsl.pages.elements import Element
6
+
7
+
8
+ class PressKey(Ops):
9
+ """
10
+ Атомарное действие: нажать клавишу на элементе.
11
+ Приоритет поиска: по ARIA-роли и имени, затем по `locator`.
12
+ Поддерживает проброс аргументов в Playwright (timeout, no_wait_after, etc).
13
+ """
14
+
15
+ def __init__(self, element: Element, key: str, **kwargs: Any):
16
+ """
17
+ Args:
18
+ element: Экземпляр элемента, на котором нажимается клавиша.
19
+ key: Имя клавиши для нажатия (например, 'Enter', 'Tab', 'ArrowDown').
20
+ **kwargs: Дополнительные аргументы для Playwright locator.press().
21
+ """
22
+ if not isinstance(element, Element):
23
+ raise TypeError(
24
+ f"PressKey ожидает экземпляр Element, получено: {type(element)}"
25
+ )
26
+ self.element = element
27
+ self.key = key
28
+ self.kwargs = kwargs
29
+
30
+ def _get_step_description(self, persona: Any) -> str:
31
+ extra = f" с параметрами {self.kwargs}" if self.kwargs else ""
32
+ return f"{persona} нажимает клавишу '{self.key}' на элементе '{self.element.name}'{extra}"
33
+
34
+ def _perform(self, persona: Any, *args: Any, **kwargs: Any) -> None:
35
+ page = persona.skill(SkillId.BROWSER).page
36
+ locator = self.element.resolve(page)
37
+ locator.press(self.key, **self.kwargs)
@@ -0,0 +1,146 @@
1
+ """Богатый ARIA-снепшот с использованием Persona Runtime (JS Kernel)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List
6
+ import allure
7
+
8
+ from persona_dsl.components.ops import Ops
9
+ from persona_dsl.skills.core.skill_definition import SkillId
10
+
11
+
12
+ class RichAriaSnapshot(Ops):
13
+ """
14
+ Факт: получить богатый ARIA-снепшот всей страницы.
15
+ Использует внедренное JS-ядро (window.__PERSONA__) для получения дерева,
16
+ затем рендерит его в YAML-формат, совместимый с Playwright/MCP.
17
+ """
18
+
19
+ def __init__(self, timeout: float | None = None) -> None:
20
+ self.timeout = timeout
21
+
22
+ def _get_step_description(self, persona: Any) -> str:
23
+ return f"{persona} получает богатый ARIA-снепшот страницы (через Runtime)"
24
+
25
+ def _perform(self, persona: Any, *args: Any, **kwargs: Any) -> Dict[str, Any]:
26
+ page = persona.skill(SkillId.BROWSER).page
27
+
28
+ # 1. Получаем чистое JSON-дерево от ядра
29
+ snapshot_tree = page.evaluate(
30
+ "window.__PERSONA__ && window.__PERSONA__.getTreeForTransport && window.__PERSONA__.getTreeForTransport()"
31
+ )
32
+
33
+ if not snapshot_tree:
34
+ raise RuntimeError(
35
+ "Persona Runtime не вернул JSON-дерево (getTreeForTransport). Убедитесь, что Runtime инициализирован."
36
+ )
37
+
38
+ # 2. Рендерим YAML для отчетов (визуализация)
39
+ snapshot_yaml = self._tree_to_yaml(snapshot_tree)
40
+
41
+ try:
42
+ allure.attach(
43
+ snapshot_yaml,
44
+ name="rich-aria-page",
45
+ attachment_type=allure.attachment_type.YAML,
46
+ )
47
+ except Exception:
48
+ pass
49
+
50
+ # Возвращаем структуру данных (dict) для Генератора
51
+ return snapshot_tree
52
+
53
+ def _tree_to_yaml(self, root: Dict[str, Any] | List[Any]) -> str:
54
+ """
55
+ Преобразует JSON-дерево в читаемый структурный YAML формат.
56
+ Формат:
57
+ role "name" [ref=...]:
58
+ - /props...
59
+ - children...
60
+ """
61
+ lines = []
62
+ if isinstance(root, dict):
63
+ lines.extend(self._render_node_yaml(root, indent=0))
64
+ elif isinstance(root, list):
65
+ for node in root:
66
+ lines.extend(self._render_node_yaml(node, indent=0))
67
+
68
+ return "\n".join(lines)
69
+
70
+ def _render_node_yaml(self, node: Dict[str, Any], indent: int) -> List[str]:
71
+ if isinstance(node, str):
72
+ # Текстовый узел (строка)
73
+ # В новом Runtime children могут быть строками? Вроде в TreeWalker мы делаем узлы для текста.
74
+ # Но на всякий случай.
75
+ return [f"{' ' * indent}- \"{node}\""]
76
+
77
+ lines = []
78
+ prefix = " " * indent
79
+
80
+ role = node.get("role", "generic")
81
+ name = node.get("name", "")
82
+ ref = node.get("ref")
83
+
84
+ # Формируем заголовок узла
85
+ # Пример: - combobox "Bank" [ref=xyz]
86
+
87
+ # Экранирование имени
88
+ safe_name = ""
89
+ if name:
90
+ escaped_name = name.replace("`", "").replace('"', '\\"')
91
+ safe_name = f' "{escaped_name}"'
92
+
93
+ ref_str = f" [ref={ref}]" if ref else ""
94
+
95
+ # Собираем пропы, которые надо показать в строке (статусы)
96
+ status_parts = []
97
+ # ARIA/состояния из Runtime кладутся на корень узла, а не в props
98
+ if node.get("checked") is True:
99
+ status_parts.append("[checked]")
100
+ elif node.get("checked") == "mixed":
101
+ status_parts.append("[checked=mixed]")
102
+
103
+ if node.get("pressed") is True:
104
+ status_parts.append("[pressed]")
105
+ elif node.get("pressed") == "mixed":
106
+ status_parts.append("[pressed=mixed]")
107
+
108
+ if node.get("disabled"):
109
+ status_parts.append("[disabled]")
110
+ if node.get("expanded") is True:
111
+ status_parts.append("[expanded]")
112
+ if node.get("selected"):
113
+ status_parts.append("[selected]")
114
+ if node.get("level") is not None:
115
+ status_parts.append(f"[level={node['level']}]")
116
+ # Поле hidden помечает элементы, скрытые визуально/по ARIA
117
+ if node.get("hidden"):
118
+ status_parts.append("[hidden]")
119
+
120
+ status_str = " ".join(status_parts)
121
+ if status_str:
122
+ status_str = " " + status_str
123
+
124
+ # Основная строка узла
125
+ header = f"{prefix}- {role}{safe_name}{ref_str}{status_str}:"
126
+
127
+ children = node.get("children", [])
128
+ props = node.get("props", {})
129
+
130
+ # Если нет детей и пропов, убираем двоеточие
131
+ if not children and not props:
132
+ header = header.rstrip(":")
133
+ lines.append(header)
134
+ else:
135
+ lines.append(header)
136
+
137
+ # Рендерим props
138
+ for k, v in props.items():
139
+ if v: # Не выводим пустые пропы
140
+ lines.append(f'{prefix} - /{k}: "{v}"')
141
+
142
+ # Рендерим детей
143
+ for child in children:
144
+ lines.extend(self._render_node_yaml(child, indent + 1))
145
+
146
+ return lines
@@ -0,0 +1,68 @@
1
+ import time
2
+ from typing import Any, Optional
3
+
4
+ import allure
5
+
6
+ from persona_dsl.components.ops import Ops
7
+ from persona_dsl.skills.core.skill_definition import SkillId
8
+ from persona_dsl.utils.artifacts import sanitize_filename, screenshots_dir
9
+ from persona_dsl.pages.elements import Element
10
+
11
+
12
+ class PageScreenshot(Ops):
13
+ """
14
+ Факт: сделать скриншот текущей страницы.
15
+ Возвращает путь к PNG-файлу.
16
+ """
17
+
18
+ def __init__(
19
+ self, name: str, full_page: bool = False, omit_background: bool = False
20
+ ):
21
+ self.name = name
22
+ self.full_page = full_page
23
+ self.omit_background = omit_background
24
+
25
+ def _get_step_description(self, persona: Any) -> str:
26
+ return f"{persona} делает скриншот страницы '{self.name}'"
27
+
28
+ def _perform(self, persona: Any, *args: Any, **kwargs: Any) -> str:
29
+ page = persona.skill(SkillId.BROWSER).page
30
+ ts = int(time.time() * 1000)
31
+ base_name = sanitize_filename(self.name)
32
+ path = screenshots_dir() / f"{ts}-{base_name}.png"
33
+ page.screenshot(
34
+ path=str(path),
35
+ full_page=self.full_page,
36
+ omit_background=self.omit_background,
37
+ )
38
+ allure.attach.file(
39
+ str(path), name=base_name, attachment_type=allure.attachment_type.PNG
40
+ )
41
+ return str(path)
42
+
43
+
44
+ class ElementScreenshot(Ops):
45
+ """
46
+ Факт: сделать скриншот элемента.
47
+ Возвращает путь к PNG-файлу.
48
+ """
49
+
50
+ def __init__(self, element: Element, name: Optional[str] = None):
51
+ self.element = element
52
+ self.name = name or element.name
53
+
54
+ def _get_step_description(self, persona: Any) -> str:
55
+ return f"{persona} делает скриншот элемента '{self.element.name}' как '{self.name}'"
56
+
57
+ def _perform(self, persona: Any, *args: Any, **kwargs: Any) -> str:
58
+ page = persona.skill(SkillId.BROWSER).page
59
+ ts = int(time.time() * 1000)
60
+ base_name = sanitize_filename(self.name)
61
+ path = screenshots_dir() / f"{ts}-{base_name}.png"
62
+
63
+ locator = self.element.resolve(page)
64
+ locator.screenshot(path=str(path))
65
+ allure.attach.file(
66
+ str(path), name=base_name, attachment_type=allure.attachment_type.PNG
67
+ )
68
+ return str(path)
@@ -0,0 +1,43 @@
1
+ from typing import Any, Dict, List
2
+
3
+ from persona_dsl.components.ops import Ops
4
+ from persona_dsl.skills.core.skill_definition import SkillId
5
+ from persona_dsl.pages.elements import Table
6
+
7
+
8
+ class TableData(Ops):
9
+ """
10
+ Извлекает данные из таблицы в виде списка словарей.
11
+ Ключи — заголовки столбцов, значения — текст ячеек.
12
+ """
13
+
14
+ def __init__(self, table_element: Table):
15
+ self.table = table_element
16
+
17
+ def _get_step_description(self, persona: Any) -> str:
18
+ return f"{persona} извлекает данные из таблицы '{self.table.name}'"
19
+
20
+ def _perform(self, persona: Any, *args: Any, **kwargs: Any) -> List[Dict[str, str]]:
21
+ page = persona.skill(SkillId.BROWSER).page
22
+ table_locator = self.table.resolve(page)
23
+
24
+ # Заголовки
25
+ header_locators = table_locator.locator("th, [role=columnheader]").all()
26
+ headers = [(h.text_content() or "").strip() for h in header_locators]
27
+
28
+ data: List[Dict[str, str]] = []
29
+
30
+ # Строки таблицы (постараемся избегать header-строк)
31
+ rows_locator = table_locator.locator("tbody tr, tr[role=row], :scope > tr")
32
+ for row in rows_locator.all():
33
+ cell_locs = row.locator("td, [role=cell], th, [role=rowheader]").all()
34
+ # Берём только строки, где количество ячеек равно количеству заголовков
35
+ if headers and len(cell_locs) == len(headers):
36
+ row_dict: Dict[str, str] = {}
37
+ for i, cell in enumerate(cell_locs):
38
+ text = (cell.text_content() or "").strip()
39
+ row_dict[headers[i]] = text
40
+ if any(v for v in row_dict.values()):
41
+ data.append(row_dict)
42
+
43
+ return data
@@ -0,0 +1,23 @@
1
+ from typing import Any, Optional
2
+
3
+ from persona_dsl.components.ops import Ops
4
+ from persona_dsl.skills.core.skill_definition import SkillId
5
+
6
+
7
+ class WaitForNavigation(Ops):
8
+ """
9
+ Атомарное действие: дождаться загрузки страницы/сети.
10
+
11
+ По умолчанию ждём состояния 'networkidle' для стабильности перед дальнейшими действиями/генерацией.
12
+ """
13
+
14
+ def __init__(self, state: str = "networkidle", timeout: Optional[float] = 60000):
15
+ self.state = state
16
+ self.timeout = timeout
17
+
18
+ def _get_step_description(self, persona: Any) -> str:
19
+ return f"{persona} ожидает состояние загрузки страницы '{self.state}'"
20
+
21
+ def _perform(self, persona: Any, *args: Any, **kwargs: Any) -> None:
22
+ page = persona.skill(SkillId.BROWSER).page
23
+ page.wait_for_load_state(self.state, timeout=self.timeout)