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,1230 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import logging
|
|
6
|
+
import functools
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, Tuple, Type, List, Optional, Generator, TYPE_CHECKING
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
import allure
|
|
15
|
+
import pytest
|
|
16
|
+
import yaml
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from persona_dsl.components.expectation import Expectation
|
|
21
|
+
from persona_dsl.persona import Persona
|
|
22
|
+
from persona_dsl.utils.config import Config
|
|
23
|
+
from persona_dsl.skills.core.skill_definition import SkillId
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DataDrivenError(Exception):
|
|
30
|
+
"""Кастомное исключение для ошибок в @data_driven."""
|
|
31
|
+
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _taas_ready() -> bool:
|
|
36
|
+
"""
|
|
37
|
+
Проверяет, корректно ли сконфигурирован контекст TaaS:
|
|
38
|
+
- установлен TAAS_RUN_ID
|
|
39
|
+
- TAAS_STREAM_MAXLEN является положительным целым
|
|
40
|
+
"""
|
|
41
|
+
if not os.environ.get("TAAS_RUN_ID"):
|
|
42
|
+
return False
|
|
43
|
+
raw = os.getenv("TAAS_STREAM_MAXLEN", "10000")
|
|
44
|
+
try:
|
|
45
|
+
return int(raw) > 0
|
|
46
|
+
except Exception:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- Allure Step Instrumentation ---
|
|
51
|
+
_original_allure_step = allure.step
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@contextmanager
|
|
55
|
+
def _instrumented_allure_step(description: str) -> Generator[Any, None, None]:
|
|
56
|
+
"""Обертка над allure.step, которая отправляет события в TaaS."""
|
|
57
|
+
from .utils.taas_integration import publish_taas_event
|
|
58
|
+
|
|
59
|
+
start_time = time.time()
|
|
60
|
+
publish_taas_event(
|
|
61
|
+
{
|
|
62
|
+
"event": "step_start",
|
|
63
|
+
"type": "allure_step",
|
|
64
|
+
"data": {"description": description, "timestamp": start_time},
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
status = "passed"
|
|
69
|
+
try:
|
|
70
|
+
with _original_allure_step(description) as s:
|
|
71
|
+
yield s
|
|
72
|
+
except Exception:
|
|
73
|
+
status = "failed"
|
|
74
|
+
raise
|
|
75
|
+
finally:
|
|
76
|
+
end_time = time.time()
|
|
77
|
+
duration = end_time - start_time
|
|
78
|
+
publish_taas_event(
|
|
79
|
+
{
|
|
80
|
+
"event": "step_end",
|
|
81
|
+
"type": "allure_step",
|
|
82
|
+
"data": {
|
|
83
|
+
"description": description,
|
|
84
|
+
"timestamp": end_time,
|
|
85
|
+
"duration": duration,
|
|
86
|
+
"status": status,
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --- End Allure Step Instrumentation ---
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _get_skill_config(
|
|
96
|
+
config: Config, skill_type: str, name: str
|
|
97
|
+
) -> Optional[Dict[str, Any]]:
|
|
98
|
+
stype_config = config.skills.get(skill_type)
|
|
99
|
+
if stype_config is None:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
if name == "default":
|
|
103
|
+
if "default" in stype_config and isinstance(stype_config["default"], dict):
|
|
104
|
+
return stype_config["default"]
|
|
105
|
+
|
|
106
|
+
# If there are no named configs (sub-dicts), the whole thing is the default config
|
|
107
|
+
if not any(isinstance(v, dict) for v in stype_config.values()):
|
|
108
|
+
return stype_config
|
|
109
|
+
|
|
110
|
+
# Otherwise, collect only non-dict values for the default config
|
|
111
|
+
implicit_config = {
|
|
112
|
+
k: v for k, v in stype_config.items() if not isinstance(v, dict)
|
|
113
|
+
}
|
|
114
|
+
return implicit_config or None
|
|
115
|
+
|
|
116
|
+
named_config = stype_config.get(name)
|
|
117
|
+
return named_config if isinstance(named_config, dict) else None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _create_selenoid_session(
|
|
121
|
+
hub_url: str, capabilities: Dict[str, Any], retries: int = 3, timeout: float = 10.0
|
|
122
|
+
) -> Tuple[str, str]:
|
|
123
|
+
"""
|
|
124
|
+
Создает сессию в Selenoid через WebDriver API с повторными попытками.
|
|
125
|
+
Возвращает кортеж (ws_endpoint, session_id).
|
|
126
|
+
"""
|
|
127
|
+
session_url = f"{hub_url.rstrip('/')}/wd/hub/session"
|
|
128
|
+
last_error: Optional[Exception] = None
|
|
129
|
+
|
|
130
|
+
for attempt in range(retries + 1):
|
|
131
|
+
try:
|
|
132
|
+
logger.info(
|
|
133
|
+
f"Попытка создания сессии Selenoid ({attempt + 1}/{retries + 1})..."
|
|
134
|
+
)
|
|
135
|
+
# requests imported at module level
|
|
136
|
+
resp = requests.post(
|
|
137
|
+
session_url, json={"capabilities": capabilities}, timeout=timeout
|
|
138
|
+
)
|
|
139
|
+
if resp.status_code == 200:
|
|
140
|
+
data = resp.json()
|
|
141
|
+
# Формат ответа WebDriver: { "value": { "sessionId": "...", "capabilities": ... } }
|
|
142
|
+
value = data.get("value", {})
|
|
143
|
+
session_id = value.get("sessionId") or data.get("sessionId")
|
|
144
|
+
|
|
145
|
+
if not session_id:
|
|
146
|
+
raise RuntimeError(f"Selenoid не вернул sessionId: {data}")
|
|
147
|
+
|
|
148
|
+
# Формируем WS URL для CDP
|
|
149
|
+
parsed = urlparse(hub_url)
|
|
150
|
+
ws_scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
151
|
+
ws_endpoint = f"{ws_scheme}://{parsed.netloc}/devtools/{session_id}"
|
|
152
|
+
|
|
153
|
+
logger.info(f"Сессия Selenoid создана: {session_id}")
|
|
154
|
+
return ws_endpoint, session_id
|
|
155
|
+
else:
|
|
156
|
+
logger.warning(
|
|
157
|
+
f"Ошибка создания сессии Selenoid (Code {resp.status_code}): {resp.text}"
|
|
158
|
+
)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
last_error = e
|
|
161
|
+
logger.warning(f"Ошибка подключения к Selenoid: {e}")
|
|
162
|
+
|
|
163
|
+
if attempt < retries:
|
|
164
|
+
time.sleep(2.0)
|
|
165
|
+
|
|
166
|
+
raise RuntimeError(
|
|
167
|
+
f"Не удалось создать сессию Selenoid после {retries + 1} попыток. Последняя ошибка: {last_error}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _get_persona_config(config: Config, role: str) -> Optional[Dict[str, Any]]:
|
|
172
|
+
personas_config = config.personas
|
|
173
|
+
if role == "default":
|
|
174
|
+
if "default" in personas_config and isinstance(
|
|
175
|
+
personas_config["default"], dict
|
|
176
|
+
):
|
|
177
|
+
return personas_config["default"]
|
|
178
|
+
implicit_config = {
|
|
179
|
+
k: v for k, v in personas_config.items() if not isinstance(v, dict)
|
|
180
|
+
}
|
|
181
|
+
return implicit_config or None
|
|
182
|
+
named_config = personas_config.get(role)
|
|
183
|
+
return named_config if isinstance(named_config, dict) else None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _build_chromium_args(cfg: Dict[str, Any]) -> List[str]:
|
|
187
|
+
"""Строит список аргументов командной строки для Chromium-based браузеров."""
|
|
188
|
+
args = list(cfg.get("args") or [])
|
|
189
|
+
|
|
190
|
+
if cfg.get("no_sandbox", False):
|
|
191
|
+
args.append("--no-sandbox")
|
|
192
|
+
|
|
193
|
+
disable_features = list(cfg.get("disable_features") or [])
|
|
194
|
+
|
|
195
|
+
if cfg.get("ignore_https_errors", False):
|
|
196
|
+
args.append("--ignore-certificate-errors")
|
|
197
|
+
# Отключаем принудительное обновление до HTTPS и связанные с ним предупреждения
|
|
198
|
+
if "HttpsUpgrades" not in disable_features:
|
|
199
|
+
disable_features.append("HttpsUpgrades")
|
|
200
|
+
if "EnforceHttps" not in disable_features:
|
|
201
|
+
disable_features.append("EnforceHttps")
|
|
202
|
+
|
|
203
|
+
# Для HTTP сайтов отключаем предупреждения о небезопасности
|
|
204
|
+
base_url = cfg.get("base_url")
|
|
205
|
+
if base_url:
|
|
206
|
+
parsed_url = urlparse(base_url)
|
|
207
|
+
if parsed_url.scheme == "http":
|
|
208
|
+
origin = f"{parsed_url.scheme}://{parsed_url.netloc}"
|
|
209
|
+
args.append(f"--unsafely-treat-insecure-origin-as-secure={origin}")
|
|
210
|
+
|
|
211
|
+
if disable_features:
|
|
212
|
+
features_str = ",".join(disable_features)
|
|
213
|
+
args.append(f"--disable-features={features_str}")
|
|
214
|
+
|
|
215
|
+
return args
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class PersonaDispatcher:
|
|
219
|
+
def __init__(
|
|
220
|
+
self,
|
|
221
|
+
config: Config,
|
|
222
|
+
persona_class: Type[Persona],
|
|
223
|
+
request: pytest.FixtureRequest,
|
|
224
|
+
):
|
|
225
|
+
self.config = config
|
|
226
|
+
self.persona_class = persona_class
|
|
227
|
+
self.request = request
|
|
228
|
+
self._personas: Dict[str, Persona] = {}
|
|
229
|
+
self._resources: Dict[str, Any] = {}
|
|
230
|
+
|
|
231
|
+
def __call__(self, role_id: str = "default") -> Persona:
|
|
232
|
+
return self.persona(role_id)
|
|
233
|
+
|
|
234
|
+
def persona(self, role_id: str | Enum = "default") -> Persona:
|
|
235
|
+
role_id_str = role_id.value if isinstance(role_id, Enum) else str(role_id)
|
|
236
|
+
if role_id_str not in self._personas:
|
|
237
|
+
persona_cfg = _get_persona_config(self.config, role_id_str)
|
|
238
|
+
if not persona_cfg:
|
|
239
|
+
if role_id_str == "default":
|
|
240
|
+
# Создаем пустую конфигурацию для персоны по умолчанию
|
|
241
|
+
persona_cfg = {"name": "Персона"}
|
|
242
|
+
else:
|
|
243
|
+
pytest.fail(
|
|
244
|
+
f"Конфигурация для роли '{role_id_str}' не найдена в 'config/{self.config.env}.yaml'"
|
|
245
|
+
)
|
|
246
|
+
persona_name = persona_cfg.get("name", "Персона")
|
|
247
|
+
new_persona = self.persona_class(name=persona_name)
|
|
248
|
+
|
|
249
|
+
# Инициализация RolePersona
|
|
250
|
+
new_persona.role_id = role_id_str
|
|
251
|
+
new_persona.params = persona_cfg or {}
|
|
252
|
+
|
|
253
|
+
auth_key = persona_cfg.get("auth_key")
|
|
254
|
+
if auth_key:
|
|
255
|
+
if not isinstance(auth_key, str):
|
|
256
|
+
pytest.fail(
|
|
257
|
+
f"Поле 'auth_key' для роли '{role_id_str}' должно быть строкой.",
|
|
258
|
+
pytrace=False,
|
|
259
|
+
)
|
|
260
|
+
if auth_key not in self.config.auth:
|
|
261
|
+
pytest.fail(
|
|
262
|
+
f"Ключ авторизации '{auth_key}' для роли '{role_id_str}' не найден в config/auth.yaml",
|
|
263
|
+
pytrace=False,
|
|
264
|
+
)
|
|
265
|
+
secrets_data = self.config.auth.get(auth_key)
|
|
266
|
+
if not isinstance(secrets_data, dict):
|
|
267
|
+
pytest.fail(
|
|
268
|
+
f"Данные для ключа авторизации '{auth_key}' в config/auth.yaml должны быть словарём.",
|
|
269
|
+
pytrace=False,
|
|
270
|
+
)
|
|
271
|
+
new_persona.secrets = secrets_data
|
|
272
|
+
else:
|
|
273
|
+
new_persona.secrets = {}
|
|
274
|
+
|
|
275
|
+
# LCL-37: Прокидываем конфигурацию ретраев
|
|
276
|
+
new_persona.retries_config = self.config.retries or {}
|
|
277
|
+
|
|
278
|
+
def provider(p: Persona, st: SkillId, sn: str) -> Any:
|
|
279
|
+
return self._get_skill_for_persona(p, st, sn, role_id_str)
|
|
280
|
+
|
|
281
|
+
new_persona.set_skill_provider(provider)
|
|
282
|
+
# Экспонируем pytest request внутрь персоны для Ops, которым нужен доступ к CLI-флагам
|
|
283
|
+
setattr(new_persona, "_pytest_request", self.request)
|
|
284
|
+
self._personas[role_id_str] = new_persona
|
|
285
|
+
return self._personas[role_id_str]
|
|
286
|
+
|
|
287
|
+
def _get_skill_for_persona(
|
|
288
|
+
self, persona: Persona, skill_id: Any, name: str, role_id: str
|
|
289
|
+
) -> Any:
|
|
290
|
+
name_str = name
|
|
291
|
+
# skill_id is SkillId(str, Enum), so .value works if it is an instance,
|
|
292
|
+
# but here we rely on it being string-like or having .value
|
|
293
|
+
skill_val = getattr(skill_id, "value", str(skill_id))
|
|
294
|
+
skill_key = f"{skill_val}.{name_str}"
|
|
295
|
+
if skill_key not in persona.skills.get(skill_val, {}):
|
|
296
|
+
skill_instance = self._create_skill_instance(skill_id, name_str, role_id)
|
|
297
|
+
persona.learn((skill_val, name_str, skill_instance))
|
|
298
|
+
return persona.skills[skill_val][name_str]
|
|
299
|
+
|
|
300
|
+
def _create_skill_instance(self, skill_id: Any, name: str, role_id: str) -> Any:
|
|
301
|
+
cfg = _get_skill_config(self.config, skill_id.value, name)
|
|
302
|
+
if cfg is None:
|
|
303
|
+
pytest.fail(
|
|
304
|
+
f"Конфигурация для навыка '{skill_id.value}.{name}' не найдена."
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# skill_id can be SkillId enum or string
|
|
308
|
+
skill_val = getattr(skill_id, "value", str(skill_id))
|
|
309
|
+
|
|
310
|
+
if skill_val == "browser":
|
|
311
|
+
from persona_dsl.skills.use_browser import UseBrowser
|
|
312
|
+
from playwright.sync_api import (
|
|
313
|
+
sync_playwright,
|
|
314
|
+
) # локальный импорт для избежания тяжёлых импортов на уровне модуля
|
|
315
|
+
|
|
316
|
+
if "playwright" not in self._resources:
|
|
317
|
+
self._resources["playwright"] = sync_playwright().start()
|
|
318
|
+
pw = self._resources["playwright"]
|
|
319
|
+
browser_type = getattr(pw, cfg.get("type", "chromium"))
|
|
320
|
+
|
|
321
|
+
selenoid_url = cfg.get("selenoid_url")
|
|
322
|
+
ws_endpoint = cfg.get("ws_endpoint")
|
|
323
|
+
|
|
324
|
+
if selenoid_url:
|
|
325
|
+
# Создание сессии в Selenoid
|
|
326
|
+
selenoid_opts = cfg.get("selenoid_options", {})
|
|
327
|
+
|
|
328
|
+
# Определяем browserName для Selenoid
|
|
329
|
+
requested_type = cfg.get("type", "chromium")
|
|
330
|
+
if requested_type == "sberbrowser":
|
|
331
|
+
browser_name = "sberbrowser"
|
|
332
|
+
# Playwright не знает про sberbrowser, используем chromium
|
|
333
|
+
browser_type = getattr(pw, "chromium")
|
|
334
|
+
elif requested_type == "chromium":
|
|
335
|
+
browser_name = "chrome" # Selenoid обычно использует "chrome" или "chromium"
|
|
336
|
+
else:
|
|
337
|
+
browser_name = requested_type
|
|
338
|
+
|
|
339
|
+
# Базовые capabilities
|
|
340
|
+
caps = {
|
|
341
|
+
"browserName": browser_name,
|
|
342
|
+
"version": cfg.get("version", ""),
|
|
343
|
+
"enableVNC": selenoid_opts.get("enableVNC", True),
|
|
344
|
+
"enableVideo": selenoid_opts.get("enableVideo", False),
|
|
345
|
+
}
|
|
346
|
+
# Добавляем специфичные опции
|
|
347
|
+
if "goog:chromeOptions" in selenoid_opts:
|
|
348
|
+
caps["goog:chromeOptions"] = selenoid_opts["goog:chromeOptions"]
|
|
349
|
+
|
|
350
|
+
# Другие опции первого уровня
|
|
351
|
+
for k, v in selenoid_opts.items():
|
|
352
|
+
if k not in caps:
|
|
353
|
+
caps[k] = v
|
|
354
|
+
|
|
355
|
+
# --- UNIFICATION: Inject config args into capabilities ---
|
|
356
|
+
common_args = _build_chromium_args(cfg)
|
|
357
|
+
|
|
358
|
+
opts_key = "goog:chromeOptions"
|
|
359
|
+
if "browserName" in caps and "firefox" in caps["browserName"]:
|
|
360
|
+
opts_key = "moz:firefoxOptions"
|
|
361
|
+
|
|
362
|
+
if opts_key not in caps:
|
|
363
|
+
caps[opts_key] = {}
|
|
364
|
+
if "args" not in caps[opts_key]:
|
|
365
|
+
caps[opts_key]["args"] = []
|
|
366
|
+
|
|
367
|
+
# 1. Headless (from config)
|
|
368
|
+
if cfg.get("headless", False):
|
|
369
|
+
if "--headless" not in caps[opts_key]["args"]:
|
|
370
|
+
caps[opts_key]["args"].append("--headless")
|
|
371
|
+
|
|
372
|
+
# 2. Common Args (no_sandbox, disable_features, etc)
|
|
373
|
+
# Avoid duplicates
|
|
374
|
+
existing_args = set(caps[opts_key]["args"])
|
|
375
|
+
for arg in common_args:
|
|
376
|
+
if arg not in existing_args:
|
|
377
|
+
caps[opts_key]["args"].append(arg)
|
|
378
|
+
|
|
379
|
+
# 3. Добавление имени теста
|
|
380
|
+
if self.request and self.request.node:
|
|
381
|
+
caps["name"] = self.request.node.name
|
|
382
|
+
if caps.get("enableVideo"):
|
|
383
|
+
caps["videoName"] = f"{self.request.node.name}.mp4"
|
|
384
|
+
|
|
385
|
+
# Все capabilities оборачиваем в alwaysMatch для W3C
|
|
386
|
+
w3c_caps = {"alwaysMatch": caps}
|
|
387
|
+
|
|
388
|
+
retries = cfg.get("session_retries", 3)
|
|
389
|
+
timeout = cfg.get("session_timeout", 30.0)
|
|
390
|
+
|
|
391
|
+
ws_url, session_id = _create_selenoid_session(
|
|
392
|
+
selenoid_url, w3c_caps, retries=retries, timeout=timeout
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Сохраняем для очистки
|
|
396
|
+
if "selenoid_sessions" not in self._resources:
|
|
397
|
+
self._resources["selenoid_sessions"] = []
|
|
398
|
+
self._resources["selenoid_sessions"].append(
|
|
399
|
+
{"url": selenoid_url, "id": session_id}
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
self._resources["browser"] = browser_type.connect_over_cdp(
|
|
403
|
+
ws_url,
|
|
404
|
+
slow_mo=float(cfg.get("slow_mo", 0.0)),
|
|
405
|
+
timeout=float(cfg.get("timeout", 30000.0)),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
elif ws_endpoint:
|
|
409
|
+
# Подключение к удалённому браузеру по WebSocket
|
|
410
|
+
self._resources["browser"] = browser_type.connect_over_cdp(
|
|
411
|
+
ws_endpoint,
|
|
412
|
+
slow_mo=float(cfg.get("slow_mo", 0.0)),
|
|
413
|
+
timeout=float(cfg.get("timeout", 30000.0)),
|
|
414
|
+
)
|
|
415
|
+
else:
|
|
416
|
+
# Локальный запуск
|
|
417
|
+
args = _build_chromium_args(cfg)
|
|
418
|
+
|
|
419
|
+
executable_path = cfg.get("executable_path")
|
|
420
|
+
channel = cfg.get("channel")
|
|
421
|
+
slow_mo = float(cfg.get("slow_mo", 0.0))
|
|
422
|
+
timeout = float(cfg.get("timeout", 30000.0))
|
|
423
|
+
|
|
424
|
+
launch_opts = {
|
|
425
|
+
"headless": cfg.get("headless", True),
|
|
426
|
+
"executable_path": executable_path,
|
|
427
|
+
"args": args if args else None,
|
|
428
|
+
"channel": channel,
|
|
429
|
+
"slow_mo": slow_mo,
|
|
430
|
+
"timeout": timeout,
|
|
431
|
+
}
|
|
432
|
+
launch_opts = {
|
|
433
|
+
k: v for k, v in launch_opts.items() if v is not None
|
|
434
|
+
}
|
|
435
|
+
self._resources["browser"] = browser_type.launch(**launch_opts)
|
|
436
|
+
self._resources["browser_contexts"] = {}
|
|
437
|
+
browser = self._resources["browser"]
|
|
438
|
+
contexts: Dict[str, Any] = self._resources["browser_contexts"]
|
|
439
|
+
if role_id not in contexts:
|
|
440
|
+
context_options = {
|
|
441
|
+
"base_url": cfg.get("base_url"),
|
|
442
|
+
"ignore_https_errors": cfg.get("ignore_https_errors", False),
|
|
443
|
+
}
|
|
444
|
+
viewport = cfg.get("viewport")
|
|
445
|
+
if (
|
|
446
|
+
isinstance(viewport, dict)
|
|
447
|
+
and "width" in viewport
|
|
448
|
+
and "height" in viewport
|
|
449
|
+
):
|
|
450
|
+
context_options["viewport"] = viewport
|
|
451
|
+
contexts[role_id] = browser.new_context(**context_options)
|
|
452
|
+
context = contexts[role_id]
|
|
453
|
+
# Используем первую страницу в контексте или создаем новую, если ее нет.
|
|
454
|
+
# Это обеспечивает персистентную сессию для роли в рамках теста.
|
|
455
|
+
# Это обеспечивает персистентную сессию для роли в рамках теста.
|
|
456
|
+
if not context.pages:
|
|
457
|
+
page = context.new_page()
|
|
458
|
+
else:
|
|
459
|
+
page = context.pages[0]
|
|
460
|
+
|
|
461
|
+
base_url_from_config = cfg.get("base_url")
|
|
462
|
+
|
|
463
|
+
# Глобальные таймауты страницы из конфига (мс)
|
|
464
|
+
default_timeout = cfg.get("default_timeout", 0.0)
|
|
465
|
+
default_navigation_timeout = cfg.get("default_navigation_timeout", 0.0)
|
|
466
|
+
|
|
467
|
+
# Валидация и применение в навык
|
|
468
|
+
def _to_float_or_none(v: Any) -> Optional[float]:
|
|
469
|
+
if v is None:
|
|
470
|
+
return None
|
|
471
|
+
if not isinstance(v, (int, float, str)):
|
|
472
|
+
pytest.fail(
|
|
473
|
+
f"Параметр таймаута должен быть числом или строкой, а не {type(v).__name__}",
|
|
474
|
+
pytrace=False,
|
|
475
|
+
)
|
|
476
|
+
try:
|
|
477
|
+
return float(v)
|
|
478
|
+
except (ValueError, TypeError):
|
|
479
|
+
pytest.fail(
|
|
480
|
+
f"Параметр таймаута '{v}' не может быть преобразован в число.",
|
|
481
|
+
pytrace=False,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
default_timeout_f = _to_float_or_none(default_timeout)
|
|
485
|
+
default_navigation_timeout_f = _to_float_or_none(default_navigation_timeout)
|
|
486
|
+
|
|
487
|
+
return UseBrowser.from_page(
|
|
488
|
+
page,
|
|
489
|
+
base_url=base_url_from_config,
|
|
490
|
+
default_timeout=default_timeout_f,
|
|
491
|
+
default_navigation_timeout=default_navigation_timeout_f,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
if skill_val == "api":
|
|
495
|
+
from persona_dsl.skills.use_api import UseAPI
|
|
496
|
+
|
|
497
|
+
base_url = cfg.get("base_url")
|
|
498
|
+
if not isinstance(base_url, str) or not base_url:
|
|
499
|
+
pytest.fail(
|
|
500
|
+
f"Конфигурация 'skills.api.{name}.base_url' должна быть непустой строкой.",
|
|
501
|
+
pytrace=False,
|
|
502
|
+
)
|
|
503
|
+
verify_ssl = bool(cfg.get("verify_ssl", True))
|
|
504
|
+
timeout = float(cfg.get("timeout", 10.0))
|
|
505
|
+
retries = int(cfg.get("retries", 0))
|
|
506
|
+
backoff = float(cfg.get("backoff", 0.5))
|
|
507
|
+
log_bodies = bool(cfg.get("log_bodies", False))
|
|
508
|
+
headers = cfg.get("headers")
|
|
509
|
+
if headers is not None and not isinstance(headers, dict):
|
|
510
|
+
pytest.fail(
|
|
511
|
+
f"Конфигурация 'skills.api.{name}.headers' должна быть словарём (dict).",
|
|
512
|
+
pytrace=False,
|
|
513
|
+
)
|
|
514
|
+
return UseAPI.at(
|
|
515
|
+
base_url=base_url,
|
|
516
|
+
verify_ssl=verify_ssl,
|
|
517
|
+
timeout=timeout,
|
|
518
|
+
retries=retries,
|
|
519
|
+
backoff=backoff,
|
|
520
|
+
log_bodies=log_bodies,
|
|
521
|
+
default_headers=headers,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
if skill_val == "db":
|
|
525
|
+
from persona_dsl.skills.use_database import UseDatabase
|
|
526
|
+
|
|
527
|
+
driver_name = cfg.get("driver")
|
|
528
|
+
dsn = cfg.get("dsn")
|
|
529
|
+
if not isinstance(driver_name, str) or not driver_name:
|
|
530
|
+
pytest.fail(
|
|
531
|
+
f"Конфигурация 'skills.db.{name}' должна содержать непустую строку 'driver'."
|
|
532
|
+
)
|
|
533
|
+
if not isinstance(dsn, str) or not dsn:
|
|
534
|
+
pytest.fail(
|
|
535
|
+
f"Конфигурация 'skills.db.{name}.dsn' должна быть непустой строкой."
|
|
536
|
+
)
|
|
537
|
+
user = cfg.get("user")
|
|
538
|
+
password = cfg.get("password")
|
|
539
|
+
# если в dsn есть плейсхолдеры, требуем соответствующие поля
|
|
540
|
+
if ("{user}" in dsn or "{password}" in dsn) and (
|
|
541
|
+
not isinstance(user, str) or not isinstance(password, str)
|
|
542
|
+
):
|
|
543
|
+
pytest.fail(
|
|
544
|
+
f"Конфигурация 'skills.db.{name}' должна содержать 'user' и 'password' для подстановки в dsn.",
|
|
545
|
+
pytrace=False,
|
|
546
|
+
)
|
|
547
|
+
if "{user}" in dsn or "{password}" in dsn:
|
|
548
|
+
dsn_final = dsn.format(user=user or "", password=password or "")
|
|
549
|
+
else:
|
|
550
|
+
dsn_final = dsn
|
|
551
|
+
try:
|
|
552
|
+
driver_module = __import__(driver_name)
|
|
553
|
+
except (ImportError, ModuleNotFoundError) as e:
|
|
554
|
+
pytest.fail(f"Не удалось импортировать драйвер БД '{driver_name}': {e}")
|
|
555
|
+
return UseDatabase.with_dsn(dsn=dsn_final, driver=driver_module)
|
|
556
|
+
|
|
557
|
+
if skill_val == "kafka":
|
|
558
|
+
from persona_dsl.skills.use_kafka import UseKafka
|
|
559
|
+
|
|
560
|
+
bootstrap = cfg.get("bootstrap_servers")
|
|
561
|
+
if not bootstrap:
|
|
562
|
+
pytest.fail("Конфигурация Kafka должна содержать 'bootstrap_servers'")
|
|
563
|
+
group_id = cfg.get("group_id")
|
|
564
|
+
from_offset = cfg.get("from_offset", "latest")
|
|
565
|
+
timeout = float(cfg.get("timeout", 10.0))
|
|
566
|
+
return UseKafka.with_servers(
|
|
567
|
+
bootstrap_servers=bootstrap,
|
|
568
|
+
group_id=group_id,
|
|
569
|
+
from_offset=from_offset,
|
|
570
|
+
timeout=timeout,
|
|
571
|
+
)
|
|
572
|
+
if skill_val == "soap":
|
|
573
|
+
from persona_dsl.skills.use_soap import UseSOAP
|
|
574
|
+
|
|
575
|
+
wsdl_url = cfg.get("wsdl_url")
|
|
576
|
+
if not wsdl_url:
|
|
577
|
+
pytest.fail("Конфигурация SOAP должна содержать 'wsdl_url'")
|
|
578
|
+
return UseSOAP.at(wsdl_url)
|
|
579
|
+
|
|
580
|
+
raise NotImplementedError(
|
|
581
|
+
f"Ленивое создание для навыка '{skill_val}' не реализовано."
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
def _parse_args_with_role(
|
|
585
|
+
self, args: Tuple[Any, ...]
|
|
586
|
+
) -> Tuple[str, Tuple[Any, ...]]:
|
|
587
|
+
"""Разбирает аргументы, отделяя опциональную роль в начале.
|
|
588
|
+
|
|
589
|
+
Поддерживаются:
|
|
590
|
+
- Enum (BaseRole)
|
|
591
|
+
- str (идентификатор роли)
|
|
592
|
+
"""
|
|
593
|
+
role_id = "default"
|
|
594
|
+
if args:
|
|
595
|
+
first = args[0]
|
|
596
|
+
if isinstance(first, Enum):
|
|
597
|
+
role_id = first.value
|
|
598
|
+
args = args[1:]
|
|
599
|
+
elif isinstance(first, str):
|
|
600
|
+
role_id = first
|
|
601
|
+
args = args[1:]
|
|
602
|
+
return role_id, args
|
|
603
|
+
|
|
604
|
+
def make(self, *args: Any) -> None:
|
|
605
|
+
"""Выполняет действия, изменяющие состояние системы."""
|
|
606
|
+
from persona_dsl.components.base_step import BaseStep
|
|
607
|
+
|
|
608
|
+
role_id, actions = self._parse_args_with_role(args)
|
|
609
|
+
if not all(isinstance(arg, BaseStep) for arg in actions):
|
|
610
|
+
raise TypeError("Метод 'make' принимает только шаги (BaseStep).")
|
|
611
|
+
persona_instance = self.persona(role_id)
|
|
612
|
+
for item in actions:
|
|
613
|
+
item.execute(persona_instance)
|
|
614
|
+
|
|
615
|
+
# Синонимы для make
|
|
616
|
+
perform = make
|
|
617
|
+
does = make
|
|
618
|
+
|
|
619
|
+
def get(self, *args: Any) -> Any:
|
|
620
|
+
"""Получает данные из системы, не изменяя ее состояние."""
|
|
621
|
+
from persona_dsl.components.base_step import BaseStep
|
|
622
|
+
|
|
623
|
+
role_id, facts = self._parse_args_with_role(args)
|
|
624
|
+
if not all(isinstance(arg, BaseStep) for arg in facts):
|
|
625
|
+
raise TypeError("Метод 'get' принимает только шаги (BaseStep).")
|
|
626
|
+
if not facts:
|
|
627
|
+
raise ValueError("Метод 'get' требует хотя бы один Fact.")
|
|
628
|
+
|
|
629
|
+
persona_instance = self.persona(role_id)
|
|
630
|
+
results = [fact.execute(persona_instance) for fact in facts]
|
|
631
|
+
|
|
632
|
+
return results[0] if len(results) == 1 else tuple(results)
|
|
633
|
+
|
|
634
|
+
def check(self, actual_value: Any, *expectations: Expectation) -> None:
|
|
635
|
+
"""Проверяет фактическое значение на соответствие ожиданиям."""
|
|
636
|
+
from persona_dsl.components.expectation import Expectation
|
|
637
|
+
|
|
638
|
+
if not all(isinstance(arg, Expectation) for arg in expectations):
|
|
639
|
+
raise TypeError(
|
|
640
|
+
"Метод 'check' может принимать только Expectation в качестве второго и последующих аргументов."
|
|
641
|
+
)
|
|
642
|
+
if not expectations:
|
|
643
|
+
raise ValueError("Метод 'check' требует хотя бы один Expectation.")
|
|
644
|
+
|
|
645
|
+
# Для проверок используется персона по умолчанию, т.к. они не привязаны к роли
|
|
646
|
+
persona_instance = self.persona("default")
|
|
647
|
+
for expectation in expectations:
|
|
648
|
+
expectation.execute(persona_instance, actual_value)
|
|
649
|
+
|
|
650
|
+
def _cleanup_resources(self) -> None:
|
|
651
|
+
for persona in self._personas.values():
|
|
652
|
+
for skill_type in persona.skills.values():
|
|
653
|
+
for skill_instance in skill_type.values():
|
|
654
|
+
if hasattr(skill_instance, "forget"):
|
|
655
|
+
skill_instance.forget()
|
|
656
|
+
# Закрываем все браузерные контексты перед закрытием самого браузера
|
|
657
|
+
if "browser_contexts" in self._resources:
|
|
658
|
+
for context in self._resources["browser_contexts"].values():
|
|
659
|
+
context.close()
|
|
660
|
+
if "browser" in self._resources:
|
|
661
|
+
try:
|
|
662
|
+
self._resources["browser"].close()
|
|
663
|
+
except Exception as e:
|
|
664
|
+
logger.warning(f"Ошибка при закрытии браузера: {e}")
|
|
665
|
+
|
|
666
|
+
# Удаление сессий Selenoid
|
|
667
|
+
if "selenoid_sessions" in self._resources:
|
|
668
|
+
for session in self._resources["selenoid_sessions"]:
|
|
669
|
+
s_url = session["url"]
|
|
670
|
+
s_id = session["id"]
|
|
671
|
+
try:
|
|
672
|
+
del_url = f"{s_url.rstrip('/')}/wd/hub/session/{s_id}"
|
|
673
|
+
logger.info(f"Удаление сессии Selenoid: {s_id}")
|
|
674
|
+
requests.delete(del_url, timeout=5.0)
|
|
675
|
+
except Exception as e:
|
|
676
|
+
logger.warning(f"Ошибка удаления сессии Selenoid {s_id}: {e}")
|
|
677
|
+
if "playwright" in self._resources:
|
|
678
|
+
pw = self._resources["playwright"]
|
|
679
|
+
if not hasattr(pw, "stop") or not callable(pw.stop):
|
|
680
|
+
raise RuntimeError(
|
|
681
|
+
"Playwright-ресурс не имеет метода stop(); нарушен контракт sync_playwright().start()"
|
|
682
|
+
)
|
|
683
|
+
pw.stop()
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def pytest_addoption(parser: Any) -> None:
|
|
687
|
+
parser.addoption(
|
|
688
|
+
"--env", action="store", default="dev", help="Окружение: dev/staging/prod"
|
|
689
|
+
)
|
|
690
|
+
parser.addoption(
|
|
691
|
+
"--generate-pages",
|
|
692
|
+
action="store_true",
|
|
693
|
+
default=False,
|
|
694
|
+
help="Генерация PageObject во время тестов (используется с Op GeneratePageObject).",
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def pytest_configure(config: Any) -> None:
|
|
699
|
+
"""Регистрирует кастомные маркеры и настраивает предупреждения."""
|
|
700
|
+
# 1. Подавляем предупреждения о неизвестных маркерах,
|
|
701
|
+
# чтобы можно было использовать @tag("any_tag") без редактирования pytest.ini
|
|
702
|
+
config.addinivalue_line("filterwarnings", "ignore::pytest.PytestUnknownMarkWarning")
|
|
703
|
+
|
|
704
|
+
# 2. Регистрируем маркер @data_driven из ядра, чтобы не делать это в проекте
|
|
705
|
+
config.addinivalue_line(
|
|
706
|
+
"markers",
|
|
707
|
+
"data_driven: кастомная параметризация теста, управляемая фреймворком",
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
# 3. Регистрируем маркер @persona_retry (LCL-37)
|
|
711
|
+
config.addinivalue_line(
|
|
712
|
+
"markers",
|
|
713
|
+
"persona_retry(max_attempts=1, delay=0.0, ...): автоматический перезапуск теста при падении",
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
# 4. Пробрасываем выбранное окружение в переменную TAAS_ENV для детерминированности генераторов
|
|
717
|
+
try:
|
|
718
|
+
env_opt = config.getoption("--env")
|
|
719
|
+
if env_opt:
|
|
720
|
+
os.environ["TAAS_ENV"] = env_opt
|
|
721
|
+
except Exception:
|
|
722
|
+
# На очень ранних стадиях инициализации getoption может быть недоступен — оставляем окружение как есть.
|
|
723
|
+
pass
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
@pytest.fixture(scope="session")
|
|
727
|
+
def config(pytestconfig: Any) -> Config:
|
|
728
|
+
"""Загружает конфигурацию из файлов в зависимости от окружения."""
|
|
729
|
+
from persona_dsl.utils.config import Config
|
|
730
|
+
|
|
731
|
+
env = pytestconfig.getoption("--env")
|
|
732
|
+
return Config.load(env, project_root=pytestconfig.rootpath)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
@pytest.fixture(scope="session")
|
|
736
|
+
def persona_class() -> Type[Persona]:
|
|
737
|
+
"""Возвращает базовый класс Persona для использования в диспетчере."""
|
|
738
|
+
from persona_dsl.persona import Persona
|
|
739
|
+
|
|
740
|
+
return Persona
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
@pytest.fixture(scope="session", autouse=True)
|
|
744
|
+
def _validate_config(pytestconfig: Any) -> None:
|
|
745
|
+
"""
|
|
746
|
+
Автоматически используемая фикстура для строгой валидации конфигурации проекта
|
|
747
|
+
на основе деклараций в tests/conftest.py (PERSONA_SKILLS, PERSONA_ROLES).
|
|
748
|
+
- Если деклараций нет — конфигурация не требуется и валидация пропускается.
|
|
749
|
+
- Если декларации есть — файл config/{env}.yaml обязателен; при отсутствии/несоответствии — жёсткая ошибка.
|
|
750
|
+
"""
|
|
751
|
+
env = pytestconfig.getoption("--env")
|
|
752
|
+
cfg_path = Path(pytestconfig.rootpath) / "config" / f"{env}.yaml"
|
|
753
|
+
plugins = list(pytestconfig.pluginmanager.get_plugins())
|
|
754
|
+
project_skills: List[Any] = []
|
|
755
|
+
project_roles_enum = None
|
|
756
|
+
for mod in plugins:
|
|
757
|
+
ps = getattr(mod, "PERSONA_SKILLS", None)
|
|
758
|
+
pr = getattr(mod, "PERSONA_ROLES", None)
|
|
759
|
+
if ps and not project_skills:
|
|
760
|
+
project_skills = ps
|
|
761
|
+
if pr and project_roles_enum is None:
|
|
762
|
+
project_roles_enum = pr
|
|
763
|
+
|
|
764
|
+
# Нет деклараций — ничего не валидируем и не загружаем конфиг
|
|
765
|
+
if not project_skills and not project_roles_enum:
|
|
766
|
+
return
|
|
767
|
+
|
|
768
|
+
# Декларации есть — файл обязателен
|
|
769
|
+
if not cfg_path.exists():
|
|
770
|
+
pytest.fail(
|
|
771
|
+
f"Отсутствует файл конфигурации 'config/{env}.yaml', тогда как в conftest.py объявлены "
|
|
772
|
+
f"PERSONA_SKILLS/ROLES. Создайте config/{env}.yaml или уберите декларации.",
|
|
773
|
+
pytrace=False,
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
# Загружаем конфиг и валидируем перечисленные навыки/роли
|
|
777
|
+
# Загружаем конфиг и валидируем перечисленные навыки/роли
|
|
778
|
+
from persona_dsl.utils.config import Config
|
|
779
|
+
|
|
780
|
+
cfg = Config.load(env, project_root=pytestconfig.rootpath)
|
|
781
|
+
errors = []
|
|
782
|
+
|
|
783
|
+
# Проверка навыков
|
|
784
|
+
for skill_item in project_skills:
|
|
785
|
+
if isinstance(skill_item, tuple):
|
|
786
|
+
skill_id, skill_name = skill_item
|
|
787
|
+
else:
|
|
788
|
+
skill_id = skill_item
|
|
789
|
+
skill_name = "default"
|
|
790
|
+
|
|
791
|
+
if not _get_skill_config(cfg, skill_id.value, skill_name):
|
|
792
|
+
error_msg = f" - Именованный навык '{skill_id.value}.{skill_name}'"
|
|
793
|
+
if skill_name == "default":
|
|
794
|
+
error_msg = f" - Базовый навык '{skill_id.value}'"
|
|
795
|
+
errors.append(error_msg)
|
|
796
|
+
|
|
797
|
+
# Проверка ролей
|
|
798
|
+
if project_roles_enum:
|
|
799
|
+
for role_member in project_roles_enum:
|
|
800
|
+
accessible_name = role_member.value
|
|
801
|
+
if not _get_persona_config(cfg, accessible_name):
|
|
802
|
+
errors.append(
|
|
803
|
+
f" - Проектная роль '{accessible_name}' (из {project_roles_enum.__name__})"
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
if errors:
|
|
807
|
+
pytest.fail(
|
|
808
|
+
f"Отсутствует конфигурация в 'config/{env}.yaml' для следующих сущностей, объявленных в conftest.py:\n"
|
|
809
|
+
+ ", ".join(errors),
|
|
810
|
+
pytrace=False,
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
@pytest.fixture(scope="function")
|
|
815
|
+
def persona(
|
|
816
|
+
request: Any, config: Config, persona_class: Type[Persona]
|
|
817
|
+
) -> Generator[PersonaDispatcher, None, None]:
|
|
818
|
+
dispatcher = PersonaDispatcher(config, persona_class, request)
|
|
819
|
+
yield dispatcher
|
|
820
|
+
dispatcher._cleanup_resources()
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
|
824
|
+
def pytest_runtest_makereport(item: Any, call: Any) -> Generator[None, Any, None]:
|
|
825
|
+
outcome = yield
|
|
826
|
+
rep = outcome.get_result()
|
|
827
|
+
if rep.when == "call":
|
|
828
|
+
from persona_dsl.utils.metrics import metrics
|
|
829
|
+
|
|
830
|
+
status = "passed" if rep.passed else "failed"
|
|
831
|
+
tags = {"test_name": item.name, "status": status}
|
|
832
|
+
metrics.gauge("test.duration", rep.duration, tags)
|
|
833
|
+
metrics.counter("test.runs", tags).inc()
|
|
834
|
+
if status == "failed" and "persona" in item.fixturenames:
|
|
835
|
+
dispatcher = item.funcargs.get("persona")
|
|
836
|
+
if dispatcher and "browser" in dispatcher._resources:
|
|
837
|
+
cfg = dispatcher.config
|
|
838
|
+
screenshot_on_fail = cfg.reporting.get("screenshot_on_fail", True)
|
|
839
|
+
pagesource_on_fail = cfg.reporting.get("pagesource_on_fail", True)
|
|
840
|
+
if not screenshot_on_fail and not pagesource_on_fail:
|
|
841
|
+
return
|
|
842
|
+
|
|
843
|
+
browser = dispatcher._resources["browser"]
|
|
844
|
+
if not browser.contexts:
|
|
845
|
+
return
|
|
846
|
+
|
|
847
|
+
from persona_dsl.utils.artifacts import artifacts_dir, sanitize_filename
|
|
848
|
+
|
|
849
|
+
screenshot_dir = artifacts_dir("screenshots")
|
|
850
|
+
|
|
851
|
+
for i, context in enumerate(browser.contexts):
|
|
852
|
+
if not context.pages:
|
|
853
|
+
continue
|
|
854
|
+
page = context.pages[-1]
|
|
855
|
+
context_name = f"context-{i}"
|
|
856
|
+
if screenshot_on_fail:
|
|
857
|
+
screenshot_path = (
|
|
858
|
+
screenshot_dir
|
|
859
|
+
/ f"{sanitize_filename(item.name)}-{context_name}.png"
|
|
860
|
+
)
|
|
861
|
+
page.screenshot(path=str(screenshot_path))
|
|
862
|
+
allure.attach.file(
|
|
863
|
+
str(screenshot_path),
|
|
864
|
+
name=f"screenshot-on-fail-{context_name}",
|
|
865
|
+
attachment_type=allure.attachment_type.PNG,
|
|
866
|
+
)
|
|
867
|
+
if pagesource_on_fail:
|
|
868
|
+
page_source = page.content()
|
|
869
|
+
allure.attach(
|
|
870
|
+
page_source,
|
|
871
|
+
name=f"page-source-on-fail-{context_name}",
|
|
872
|
+
attachment_type=allure.attachment_type.HTML,
|
|
873
|
+
)
|
|
874
|
+
setattr(item, "rep_" + rep.when, rep)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
@lru_cache(maxsize=None)
|
|
878
|
+
def _load_test_data_file(filename: str) -> List[Any]:
|
|
879
|
+
"""Загружает и кеширует тестовые данные из YAML/JSON файла в tests/data."""
|
|
880
|
+
base_dir = Path.cwd() / "tests" / "data"
|
|
881
|
+
name = str(filename)
|
|
882
|
+
|
|
883
|
+
# Список кандидатов: если расширение задано — используем его; иначе пробуем .yaml, .yml, затем .json
|
|
884
|
+
if any(name.endswith(ext) for ext in (".yaml", ".yml", ".json")):
|
|
885
|
+
candidates = [base_dir / name]
|
|
886
|
+
else:
|
|
887
|
+
candidates = [
|
|
888
|
+
base_dir / f"{name}.yaml",
|
|
889
|
+
base_dir / f"{name}.yml",
|
|
890
|
+
base_dir / f"{name}.json",
|
|
891
|
+
]
|
|
892
|
+
|
|
893
|
+
for path in candidates:
|
|
894
|
+
if path.exists():
|
|
895
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
896
|
+
if path.suffix.lower() == ".json":
|
|
897
|
+
data = json.load(f)
|
|
898
|
+
else:
|
|
899
|
+
data = yaml.safe_load(f)
|
|
900
|
+
if not isinstance(data, list):
|
|
901
|
+
raise DataDrivenError(
|
|
902
|
+
f"Данные в файле {path} должны быть списком (list)."
|
|
903
|
+
)
|
|
904
|
+
return data
|
|
905
|
+
|
|
906
|
+
raise DataDrivenError(
|
|
907
|
+
"Файл с тестовыми данными не найден. Ожидались: "
|
|
908
|
+
+ ", ".join(str(p) for p in candidates)
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def pytest_sessionstart(session: Any) -> None:
|
|
913
|
+
"""Вызывается в начале тестовой сессии для monkey-patching allure.step."""
|
|
914
|
+
if _taas_ready():
|
|
915
|
+
allure.step = _instrumented_allure_step # type: ignore[assignment]
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def pytest_sessionfinish(session: Any) -> None:
|
|
919
|
+
"""Вызывается в конце тестовой сессии для восстановления allure.step и отправки финального события."""
|
|
920
|
+
if _taas_ready():
|
|
921
|
+
# Отправляем финальное событие из самого процесса pytest, чтобы избежать гонки состояний
|
|
922
|
+
final_status = "COMPLETED" if session.exitstatus == 0 else "FAILED"
|
|
923
|
+
finish_event = {
|
|
924
|
+
"event": "finish",
|
|
925
|
+
"data": {"status": final_status, "return_code": int(session.exitstatus)},
|
|
926
|
+
}
|
|
927
|
+
try:
|
|
928
|
+
from .utils.taas_integration import publish_taas_event
|
|
929
|
+
|
|
930
|
+
publish_taas_event(finish_event)
|
|
931
|
+
except Exception as e:
|
|
932
|
+
try:
|
|
933
|
+
logger.warning("TaaS: не удалось отправить finish событие: %s", e)
|
|
934
|
+
except Exception:
|
|
935
|
+
pass
|
|
936
|
+
|
|
937
|
+
allure.step = _original_allure_step
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _resolve_data_driven_expressions(val: Any) -> Any:
|
|
941
|
+
"""Рекурсивно вычисляет SystemVar-выражения в данных."""
|
|
942
|
+
# SystemVar-узел: { "kind":"system", "name": "...", "args": { ... } }
|
|
943
|
+
if isinstance(val, dict):
|
|
944
|
+
if val.get("kind") == "system" and "name" in val:
|
|
945
|
+
name = val.get("name")
|
|
946
|
+
args = val.get("args", {}) or {}
|
|
947
|
+
# Разрешаем аргументы рекурсивно (могут содержать вложенные VarRef/SystemVar/литералы)
|
|
948
|
+
resolved_args = {
|
|
949
|
+
k: _resolve_data_driven_expressions(v) for k, v in args.items()
|
|
950
|
+
}
|
|
951
|
+
from persona_dsl.utils import data_providers as _dp
|
|
952
|
+
|
|
953
|
+
func = getattr(_dp, str(name), None)
|
|
954
|
+
if callable(func):
|
|
955
|
+
try:
|
|
956
|
+
return func(**resolved_args)
|
|
957
|
+
except Exception as e:
|
|
958
|
+
raise ValueError(
|
|
959
|
+
f"@data_driven expr: ошибка вычисления SystemVar '{name}' с args={resolved_args}: {e}"
|
|
960
|
+
)
|
|
961
|
+
raise ValueError(
|
|
962
|
+
f"@data_driven expr: неизвестный провайдер SystemVar '{name}'"
|
|
963
|
+
)
|
|
964
|
+
# Обычный словарь — обрабатываем все значения
|
|
965
|
+
return {k: _resolve_data_driven_expressions(v) for k, v in val.items()}
|
|
966
|
+
# Списки — обрабатываем элементы
|
|
967
|
+
if isinstance(val, list):
|
|
968
|
+
return [_resolve_data_driven_expressions(x) for x in val]
|
|
969
|
+
# Прочие типы — возвращаем как есть
|
|
970
|
+
return val
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def pytest_generate_tests(metafunc: Any) -> None:
|
|
974
|
+
"""Генерирует параметризованные тесты для маркера @data_driven."""
|
|
975
|
+
for marker in metafunc.definition.iter_markers(name="data_driven"):
|
|
976
|
+
source = marker.args[0] if marker.args else marker.kwargs.get("source")
|
|
977
|
+
is_file = marker.kwargs.get("file", False)
|
|
978
|
+
|
|
979
|
+
if source is None:
|
|
980
|
+
pytest.fail(
|
|
981
|
+
"Источник данных 'source' для @data_driven не был предоставлен.",
|
|
982
|
+
pytrace=False,
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
try:
|
|
986
|
+
scenarios = _load_test_data_file(source) if is_file else source
|
|
987
|
+
except DataDrivenError as e:
|
|
988
|
+
pytest.fail(str(e), pytrace=False)
|
|
989
|
+
|
|
990
|
+
if not isinstance(scenarios, list) or not all(
|
|
991
|
+
isinstance(s, dict) for s in scenarios
|
|
992
|
+
):
|
|
993
|
+
pytest.fail(
|
|
994
|
+
f"Данные для @data_driven должны быть списком словарей. Получено: {type(scenarios)}",
|
|
995
|
+
pytrace=False,
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
# Требуем variant_id ('test_id') и формируем стабильные id ДО вычисления выражений
|
|
999
|
+
name_template = marker.kwargs.get("name_template")
|
|
1000
|
+
missing = [i for i, s in enumerate(scenarios) if "test_id" not in s]
|
|
1001
|
+
if missing:
|
|
1002
|
+
pytest.fail(
|
|
1003
|
+
f"@data_driven: каждая вариация должна содержать поле 'test_id'. Отсутствует в индексах: {missing}",
|
|
1004
|
+
pytrace=False,
|
|
1005
|
+
)
|
|
1006
|
+
if isinstance(name_template, str) and name_template:
|
|
1007
|
+
|
|
1008
|
+
def _fmt_id(s: Any) -> str:
|
|
1009
|
+
try:
|
|
1010
|
+
return name_template.format(**s)
|
|
1011
|
+
except Exception:
|
|
1012
|
+
return str(s.get("test_id"))
|
|
1013
|
+
|
|
1014
|
+
ids = [_fmt_id(s) for s in scenarios]
|
|
1015
|
+
else:
|
|
1016
|
+
ids = [str(s["test_id"]) for s in scenarios]
|
|
1017
|
+
|
|
1018
|
+
# Сначала вычисляем выражения SystemVar/VarRef для всех сценариев.
|
|
1019
|
+
# Это важно: ошибки провайдеров должны приводить к немедленной диагностике,
|
|
1020
|
+
# даже если далее фильтрация могла бы исключить вариации.
|
|
1021
|
+
resolved_scenarios = []
|
|
1022
|
+
original_variant_id = os.environ.get("TAAS_VARIANT_ID")
|
|
1023
|
+
try:
|
|
1024
|
+
for s in scenarios:
|
|
1025
|
+
test_id = s.get("test_id")
|
|
1026
|
+
if test_id:
|
|
1027
|
+
os.environ["TAAS_VARIANT_ID"] = str(test_id)
|
|
1028
|
+
resolved_scenarios.append(_resolve_data_driven_expressions(s))
|
|
1029
|
+
except ValueError as e:
|
|
1030
|
+
pytest.fail(f"ValueError: {e}", pytrace=False)
|
|
1031
|
+
finally:
|
|
1032
|
+
# Восстанавливаем окружение и состояние генераторов
|
|
1033
|
+
if original_variant_id is None:
|
|
1034
|
+
os.environ.pop("TAAS_VARIANT_ID", None)
|
|
1035
|
+
else:
|
|
1036
|
+
os.environ["TAAS_VARIANT_ID"] = original_variant_id
|
|
1037
|
+
|
|
1038
|
+
# Затем фильтруем вариации по test_id через параметр only и/или переменную окружения TAAS_VARIANT_IDS
|
|
1039
|
+
only_marker = marker.kwargs.get("only")
|
|
1040
|
+
only_env = os.environ.get("TAAS_VARIANT_IDS")
|
|
1041
|
+
|
|
1042
|
+
is_filtered = False
|
|
1043
|
+
active_filter_set: Optional[set] = None
|
|
1044
|
+
|
|
1045
|
+
if only_marker is not None:
|
|
1046
|
+
is_filtered = True
|
|
1047
|
+
try:
|
|
1048
|
+
active_filter_set = {str(x) for x in only_marker}
|
|
1049
|
+
except TypeError:
|
|
1050
|
+
active_filter_set = set()
|
|
1051
|
+
|
|
1052
|
+
if only_env: # Skips None and ""
|
|
1053
|
+
is_filtered = True
|
|
1054
|
+
env_set = {s.strip() for s in only_env.split(",") if s.strip()}
|
|
1055
|
+
if active_filter_set is None:
|
|
1056
|
+
active_filter_set = env_set
|
|
1057
|
+
else:
|
|
1058
|
+
active_filter_set.intersection_update(env_set)
|
|
1059
|
+
|
|
1060
|
+
if is_filtered:
|
|
1061
|
+
final_scenarios = []
|
|
1062
|
+
final_ids = []
|
|
1063
|
+
if active_filter_set: # Filter is not empty
|
|
1064
|
+
for s, i in zip(resolved_scenarios, ids):
|
|
1065
|
+
if str(s.get("test_id")) in active_filter_set:
|
|
1066
|
+
final_scenarios.append(s)
|
|
1067
|
+
final_ids.append(i)
|
|
1068
|
+
|
|
1069
|
+
if not final_scenarios:
|
|
1070
|
+
pytest.fail(
|
|
1071
|
+
"@data_driven: ни одна вариация не осталась после фильтрации (only/TAAS_VARIANT_IDS).",
|
|
1072
|
+
pytrace=False,
|
|
1073
|
+
)
|
|
1074
|
+
resolved_scenarios = final_scenarios
|
|
1075
|
+
ids = final_ids
|
|
1076
|
+
|
|
1077
|
+
if "scenario" in metafunc.fixturenames:
|
|
1078
|
+
if resolved_scenarios:
|
|
1079
|
+
metafunc.parametrize("scenario", resolved_scenarios, ids=ids)
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def pytest_runtest_setup(item: Any) -> None:
|
|
1083
|
+
"""
|
|
1084
|
+
Перед каждым тестом:
|
|
1085
|
+
1. Устанавливаем TAAS_VARIANT_ID для детерминированного seed'а.
|
|
1086
|
+
2. (LCL-37) Оборачиваем тестовую функцию в ретрай-декоратор, если задан маркер @persona_retry.
|
|
1087
|
+
"""
|
|
1088
|
+
from persona_dsl.utils.retry import RetryPolicy # Lazy import for coverage
|
|
1089
|
+
|
|
1090
|
+
# 1. TAAS_VARIANT_ID logic
|
|
1091
|
+
prev = os.environ.get("TAAS_VARIANT_ID")
|
|
1092
|
+
variant_id = None
|
|
1093
|
+
callspec = getattr(item, "callspec", None)
|
|
1094
|
+
if callspec and "scenario" in callspec.params:
|
|
1095
|
+
scenario = callspec.params["scenario"]
|
|
1096
|
+
|
|
1097
|
+
if isinstance(scenario, dict):
|
|
1098
|
+
# Устанавливаем TAAS_VARIANT_ID
|
|
1099
|
+
variant_id = str(scenario.get("test_id") or "")
|
|
1100
|
+
|
|
1101
|
+
if variant_id:
|
|
1102
|
+
os.environ["TAAS_VARIANT_ID"] = variant_id
|
|
1103
|
+
setattr(item, "_prev_taas_variant_id", prev)
|
|
1104
|
+
|
|
1105
|
+
# 2. LCL-37: Retry Logic
|
|
1106
|
+
retry_marker = item.get_closest_marker("persona_retry")
|
|
1107
|
+
policy = None
|
|
1108
|
+
|
|
1109
|
+
if retry_marker:
|
|
1110
|
+
kwargs = retry_marker.kwargs.copy()
|
|
1111
|
+
# Простейшая поддержка: передаем kwargs как есть в RetryPolicy
|
|
1112
|
+
# Пользователь должен передавать типы исключений в retry_on, если нужно
|
|
1113
|
+
policy = RetryPolicy(**kwargs)
|
|
1114
|
+
else:
|
|
1115
|
+
# Проверяем глобальный конфиг (если доступен через item.config -> Config)
|
|
1116
|
+
# Это "мягкая" интеграция: если конфиг загружен и там есть retries.default
|
|
1117
|
+
try:
|
|
1118
|
+
env = item.config.getoption("--env")
|
|
1119
|
+
# Загружаем конфиг (он кешируется внутри Config.load если реализован singleton,
|
|
1120
|
+
# но даже если нет - чтение файла допустимо в setup)
|
|
1121
|
+
cfg = Config.load(env, project_root=item.config.rootpath)
|
|
1122
|
+
default_policy = cfg.retries.get("default") if cfg.retries else None
|
|
1123
|
+
|
|
1124
|
+
if default_policy:
|
|
1125
|
+
# Преобразуем dict конфига в RetryPolicy
|
|
1126
|
+
# Исключаем поля, которые не принимает конструктор (если есть лишние)
|
|
1127
|
+
# Но RetryPolicy принимает **kwargs, так что просто распаковываем
|
|
1128
|
+
policy = RetryPolicy(**default_policy)
|
|
1129
|
+
except Exception:
|
|
1130
|
+
# Если конфиг не загрузился или кривой - игнорируем, работаем без ретраев
|
|
1131
|
+
policy = None
|
|
1132
|
+
|
|
1133
|
+
if policy:
|
|
1134
|
+
original_func = item.obj
|
|
1135
|
+
|
|
1136
|
+
@functools.wraps(original_func)
|
|
1137
|
+
def retrying_test_func(*args: Any, **kwargs: Any) -> Any:
|
|
1138
|
+
attempt = 0
|
|
1139
|
+
while True:
|
|
1140
|
+
attempt += 1
|
|
1141
|
+
try:
|
|
1142
|
+
return original_func(*args, **kwargs)
|
|
1143
|
+
except Exception as e:
|
|
1144
|
+
if policy.should_retry(attempt, e):
|
|
1145
|
+
logger.warning(
|
|
1146
|
+
f"Тест '{item.name}' упал (попытка {attempt}/{policy.max_attempts}): {e}. Ретрай через {policy.delay}с."
|
|
1147
|
+
)
|
|
1148
|
+
policy.wait(attempt)
|
|
1149
|
+
if policy.on_retry_action:
|
|
1150
|
+
policy.execute_action()
|
|
1151
|
+
continue
|
|
1152
|
+
raise
|
|
1153
|
+
|
|
1154
|
+
item.obj = retrying_test_func
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def pytest_runtest_teardown(item: Any, nextitem: Any) -> None:
|
|
1158
|
+
"""
|
|
1159
|
+
Восстанавливаем предыдущее значение TAAS_VARIANT_ID после теста.
|
|
1160
|
+
"""
|
|
1161
|
+
prev = getattr(item, "_prev_taas_variant_id", None)
|
|
1162
|
+
if prev is None:
|
|
1163
|
+
os.environ.pop("TAAS_VARIANT_ID", None)
|
|
1164
|
+
else:
|
|
1165
|
+
os.environ["TAAS_VARIANT_ID"] = prev
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
@pytest.fixture(scope="session")
|
|
1169
|
+
def testdata() -> Any:
|
|
1170
|
+
"""Фабричная фикстура для загрузки тестовых данных из `tests/data/*.{yaml,yml,json}`."""
|
|
1171
|
+
return _load_test_data_file
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def pytest_runtest_logstart(nodeid: str, location: Any) -> None:
|
|
1175
|
+
"""
|
|
1176
|
+
Отправляет JSON-событие в TaaS при старте каждого теста.
|
|
1177
|
+
Работает только в контексте TaaS (когда задан TAAS_RUN_ID), чтобы не влиять на обычный вывод pytest.
|
|
1178
|
+
"""
|
|
1179
|
+
if not _taas_ready():
|
|
1180
|
+
return
|
|
1181
|
+
# Используем имя теста (без пути) как description для корневого узла
|
|
1182
|
+
test_name = nodeid.split("::")[-1]
|
|
1183
|
+
event_data = {
|
|
1184
|
+
"event": "test_start",
|
|
1185
|
+
"data": {
|
|
1186
|
+
"node_id": nodeid,
|
|
1187
|
+
"description": test_name,
|
|
1188
|
+
"timestamp": time.time(),
|
|
1189
|
+
},
|
|
1190
|
+
}
|
|
1191
|
+
try:
|
|
1192
|
+
from .utils.taas_integration import publish_taas_event
|
|
1193
|
+
|
|
1194
|
+
publish_taas_event(event_data)
|
|
1195
|
+
except Exception as e:
|
|
1196
|
+
try:
|
|
1197
|
+
logger.warning("TaaS: не удалось отправить событие test_start: %s", str(e))
|
|
1198
|
+
except Exception:
|
|
1199
|
+
pass
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def pytest_runtest_logreport(report: Any) -> None:
|
|
1203
|
+
"""
|
|
1204
|
+
Отправляет JSON-событие в stdout по завершении каждой фазы теста (setup, call, teardown).
|
|
1205
|
+
Нас интересует только 'call' для отображения итогового статуса.
|
|
1206
|
+
Работает только в контексте TaaS (когда задан TAAS_RUN_ID), чтобы не влиять на обычный вывод pytest.
|
|
1207
|
+
"""
|
|
1208
|
+
if not _taas_ready():
|
|
1209
|
+
return
|
|
1210
|
+
if report.when == "call":
|
|
1211
|
+
test_name = report.nodeid.split("::")[-1]
|
|
1212
|
+
event_data = {
|
|
1213
|
+
"event": "test_end",
|
|
1214
|
+
"data": {
|
|
1215
|
+
"node_id": report.nodeid,
|
|
1216
|
+
"description": test_name,
|
|
1217
|
+
"status": report.outcome,
|
|
1218
|
+
"duration": report.duration,
|
|
1219
|
+
"timestamp": time.time(),
|
|
1220
|
+
},
|
|
1221
|
+
}
|
|
1222
|
+
try:
|
|
1223
|
+
from .utils.taas_integration import publish_taas_event
|
|
1224
|
+
|
|
1225
|
+
publish_taas_event(event_data)
|
|
1226
|
+
except Exception as e:
|
|
1227
|
+
try:
|
|
1228
|
+
logger.warning("TaaS: не удалось отправить событие test_end: %s", e)
|
|
1229
|
+
except Exception:
|
|
1230
|
+
pass
|