From fbfd07336925bb914ef843a98912e50a8580a7a1 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Wed, 24 Jun 2026 10:37:41 +0700 Subject: [PATCH] feat: add ses-test CLI for interactive YAML scenario testing - New ses-test command: run a single YAML scenario against real SES step-by-step with persistent session tracking - --verbose shows raw JSON responses, --dry-run validates YAML only - --session / --robot-id overrides for iterative debugging - Add verify param to SESClient for self-signed HTTPS support - Add load_scenario_file() to config.py for single-file loading - Update README with ses-test usage examples and output samples --- README.md | 86 ++++++++- pyproject.toml | 1 + src/ses_monitor/cli.py | 299 ++++++++++++++++++++++++++++++ src/ses_monitor/config.py | 33 ++++ src/ses_monitor/session_client.py | 4 +- 5 files changed, 418 insertions(+), 5 deletions(-) create mode 100644 src/ses_monitor/cli.py diff --git a/README.md b/README.md index c52c413..e1ca2bf 100644 --- a/README.md +++ b/README.md @@ -286,15 +286,93 @@ path: "request.data[0].reply.confidence" 3. **Создайте файл сценария**, описав шаги диалога: ```bash - cp config/scenarios/example_liveness.yaml config/scenarios/$(hostname).yaml - vim config/scenarios/$(hostname).yaml + cp config/scenarios/example_dialog.yaml config/scenarios/my_project.yaml + vim config/scenarios/my_project.yaml ``` -4. **Протестируйте вручную** шаги через curl: +4. **Протестируйте сценарий** утилитой `ses-test`: ```bash - curl -X POST "http://:6190/ses/ask/?text=Привет&channel=monitoring" + # Базовый прогон + ses-test config/scenarios/my_project.yaml --ses-url http://ses-host:6190 + + # С детальным выводом JSON-ответов (режим отладки) + ses-test config/scenarios/my_project.yaml --ses-url http://ses-host:6190 -v + + # Только валидация YAML без запросов к SES + ses-test config/scenarios/my_project.yaml --ses-url http://ses-host:6190 --dry-run + + # С переопределением robot_id (если не указан в YAML) + ses-test config/scenarios/my_project.yaml --ses-url http://ses-host:6190 --robot-id abc-123 + + # С фиксированной сессией для повторных прогонов + ses-test config/scenarios/my_project.yaml --ses-url http://ses-host:6190 --session debug-1 ``` + **Примеры вывода:** + + Успешный прогон (без `-v`): + ``` + === Сценарий: 'test: МФЦ диалог' === + SES: http://ses-host:6190 + Robot ID: abc-123-def-456 + Канал: monitoring + Шагов: 2 + Сессия: monitoring_test_1782271424 + + ── Шаг 1/2 ──────────────────────────────────────── + → Привет + ← Время ответа: 176ms + [PASS] path=error equals=0 + [PASS] path=answer[0].messages[0] not_empty + + ── Шаг 2/2 ──────────────────────────────────────── + → Записаться на приём + ← Время ответа: 382ms + [PASS] path=error equals=0 + [PASS] path=answer[0].messages[0] not_empty + + ────────────────────────────────────────────────── + РЕЗУЛЬТАТ: УСПЕШНО (2/2 шагов) + ``` + + Проваленный шаг: + ``` + ── Шаг 1/1 ──────────────────────────────────────── + → Привет + ← Время ответа: 177ms + [PASS] path=error equals=0 + [FAIL] Поле 'answer[0].messages[0]' = 'пожалуйста выберите...' + не содержит 'несуществующая_фраза_xyz_999' + ────────────────────────────────────────────────── + РЕЗУЛЬТАТ: ПРОВАЛЕН (шаг 1) + ``` + + Режим отладки (`-v`): + ``` + ── Шаг 1/2 ──────────────────────────────────────── + → Привет + ← Время ответа: 176ms + ← RAW JSON: + { + "error": 0, + "question": "Привет", + "answer": [{ + "messages": [ + "Пожалуйста выберите действие или задайте вопрос.\n\n{button:...}\n" + ], + "voice": "Коля", + "interruptible": false + }], + "session": "monitoring_test_1782271424", + "uuid": "132e530c-..." + } + + [PASS] path=error equals=0 + [PASS] path=answer[0].messages[0] not_empty + ``` + + **Exit code:** `0` — все шаги пройдены, `1` — есть ошибки, `2` — ошибка аргументов. + 5. **Запустите демон** — метрики агрегируются автоматически (шаблон Zabbix универсален) ## Расширение системы diff --git a/pyproject.toml b/pyproject.toml index f33e56e..996ca1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dev = [ [project.scripts] ses-monitor = "ses_monitor.main:main" +ses-test = "ses_monitor.cli:main" [build-system] requires = ["setuptools>=61.0"] diff --git a/src/ses_monitor/cli.py b/src/ses_monitor/cli.py new file mode 100644 index 0000000..dbd6c59 --- /dev/null +++ b/src/ses_monitor/cli.py @@ -0,0 +1,299 @@ +""" +CLI-утилита для интерактивного тестирования YAML-сценариев SES. + +Позволяет аналитикам прогонять сценарий пошагово против реального SES-сервера +и видеть результат каждой проверки expect — без запуска всего демона ses-monitor. + +Usage: + ses-test scenario.yaml --ses-url http://ses-host:6190 + ses-test scenario.yaml --ses-url http://ses-host:6190 --verbose + ses-test scenario.yaml --ses-url http://ses-host:6190 --dry-run +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +import time +from typing import Any, Dict, List + +from ses_monitor.config import SESConfig, load_scenario_file +from ses_monitor.session_client import SESClient +from ses_monitor.utils import timer + + +def _format_expectation_results( + expectations: List[Dict[str, Any]], + response: Dict[str, Any], +) -> List[str]: + """Форматирует результаты проверок ожиданий для одного шага. + + Возвращает список строк [PASS]/[FAIL] для каждого expect-условия. + """ + results: List[str] = [] + for exp in expectations: + path = exp.get("path", "?") + for condition, expected in exp.items(): + if condition == "path": + continue + # check_expectation импортируется динамически для компактности — + # используем check_all_expectations с одним expectation + from ses_monitor.utils import check_expectation + + ok, msg = check_expectation(response, path, condition, expected) + if ok: + if condition in ("exists", "not_empty"): + results.append(f" [PASS] path={path} {condition}") + else: + results.append(f" [PASS] path={path} {condition}={expected!r}") + else: + results.append(f" [FAIL] {msg}") + return results + + +def _truncate(text: str, max_len: int = 120) -> str: + """Обрезает длинную строку для вывода.""" + if len(text) <= max_len: + return text + return text[:max_len] + "…" + + +def parse_args(argv: List[str] | None = None) -> argparse.Namespace: + """Разбирает аргументы командной строки.""" + parser = argparse.ArgumentParser( + prog="ses-test", + description="Пошаговое тестирование YAML-сценария SES", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""\ +Примеры: + ses-test my_scenario.yaml --ses-url http://ses:6190 + ses-test my_scenario.yaml --ses-url http://ses:6190 -v + ses-test my_scenario.yaml --ses-url http://ses:6190 --robot-id abc-123 + ses-test my_scenario.yaml --ses-url http://ses:6190 --dry-run + ses-test my_scenario.yaml --ses-url https://ses:443 --no-verify + ses-test my_scenario.yaml --ses-url http://ses:6190 --session debug-1 -v + """, + ) + + parser.add_argument( + "scenario", + help="Путь к YAML-файлу сценария", + ) + parser.add_argument( + "--ses-url", + required=True, + metavar="URL", + help="URL SES API (например, http://ses-host:6190)", + ) + parser.add_argument( + "--robot-id", + metavar="ID", + help="Переопределить robot_id из YAML", + ) + parser.add_argument( + "--channel", + metavar="CH", + default="monitoring", + help="Канал диалога (по умолчанию: monitoring)", + ) + parser.add_argument( + "--timeout", + type=int, + metavar="SEC", + help="Таймаут HTTP-запроса, секунды (по умолчанию: из сценария или 30)", + ) + parser.add_argument( + "--session", + metavar="ID", + help="ID сессии для повторных прогонов (по умолчанию: авто-генерация)", + ) + parser.add_argument( + "--restart-closed", + action="store_true", + help="Переоткрывать закрытую сессию (restartClosed=1)", + ) + parser.add_argument( + "--no-verify", + action="store_true", + help="Не проверять SSL-сертификат (для self-signed HTTPS)", + ) + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Выводить полный JSON-ответ SES для каждого шага", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Только валидация YAML, без запросов к SES", + ) + + return parser.parse_args(argv) + + +def main(argv: List[str] | None = None) -> int: + """Точка входа CLI-утилиты ses-test. + + Returns: + 0 — все шаги пройдены. + 1 — сценарий провален или ошибка выполнения. + 2 — ошибка аргументов или конфигурации. + """ + args = parse_args(argv) + + # Принудительный UTF-8 для Windows-консоли + if sys.platform == "win32": + sys.stdout.reconfigure(encoding="utf-8") + + # Загрузка сценария + try: + scenario = load_scenario_file(args.scenario) + except FileNotFoundError as e: + print(f"ОШИБКА: {e}", file=sys.stderr) + return 2 + except RuntimeError as e: + print(f"ОШИБКА: {e}", file=sys.stderr) + return 2 + + # Применяем CLI-переопределения поверх YAML + if args.robot_id: + scenario.robot_id = args.robot_id + if args.channel: + scenario.channel = args.channel + if args.timeout: + scenario.timeout = args.timeout + if args.restart_closed: + scenario.restart_closed = True + + # Проверка обязательных полей + if not scenario.robot_id: + print( + "ОШИБКА: не указан robot_id.\n" + " Добавьте 'robot_id: \"uuid-...\"' в YAML-сценарий\n" + " или передайте через --robot-id.\n" + " Узнать ID: GET http://:6190/ses/robot/listall", + file=sys.stderr, + ) + return 2 + + if not scenario.steps: + print( + f"ПРЕДУПРЕЖДЕНИЕ: сценарий '{scenario.name}' не содержит шагов.", + file=sys.stderr, + ) + + # Dry-run: только валидация, без запросов + if args.dry_run: + total_expects = sum(len(s.expect) for s in scenario.steps) + print(f"Сценарий '{scenario.name}' валиден.") + print(f" Шагов: {len(scenario.steps)}") + print(f" Ожиданий: {total_expects}") + print(f" Robot ID: {scenario.robot_id}") + print(f" Канал: {scenario.channel or 'monitoring'}") + print(" Режим: dry-run (запросы к SES не выполнялись)") + return 0 + + # Полноценный прогон + try: + return asyncio.run(_async_main(args, scenario)) + except KeyboardInterrupt: + print("\nПрервано пользователем.", file=sys.stderr) + return 1 + + +async def _async_main(args: argparse.Namespace, scenario) -> int: + """Асинхронное ядро: создаёт клиент, прогоняет шаги, выводит результат.""" + ses_config = SESConfig( + base_url=args.ses_url, + channel=scenario.channel or args.channel, + request_timeout=scenario.timeout, + ) + client = SESClient(ses_config, verify=not args.no_verify) + + session_id: str = args.session or f"monitoring_test_{int(time.time())}" + + try: + # ------------------------------- + # Шапка + print() + print(f"=== Сценарий: '{scenario.name}' ===") + print(f"SES: {args.ses_url}") + print(f"Robot ID: {scenario.robot_id}") + print(f"Канал: {scenario.channel or 'monitoring'}") + print(f"Шагов: {len(scenario.steps)}") + print(f"Сессия: {session_id}") + if args.no_verify: + print("SSL: проверка ОТКЛЮЧЕНА") + print() + + # ------------------------------- + # Пошаговое выполнение + all_passed = True + failed_step = 0 + + for i, step in enumerate(scenario.steps): + step_num = i + 1 + total = len(scenario.steps) + print(f"── Шаг {step_num}/{total} {'─'*40}") + print(f"→ {step.send}") + + with timer() as t: + response = await client.ask( + text=step.send, + session_id=session_id, + robot_id=scenario.robot_id, + channel=scenario.channel, + restart_closed=scenario.restart_closed, + ) + + response_time_ms = t.get("elapsed", 0) * 1000 + print(f"← Время ответа: {response_time_ms:.0f}ms") + + if args.verbose: + raw_json = json.dumps(response.raw, ensure_ascii=False, indent=2) + print("← RAW JSON:") + print(raw_json) + print() + + if response.error != 0: + print(f" [FAIL] SES API error={response.error}") + all_passed = False + failed_step = step_num + break + + if not step.expect: + print(" [PASS] OK (без проверок)\n") + continue + + # Проверяем ожидания + results = _format_expectation_results(step.expect, response.raw) + step_passed = True + for line in results: + print(line) + if "[FAIL]" in line: + step_passed = False + + print() + + if not step_passed: + all_passed = False + failed_step = step_num + break + + # ------------------------------- + # Итог + print(f"{'─'*50}") + if all_passed: + print( + f"РЕЗУЛЬТАТ: УСПЕШНО " + f"({len(scenario.steps)}/{len(scenario.steps)} шагов)" + ) + return 0 + else: + print(f"РЕЗУЛЬТАТ: ПРОВАЛЕН (шаг {failed_step})") + return 1 + + finally: + await client.close() diff --git a/src/ses_monitor/config.py b/src/ses_monitor/config.py index 9e7d195..8c55de6 100644 --- a/src/ses_monitor/config.py +++ b/src/ses_monitor/config.py @@ -297,3 +297,36 @@ def load_scenarios(scenarios_dir: str) -> List[ScenarioConfig]: raise RuntimeError(f"Ошибка чтения сценария {yml_file}: {e}") from e return scenarios + + +def load_scenario_file(path: str) -> ScenarioConfig: + """Загружает один YAML-файл сценария. + + Предназначена для CLI-утилиты ses-test. В отличие от load_scenarios(), + не фильтрует по флагу enabled — сценарий загружается всегда. + + Args: + path: Путь к YAML-файлу сценария. + + Returns: + ScenarioConfig с заполненными полями. + + Raises: + FileNotFoundError: Файл не найден. + yaml.YAMLError: Ошибка парсинга YAML. + RuntimeError: Ошибка структуры сценария. + """ + file_path = Path(path) + if not file_path.exists(): + raise FileNotFoundError(f"Файл сценария не найден: {path}") + + with open(file_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + + if data is None: + raise RuntimeError(f"Файл сценария пуст: {path}") + + try: + return _dict_to_scenario_config(data) + except (yaml.YAMLError, KeyError) as e: + raise RuntimeError(f"Ошибка чтения сценария {path}: {e}") from e diff --git a/src/ses_monitor/session_client.py b/src/ses_monitor/session_client.py index 98dcfbc..2e72b38 100644 --- a/src/ses_monitor/session_client.py +++ b/src/ses_monitor/session_client.py @@ -135,11 +135,12 @@ class SESClient: Все методы асинхронные для работы в asyncio-цикле. """ - def __init__(self, config: SESConfig): + def __init__(self, config: SESConfig, verify: bool = True): self._base_url = config.base_url.rstrip("/") self._channel = config.channel self._timeout = config.request_timeout self._robot_list_endpoint = config.robot_list_endpoint + self._verify = verify self._client: Optional[httpx.AsyncClient] = None @@ -149,6 +150,7 @@ class SESClient: self._client = httpx.AsyncClient( timeout=httpx.Timeout(self._timeout), limits=httpx.Limits(max_keepalive_connections=5), + verify=self._verify, ) return self._client