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