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
This commit is contained in:
Maksim Totmin 2026-06-24 10:37:41 +07:00
parent fd45734a74
commit fbfd073369
5 changed files with 418 additions and 5 deletions

View File

@ -286,15 +286,93 @@ path: "request.data[0].reply.confidence"
3. **Создайте файл сценария**, описав шаги диалога: 3. **Создайте файл сценария**, описав шаги диалога:
```bash ```bash
cp config/scenarios/example_liveness.yaml config/scenarios/$(hostname).yaml cp config/scenarios/example_dialog.yaml config/scenarios/my_project.yaml
vim config/scenarios/$(hostname).yaml vim config/scenarios/my_project.yaml
``` ```
4. **Протестируйте вручную** шаги через curl: 4. **Протестируйте сценарий** утилитой `ses-test`:
```bash ```bash
curl -X POST "http://<ses-host>:6190/ses/ask/<robot_id>?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 универсален) 5. **Запустите демон** — метрики агрегируются автоматически (шаблон Zabbix универсален)
## Расширение системы ## Расширение системы

View File

@ -36,6 +36,7 @@ dev = [
[project.scripts] [project.scripts]
ses-monitor = "ses_monitor.main:main" ses-monitor = "ses_monitor.main:main"
ses-test = "ses_monitor.cli:main"
[build-system] [build-system]
requires = ["setuptools>=61.0"] requires = ["setuptools>=61.0"]

299
src/ses_monitor/cli.py Normal file
View File

@ -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://<ses-host>: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()

View File

@ -297,3 +297,36 @@ def load_scenarios(scenarios_dir: str) -> List[ScenarioConfig]:
raise RuntimeError(f"Ошибка чтения сценария {yml_file}: {e}") from e raise RuntimeError(f"Ошибка чтения сценария {yml_file}: {e}") from e
return scenarios 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

View File

@ -135,11 +135,12 @@ class SESClient:
Все методы асинхронные для работы в asyncio-цикле. Все методы асинхронные для работы в asyncio-цикле.
""" """
def __init__(self, config: SESConfig): def __init__(self, config: SESConfig, verify: bool = True):
self._base_url = config.base_url.rstrip("/") self._base_url = config.base_url.rstrip("/")
self._channel = config.channel self._channel = config.channel
self._timeout = config.request_timeout self._timeout = config.request_timeout
self._robot_list_endpoint = config.robot_list_endpoint self._robot_list_endpoint = config.robot_list_endpoint
self._verify = verify
self._client: Optional[httpx.AsyncClient] = None self._client: Optional[httpx.AsyncClient] = None
@ -149,6 +150,7 @@ class SESClient:
self._client = httpx.AsyncClient( self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self._timeout), timeout=httpx.Timeout(self._timeout),
limits=httpx.Limits(max_keepalive_connections=5), limits=httpx.Limits(max_keepalive_connections=5),
verify=self._verify,
) )
return self._client return self._client