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:
parent
fd45734a74
commit
fbfd073369
86
README.md
86
README.md
@ -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 универсален)
|
||||||
|
|
||||||
## Расширение системы
|
## Расширение системы
|
||||||
|
|||||||
@ -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
299
src/ses_monitor/cli.py
Normal 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()
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user