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.
- persona_dsl/__init__.py +35 -0
- persona_dsl/components/action.py +10 -0
- persona_dsl/components/base_step.py +251 -0
- persona_dsl/components/combined_step.py +68 -0
- persona_dsl/components/expectation.py +10 -0
- persona_dsl/components/fact.py +10 -0
- persona_dsl/components/goal.py +10 -0
- persona_dsl/components/ops.py +7 -0
- persona_dsl/components/step.py +75 -0
- persona_dsl/expectations/generic/__init__.py +15 -0
- persona_dsl/expectations/generic/contains_item.py +19 -0
- persona_dsl/expectations/generic/contains_the_text.py +15 -0
- persona_dsl/expectations/generic/has_entries.py +21 -0
- persona_dsl/expectations/generic/is_equal.py +24 -0
- persona_dsl/expectations/generic/is_greater_than.py +18 -0
- persona_dsl/expectations/generic/path_equal.py +27 -0
- persona_dsl/expectations/web/__init__.py +5 -0
- persona_dsl/expectations/web/is_displayed.py +13 -0
- persona_dsl/expectations/web/matches_aria_snapshot.py +222 -0
- persona_dsl/expectations/web/matches_screenshot.py +160 -0
- persona_dsl/generators/__init__.py +5 -0
- persona_dsl/generators/api_generator.py +423 -0
- persona_dsl/generators/cli.py +431 -0
- persona_dsl/generators/page_generator.py +1140 -0
- persona_dsl/ops/api/__init__.py +5 -0
- persona_dsl/ops/api/json_as.py +104 -0
- persona_dsl/ops/api/json_response.py +48 -0
- persona_dsl/ops/api/send_request.py +41 -0
- persona_dsl/ops/db/__init__.py +5 -0
- persona_dsl/ops/db/execute_sql.py +22 -0
- persona_dsl/ops/db/fetch_all.py +29 -0
- persona_dsl/ops/db/fetch_one.py +22 -0
- persona_dsl/ops/kafka/__init__.py +4 -0
- persona_dsl/ops/kafka/message_in_topic.py +89 -0
- persona_dsl/ops/kafka/send_message.py +35 -0
- persona_dsl/ops/soap/__init__.py +4 -0
- persona_dsl/ops/soap/call_operation.py +24 -0
- persona_dsl/ops/soap/operation_result.py +24 -0
- persona_dsl/ops/web/__init__.py +37 -0
- persona_dsl/ops/web/aria_snapshot.py +87 -0
- persona_dsl/ops/web/click.py +30 -0
- persona_dsl/ops/web/current_path.py +17 -0
- persona_dsl/ops/web/element_attribute.py +24 -0
- persona_dsl/ops/web/element_is_visible.py +27 -0
- persona_dsl/ops/web/element_text.py +28 -0
- persona_dsl/ops/web/elements_count.py +42 -0
- persona_dsl/ops/web/fill.py +41 -0
- persona_dsl/ops/web/generate_page_object.py +118 -0
- persona_dsl/ops/web/input_value.py +23 -0
- persona_dsl/ops/web/navigate.py +52 -0
- persona_dsl/ops/web/press_key.py +37 -0
- persona_dsl/ops/web/rich_aria_snapshot.py +159 -0
- persona_dsl/ops/web/screenshot.py +68 -0
- persona_dsl/ops/web/table_data.py +43 -0
- persona_dsl/ops/web/wait_for_navigation.py +23 -0
- persona_dsl/pages/__init__.py +133 -0
- persona_dsl/pages/elements.py +998 -0
- persona_dsl/pages/page.py +44 -0
- persona_dsl/pages/virtual_page.py +94 -0
- persona_dsl/persona.py +125 -0
- persona_dsl/pytest_plugin.py +1230 -0
- persona_dsl/runtime/dist/persona_bundle.js +1077 -0
- persona_dsl/skills/__init__.py +7 -0
- persona_dsl/skills/core/base.py +41 -0
- persona_dsl/skills/core/skill_definition.py +30 -0
- persona_dsl/skills/use_api.py +251 -0
- persona_dsl/skills/use_browser.py +78 -0
- persona_dsl/skills/use_database.py +129 -0
- persona_dsl/skills/use_kafka.py +135 -0
- persona_dsl/skills/use_soap.py +66 -0
- persona_dsl/utils/__init__.py +0 -0
- persona_dsl/utils/artifacts.py +22 -0
- persona_dsl/utils/config.py +54 -0
- persona_dsl/utils/data_providers.py +159 -0
- persona_dsl/utils/decorators.py +80 -0
- persona_dsl/utils/metrics.py +69 -0
- persona_dsl/utils/naming.py +14 -0
- persona_dsl/utils/path.py +202 -0
- persona_dsl/utils/retry.py +51 -0
- persona_dsl/utils/taas_integration.py +124 -0
- persona_dsl/utils/waits.py +112 -0
- persona_dsl-26.1.21.44.dist-info/METADATA +233 -0
- persona_dsl-26.1.21.44.dist-info/RECORD +86 -0
- persona_dsl-26.1.21.44.dist-info/WHEEL +5 -0
- persona_dsl-26.1.21.44.dist-info/entry_points.txt +6 -0
- persona_dsl-26.1.21.44.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.get_tree(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
|
+
RichAriaSnapshot.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,159 @@
|
|
|
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) -> str:
|
|
26
|
+
"""
|
|
27
|
+
Выполняет операцию получения снепшота.
|
|
28
|
+
Returns:
|
|
29
|
+
str: YAML-строка снепшота (как в Playwright).
|
|
30
|
+
"""
|
|
31
|
+
snapshot_tree = self.get_tree(persona)
|
|
32
|
+
|
|
33
|
+
# 2. Рендерим YAML для отчетов (визуализация)
|
|
34
|
+
snapshot_yaml = self.to_yaml(snapshot_tree)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
allure.attach(
|
|
38
|
+
snapshot_yaml,
|
|
39
|
+
name="rich-aria-page",
|
|
40
|
+
attachment_type=allure.attachment_type.YAML,
|
|
41
|
+
)
|
|
42
|
+
except Exception:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
return snapshot_yaml
|
|
46
|
+
|
|
47
|
+
def get_tree(self, persona: Any) -> Dict[str, Any] | List[Any]:
|
|
48
|
+
"""
|
|
49
|
+
Получает сырое дерево элементов (dict/list) от Persona Runtime.
|
|
50
|
+
Используется в генераторах кода.
|
|
51
|
+
"""
|
|
52
|
+
page = persona.skill(SkillId.BROWSER).page
|
|
53
|
+
|
|
54
|
+
# 1. Получаем чистое JSON-дерево от ядра
|
|
55
|
+
snapshot_tree = page.evaluate(
|
|
56
|
+
"window.__PERSONA__ && window.__PERSONA__.getTreeForTransport && window.__PERSONA__.getTreeForTransport()"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if not snapshot_tree:
|
|
60
|
+
raise RuntimeError(
|
|
61
|
+
"Persona Runtime не вернул JSON-дерево (getTreeForTransport). Убедитесь, что Runtime инициализирован."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return snapshot_tree
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def to_yaml(root: Dict[str, Any] | List[Any]) -> str:
|
|
68
|
+
"""
|
|
69
|
+
Преобразует JSON-дерево в читаемый структурный YAML формат.
|
|
70
|
+
Формат:
|
|
71
|
+
role "name" [ref=...]:
|
|
72
|
+
- /props...
|
|
73
|
+
- children...
|
|
74
|
+
"""
|
|
75
|
+
lines = []
|
|
76
|
+
if isinstance(root, dict):
|
|
77
|
+
lines.extend(RichAriaSnapshot._render_node_yaml(root, indent=0))
|
|
78
|
+
elif isinstance(root, list):
|
|
79
|
+
for node in root:
|
|
80
|
+
lines.extend(RichAriaSnapshot._render_node_yaml(node, indent=0))
|
|
81
|
+
|
|
82
|
+
return "\n".join(lines)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _render_node_yaml(node: Dict[str, Any] | str, indent: int) -> List[str]:
|
|
86
|
+
if isinstance(node, str):
|
|
87
|
+
# Текстовый узел (строка)
|
|
88
|
+
return [f"{' ' * indent}- \"{node}\""]
|
|
89
|
+
|
|
90
|
+
lines = []
|
|
91
|
+
prefix = " " * indent
|
|
92
|
+
|
|
93
|
+
role = node.get("role", "generic")
|
|
94
|
+
name = node.get("name", "")
|
|
95
|
+
ref = node.get("ref")
|
|
96
|
+
|
|
97
|
+
# Формируем заголовок узла
|
|
98
|
+
# Пример: - combobox "Bank" [ref=xyz]
|
|
99
|
+
|
|
100
|
+
# Экранирование имени
|
|
101
|
+
safe_name = ""
|
|
102
|
+
if name:
|
|
103
|
+
escaped_name = name.replace("`", "").replace('"', '\\"')
|
|
104
|
+
safe_name = f' "{escaped_name}"'
|
|
105
|
+
|
|
106
|
+
ref_str = f" [ref={ref}]" if ref else ""
|
|
107
|
+
|
|
108
|
+
# Собираем пропы, которые надо показать в строке (статусы)
|
|
109
|
+
status_parts = []
|
|
110
|
+
# ARIA/состояния из Runtime кладутся на корень узла, а не в props
|
|
111
|
+
if node.get("checked") is True:
|
|
112
|
+
status_parts.append("[checked]")
|
|
113
|
+
elif node.get("checked") == "mixed":
|
|
114
|
+
status_parts.append("[checked=mixed]")
|
|
115
|
+
|
|
116
|
+
if node.get("pressed") is True:
|
|
117
|
+
status_parts.append("[pressed]")
|
|
118
|
+
elif node.get("pressed") == "mixed":
|
|
119
|
+
status_parts.append("[pressed=mixed]")
|
|
120
|
+
|
|
121
|
+
if node.get("disabled"):
|
|
122
|
+
status_parts.append("[disabled]")
|
|
123
|
+
if node.get("expanded") is True:
|
|
124
|
+
status_parts.append("[expanded]")
|
|
125
|
+
if node.get("selected"):
|
|
126
|
+
status_parts.append("[selected]")
|
|
127
|
+
if node.get("level") is not None:
|
|
128
|
+
status_parts.append(f"[level={node['level']}]")
|
|
129
|
+
# Поле hidden помечает элементы, скрытые визуально/по ARIA
|
|
130
|
+
if node.get("hidden"):
|
|
131
|
+
status_parts.append("[hidden]")
|
|
132
|
+
|
|
133
|
+
status_str = " ".join(status_parts)
|
|
134
|
+
if status_str:
|
|
135
|
+
status_str = " " + status_str
|
|
136
|
+
|
|
137
|
+
# Основная строка узла
|
|
138
|
+
header = f"{prefix}- {role}{safe_name}{ref_str}{status_str}:"
|
|
139
|
+
|
|
140
|
+
children = node.get("children", [])
|
|
141
|
+
props = node.get("props", {})
|
|
142
|
+
|
|
143
|
+
# Если нет детей и пропов, убираем двоеточие
|
|
144
|
+
if not children and not props:
|
|
145
|
+
header = header.rstrip(":")
|
|
146
|
+
lines.append(header)
|
|
147
|
+
else:
|
|
148
|
+
lines.append(header)
|
|
149
|
+
|
|
150
|
+
# Рендерим props
|
|
151
|
+
for k, v in props.items():
|
|
152
|
+
if v: # Не выводим пустые пропы
|
|
153
|
+
lines.append(f'{prefix} - /{k}: "{v}"')
|
|
154
|
+
|
|
155
|
+
# Рендерим детей
|
|
156
|
+
for child in children:
|
|
157
|
+
lines.extend(RichAriaSnapshot._render_node_yaml(child, indent + 1))
|
|
158
|
+
|
|
159
|
+
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)
|