Complete DDS training workflow and delivery package
This commit is contained in:
parent
68dd83c7c2
commit
4c4b91064f
229 changed files with 11969 additions and 1024 deletions
|
|
@ -1,27 +1,39 @@
|
|||
#!/usr/bin/env bash
|
||||
# make demo: поднять стенд для занятия или показа.
|
||||
#
|
||||
# Postgres в контейнере, бэкенд и фронт — нативно: модели речи под WSL работают
|
||||
# нативно, так они и мерялись (docs/LATENCY.md). Скрипт обкатывается заранее,
|
||||
# а не пишется в последнюю ночь: им поднимают стенд в классе.
|
||||
# Postgres в контейнере, бэкенд и фронт — нативно. В основном упражнении ДДС
|
||||
# голосовой контур выключен; VOICE_ENABLED=true — только отдельный эксперимент.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Не конфликтуем с уже установленным PostgreSQL. Один POSTGRES_PORT меняет и
|
||||
# проброс контейнера, и адрес нативного backend; явный DATABASE_URL приоритетен.
|
||||
export POSTGRES_PORT="${POSTGRES_PORT:-5432}"
|
||||
export DATABASE_URL="${DATABASE_URL:-postgresql+asyncpg://lct:lct@127.0.0.1:${POSTGRES_PORT}/lct}"
|
||||
|
||||
say() { printf '\n\033[1m%s\033[0m\n' "$*"; }
|
||||
|
||||
say "1/5 Модели"
|
||||
missing=0
|
||||
for path in backend/models/e5-small/model_quantized.onnx \
|
||||
backend/models/gigaam-v3-onnx/v3_rnnt_encoder.int8.onnx \
|
||||
backend/models/silero-vad/silero_vad.onnx \
|
||||
backend/models/silero-tts/v5_ru.pt; do
|
||||
if [ ! -f "$path" ]; then echo " нет $path"; missing=1; fi
|
||||
done
|
||||
if [ "$missing" = 1 ]; then
|
||||
echo " запусти: make models (Silero TTS качается вручную, скрипт подскажет)"
|
||||
exit 1
|
||||
if [ "${VOICE_ENABLED:-false}" = "true" ]; then
|
||||
missing=0
|
||||
for path in backend/models/e5-small/model_quantized.onnx \
|
||||
backend/models/whisper-small/ggml-small-q5_1.bin \
|
||||
backend/models/silero-vad/silero_vad.onnx \
|
||||
backend/models/silero-tts/v5_ru.pt; do
|
||||
if [ ! -f "$path" ]; then echo " нет $path"; missing=1; fi
|
||||
done
|
||||
if [ "$missing" = 1 ]; then
|
||||
echo " запусти: make models и make local-models; Silero TTS положи отдельно"
|
||||
exit 1
|
||||
fi
|
||||
if ! curl -fsS --max-time 2 http://127.0.0.1:18082/ >/dev/null; then
|
||||
echo " нет whisper-server: запусти make local-stt в отдельном терминале"
|
||||
exit 1
|
||||
fi
|
||||
echo " голосовой контур включён"
|
||||
else
|
||||
echo " MVP ДДС: STT и голосовой звонок 112 выключены"
|
||||
fi
|
||||
echo " все модели на месте"
|
||||
|
||||
say "2/5 База"
|
||||
docker compose up -d postgres
|
||||
|
|
@ -32,18 +44,19 @@ say "3/5 Сценарии и учётные записи"
|
|||
make seed
|
||||
make users
|
||||
|
||||
say "4/5 Бэкенд с голосом"
|
||||
# Контейнерные бэкенд и фронт гасим: порты те же, а голос работает нативно.
|
||||
say "4/5 Бэкенд"
|
||||
# Контейнерные бэкенд и фронт гасим: порты те же.
|
||||
docker compose stop backend frontend >/dev/null 2>&1 || true
|
||||
# Подоболочка явно: `cd backend && команда &` уводит в фон всю связку,
|
||||
# а родительская оболочка остаётся на месте — следующий `cd ..` увёл бы её
|
||||
# из репозитория, и фронт запускался бы не оттуда.
|
||||
( cd backend && OFFLINE=true exec uv run --extra voice uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 ) &
|
||||
( cd backend && OFFLINE=true VOICE_ENABLED="${VOICE_ENABLED:-false}" exec uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 ) &
|
||||
BACKEND=$!
|
||||
FRONTEND=
|
||||
trap 'kill $BACKEND $FRONTEND 2>/dev/null || true' EXIT INT TERM
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -sf localhost:8000/api/health | grep -q '"models_ready":true'; then break; fi
|
||||
if curl -sf localhost:8000/api/health | grep -q '"status":"ok"'; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
curl -s localhost:8000/api/health; echo
|
||||
|
|
|
|||
226
scripts/load_browser.py
Normal file
226
scripts/load_browser.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Локальная браузерная проверка 20 занятий / 100 Chromium-контекстов."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import math
|
||||
import statistics
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from uuid import uuid4
|
||||
|
||||
import websockets
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
|
||||
def local_url(value: str, schemes: set[str]) -> str:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
if parsed.scheme not in schemes or parsed.hostname not in {"127.0.0.1", "localhost", "::1"}:
|
||||
raise ValueError(f"принимаются только локальные URL со схемами {sorted(schemes)}")
|
||||
return value.rstrip("/")
|
||||
|
||||
|
||||
def percentile(values: list[float], percent: float) -> float:
|
||||
ordered = sorted(values)
|
||||
return ordered[max(0, math.ceil(percent / 100 * len(ordered)) - 1)]
|
||||
|
||||
|
||||
def timing_summary(values: list[float]) -> dict | None:
|
||||
if not values:
|
||||
return None
|
||||
return {
|
||||
"min": min(values),
|
||||
"median": statistics.median(values),
|
||||
"p95": percentile(values, 95),
|
||||
"max": max(values),
|
||||
}
|
||||
|
||||
|
||||
def login_cookie(base: str, login: str, password: str) -> str:
|
||||
body = json.dumps({"login": login, "password": password}).encode()
|
||||
request = urllib.request.Request(
|
||||
f"{base}/api/auth/login", data=body, method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
value = response.headers.get("Set-Cookie", "").split(";", 1)[0]
|
||||
if not value.startswith("lct_session="):
|
||||
raise RuntimeError("сервер не выдал lct_session cookie")
|
||||
return value
|
||||
|
||||
|
||||
async def control(ws_base: str, session_id: str, cookie: str, payload: dict) -> None:
|
||||
header_arg = ("additional_headers" if "additional_headers" in inspect.signature(
|
||||
websockets.connect
|
||||
).parameters else "extra_headers")
|
||||
async with websockets.connect(
|
||||
f"{ws_base}/ws/control/{session_id}",
|
||||
**{header_arg: {"Cookie": cookie}}, open_timeout=10, ping_interval=None,
|
||||
) as socket:
|
||||
await socket.send(json.dumps(payload, ensure_ascii=False))
|
||||
await asyncio.sleep(0.03)
|
||||
|
||||
|
||||
async def run(args: argparse.Namespace) -> int:
|
||||
frontend = local_url(args.frontend_url, {"http", "https"})
|
||||
backend = local_url(args.backend_url, {"http"})
|
||||
if args.sessions < 1 or args.clients < args.sessions or args.clients % args.sessions:
|
||||
raise ValueError("clients должно делиться на sessions и быть не меньше sessions")
|
||||
if args.clients > 100 or args.sessions > 20:
|
||||
raise ValueError("предохранитель: максимум 100 браузеров и 20 занятий")
|
||||
if args.ramp_seconds < 0:
|
||||
raise ValueError("ramp-seconds не может быть отрицательным")
|
||||
|
||||
cookie = login_cookie(backend, args.login, args.password)
|
||||
cookie_name, cookie_value = cookie.split("=", 1)
|
||||
ws_base = "ws" + backend.removeprefix("http")
|
||||
sessions = [str(uuid4()) for _ in range(args.sessions)]
|
||||
started: list[str] = []
|
||||
try:
|
||||
await asyncio.gather(*(
|
||||
control(ws_base, session_id, cookie, {
|
||||
"type": "scenario.start", "scenario_id": args.scenario,
|
||||
"trainee": f"browser-load-{index:02d}", "mode": "training", "exercise": "dds",
|
||||
}) for index, session_id in enumerate(sessions)
|
||||
))
|
||||
started.extend(sessions)
|
||||
|
||||
timings: list[float] = []
|
||||
dds_timings: list[float] = []
|
||||
observer_timings: list[float] = []
|
||||
browser_navigation: list[dict] = []
|
||||
errors: list[str] = []
|
||||
pages = []
|
||||
contexts = []
|
||||
per_session = args.clients // args.sessions
|
||||
async with async_playwright() as playwright:
|
||||
browser = await playwright.chromium.launch(headless=True)
|
||||
for index in range(args.clients):
|
||||
context = await browser.new_context(
|
||||
viewport={"width": 1280, "height": 720},
|
||||
ignore_https_errors=args.ignore_https_errors,
|
||||
)
|
||||
await context.add_cookies([{
|
||||
"name": cookie_name, "value": cookie_value, "url": frontend,
|
||||
"httpOnly": True, "sameSite": "Lax",
|
||||
}])
|
||||
page = await context.new_page()
|
||||
page.on("pageerror", lambda error, i=index: errors.append(f"page {i}: {error}"))
|
||||
contexts.append(context)
|
||||
pages.append(page)
|
||||
|
||||
async def open_page(index: int) -> None:
|
||||
if args.ramp_seconds and args.clients > 1:
|
||||
await asyncio.sleep(args.ramp_seconds * index / (args.clients - 1))
|
||||
session_index = index // per_session
|
||||
role_index = index % per_session
|
||||
session_id = sessions[session_index]
|
||||
if role_index == 0:
|
||||
path = f"/dds?session={session_id}&role=dds_01"
|
||||
selector = ".dds-register-panel .dds-selected"
|
||||
else:
|
||||
path = f"/wall?session={session_id}"
|
||||
selector = "main.wall h1"
|
||||
started_at = time.perf_counter()
|
||||
try:
|
||||
response = await pages[index].goto(
|
||||
frontend + path, wait_until="domcontentloaded", timeout=args.timeout * 1000,
|
||||
)
|
||||
if response is None or not response.ok:
|
||||
raise RuntimeError(f"HTTP {response.status if response else 'нет ответа'}")
|
||||
await pages[index].locator(selector).wait_for(timeout=args.timeout * 1000)
|
||||
if role_index != 0:
|
||||
await pages[index].wait_for_function(
|
||||
"document.querySelector('main.wall h1')?.textContent !== 'Ожидание занятия'",
|
||||
timeout=args.timeout * 1000,
|
||||
)
|
||||
ready_ms = (time.perf_counter() - started_at) * 1000
|
||||
timings.append(ready_ms)
|
||||
(dds_timings if role_index == 0 else observer_timings).append(ready_ms)
|
||||
navigation = await pages[index].evaluate("""() => {
|
||||
const entry = performance.getEntriesByType('navigation')[0];
|
||||
if (!entry) return null;
|
||||
return {
|
||||
responseStart: entry.responseStart,
|
||||
responseEnd: entry.responseEnd,
|
||||
domInteractive: entry.domInteractive,
|
||||
domContentLoaded: entry.domContentLoadedEventEnd,
|
||||
transferSize: entry.transferSize,
|
||||
};
|
||||
}""")
|
||||
if navigation:
|
||||
browser_navigation.append({"role": "dds" if role_index == 0 else "wall", **navigation})
|
||||
except Exception as exc: # каждый незагрузившийся интерфейс — измеренный отказ
|
||||
errors.append(f"page {index} {path}: {type(exc).__name__}: {exc}")
|
||||
|
||||
await asyncio.gather(*(open_page(index) for index in range(args.clients)))
|
||||
await asyncio.sleep(args.hold_seconds)
|
||||
await asyncio.gather(*(context.close() for context in contexts))
|
||||
await browser.close()
|
||||
|
||||
result = {
|
||||
"frontend": frontend, "backend": backend,
|
||||
"sessions_started": len(sessions), "browser_contexts": args.clients,
|
||||
"dds_pages": args.sessions, "observer_pages": args.clients - args.sessions,
|
||||
"pages_ready": len(timings), "hold_seconds": args.hold_seconds,
|
||||
"ramp_seconds": args.ramp_seconds,
|
||||
"ready_ms": timing_summary(timings),
|
||||
"ready_ms_by_page": {
|
||||
"dds": timing_summary(dds_timings),
|
||||
"wall": timing_summary(observer_timings),
|
||||
},
|
||||
"browser_navigation_ms": {
|
||||
key: timing_summary([float(item[key]) for item in browser_navigation])
|
||||
for key in ("responseStart", "responseEnd", "domInteractive", "domContentLoaded")
|
||||
},
|
||||
"transfer_bytes": timing_summary([
|
||||
float(item["transferSize"]) for item in browser_navigation
|
||||
]),
|
||||
"errors": errors[:50],
|
||||
"pass_browser_ui_2s": (
|
||||
len(timings) == args.clients and not errors and percentile(timings, 95) <= 2000
|
||||
),
|
||||
"scope": (
|
||||
f"{args.clients} isolated Chromium contexts render real React DDS/wall pages "
|
||||
f"for {args.sessions} sessions with a {args.ramp_seconds:g}s connection ramp; "
|
||||
"all contexts remain active concurrently; same instructor account, local host, no VoIP, "
|
||||
"not target 6-core/32-GB server"
|
||||
),
|
||||
}
|
||||
rendered = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
print(rendered)
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as stream:
|
||||
stream.write(rendered + "\n")
|
||||
return 0 if result["pass_browser_ui_2s"] else 1
|
||||
finally:
|
||||
await asyncio.gather(*(
|
||||
control(ws_base, session_id, cookie, {"type": "session.stop"})
|
||||
for session_id in started
|
||||
), return_exceptions=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--frontend-url", default="http://127.0.0.1:5173")
|
||||
parser.add_argument("--backend-url", default="http://127.0.0.1:8000")
|
||||
parser.add_argument("--login", required=True)
|
||||
parser.add_argument("--password", required=True)
|
||||
parser.add_argument("--sessions", type=int, default=20)
|
||||
parser.add_argument("--clients", type=int, default=100)
|
||||
parser.add_argument("--scenario", default="fire-apartment-l2")
|
||||
parser.add_argument("--timeout", type=float, default=30)
|
||||
parser.add_argument("--hold-seconds", type=float, default=2)
|
||||
parser.add_argument("--ramp-seconds", type=float, default=0,
|
||||
help="равномерно распределить начало загрузок; все страницы остаются активны")
|
||||
parser.add_argument("--output", help="сохранить JSON-отчёт")
|
||||
parser.add_argument("--ignore-https-errors", action="store_true",
|
||||
help="разрешить bootstrap self-signed сертификат только в локальном тесте")
|
||||
try:
|
||||
raise SystemExit(asyncio.run(run(parser.parse_args())))
|
||||
except Exception as exc:
|
||||
print(f"browser load failed: {type(exc).__name__}: {exc}")
|
||||
raise SystemExit(2)
|
||||
98
scripts/load_db.py
Normal file
98
scripts/load_db.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Проверка последовательной записи PostgreSQL без изменения данных продукта."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import statistics
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
import asyncpg
|
||||
|
||||
|
||||
def local_database_url(value: str) -> str:
|
||||
normalized = value.replace("postgresql+asyncpg://", "postgresql://", 1)
|
||||
parsed = urllib.parse.urlsplit(normalized)
|
||||
if parsed.scheme not in {"postgres", "postgresql"}:
|
||||
raise ValueError("поддерживается только PostgreSQL")
|
||||
if parsed.hostname not in {"127.0.0.1", "localhost", "::1"}:
|
||||
raise ValueError("предохранитель: benchmark разрешён только для локальной БД")
|
||||
return normalized
|
||||
|
||||
|
||||
def percentile(values: list[float], percent: float) -> float:
|
||||
ordered = sorted(values)
|
||||
return ordered[max(0, math.ceil(percent / 100 * len(ordered)) - 1)]
|
||||
|
||||
|
||||
async def run(args: argparse.Namespace) -> int:
|
||||
if not 100 <= args.operations <= 10_000:
|
||||
raise ValueError("operations должно быть от 100 до 10000")
|
||||
database_url = local_database_url(args.database_url)
|
||||
connection = await asyncpg.connect(database_url, timeout=10)
|
||||
latencies: list[float] = []
|
||||
try:
|
||||
async with connection.transaction():
|
||||
await connection.execute("""
|
||||
CREATE TEMPORARY TABLE lct_write_benchmark (
|
||||
sequence_no integer PRIMARY KEY,
|
||||
written_at timestamptz NOT NULL DEFAULT now(),
|
||||
payload text NOT NULL
|
||||
) ON COMMIT DROP
|
||||
""")
|
||||
started = time.perf_counter()
|
||||
for sequence_no in range(args.operations):
|
||||
operation_started = time.perf_counter()
|
||||
await connection.execute(
|
||||
"INSERT INTO lct_write_benchmark(sequence_no, payload) VALUES($1, $2)",
|
||||
sequence_no, f"local-load-{sequence_no:05d}",
|
||||
)
|
||||
latencies.append((time.perf_counter() - operation_started) * 1000)
|
||||
elapsed = time.perf_counter() - started
|
||||
count = await connection.fetchval("SELECT count(*) FROM lct_write_benchmark")
|
||||
finally:
|
||||
await connection.close()
|
||||
|
||||
throughput = args.operations / elapsed
|
||||
result = {
|
||||
"database": "local PostgreSQL",
|
||||
"operations_requested": args.operations,
|
||||
"operations_written": count,
|
||||
"elapsed_seconds": elapsed,
|
||||
"operations_per_second": throughput,
|
||||
"operation_latency_ms": {
|
||||
"min": min(latencies),
|
||||
"median": statistics.median(latencies),
|
||||
"p95": percentile(latencies, 95),
|
||||
"max": max(latencies),
|
||||
},
|
||||
"pass_100_writes_per_second": count == args.operations and throughput >= 100,
|
||||
"scope": (
|
||||
"sequential INSERT statements over one local asyncpg connection into a temporary "
|
||||
"table; table is dropped on commit; not target 6-core/32-GB hardware"
|
||||
),
|
||||
}
|
||||
rendered = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
print(rendered)
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as stream:
|
||||
stream.write(rendered + "\n")
|
||||
return 0 if result["pass_100_writes_per_second"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--database-url",
|
||||
default=os.environ.get("DATABASE_URL", "postgresql://lct:lct@127.0.0.1:5432/lct"),
|
||||
)
|
||||
parser.add_argument("--operations", type=int, default=1000)
|
||||
parser.add_argument("--output", help="сохранить JSON-отчёт")
|
||||
try:
|
||||
raise SystemExit(asyncio.run(run(parser.parse_args())))
|
||||
except Exception as exc:
|
||||
print(f"database load failed: {type(exc).__name__}: {exc}")
|
||||
raise SystemExit(2)
|
||||
197
scripts/smoke_active_session_recovery.py
Normal file
197
scripts/smoke_active_session_recovery.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Живой smoke: активная карточка ДДС переживает restart backend.
|
||||
|
||||
Скрипт сам запускает два последовательных uvicorn-процесса на одном порту,
|
||||
использует один локальный PostgreSQL и проверяет карточку, статус службы,
|
||||
выбранную бригаду, письменный ответ и продолжающийся таймер.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import websockets
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BACKEND = ROOT / "backend"
|
||||
|
||||
|
||||
def request_json(url: str, *, method: str = "GET", cookie: str = "") -> tuple[dict, str]:
|
||||
request = urllib.request.Request(url, method=method)
|
||||
if cookie:
|
||||
request.add_header("Cookie", cookie)
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
value = json.loads(response.read())
|
||||
issued = response.headers.get("set-cookie", "").split(";", 1)[0]
|
||||
return value, issued
|
||||
|
||||
|
||||
def wait_ready(base: str, timeout: float = 20) -> dict:
|
||||
deadline = time.monotonic() + timeout
|
||||
last: Exception | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
health, _ = request_json(f"{base}/api/health")
|
||||
return health
|
||||
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
||||
last = exc
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(f"backend не стал готов: {last}")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def backend_process(port: int, database_url: str, *, hard_stop: bool = False):
|
||||
env = {
|
||||
**os.environ,
|
||||
"DATABASE_URL": database_url,
|
||||
"OFFLINE": "true",
|
||||
"VOICE_ENABLED": "false",
|
||||
"DEV_AUTH_BYPASS": "true",
|
||||
"DEMO_NO_DB": "false",
|
||||
"SESSION_SECRET": "recovery-smoke-secret-that-is-not-production",
|
||||
"LLM_API_KEY": "",
|
||||
"LLM_PROVIDER": "disabled",
|
||||
}
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1",
|
||||
"--port", str(port), "--workers", "1"],
|
||||
cwd=BACKEND,
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
wait_ready(f"http://127.0.0.1:{port}")
|
||||
yield process
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill() if hard_stop else process.terminate()
|
||||
try:
|
||||
process.wait(timeout=8)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
if not hard_stop and process.returncode not in (0, -15):
|
||||
error = process.stderr.read()[-4000:] if process.stderr else ""
|
||||
raise RuntimeError(f"backend завершился с кодом {process.returncode}: {error}")
|
||||
|
||||
|
||||
async def receive_type(socket, wanted: str) -> dict:
|
||||
for _ in range(30):
|
||||
value = json.loads(await asyncio.wait_for(socket.recv(), timeout=5))
|
||||
if value.get("type") == wanted:
|
||||
return value
|
||||
raise AssertionError(f"событие {wanted} не получено")
|
||||
|
||||
|
||||
async def start_and_edit(ws_base: str, cookie: str, session_id: str) -> dict:
|
||||
options = {"additional_headers": {"Cookie": cookie}}
|
||||
async with websockets.connect(f"{ws_base}/ws/control/{session_id}", **options) as control:
|
||||
await control.send(json.dumps({
|
||||
"type": "scenario.start",
|
||||
"scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Recovery smoke",
|
||||
"mode": "training",
|
||||
"exercise": "dds",
|
||||
"criteria": {
|
||||
"decision_time_limit_seconds": 45,
|
||||
"allowed_errors": 0,
|
||||
"require_correct_grammar": True,
|
||||
},
|
||||
}))
|
||||
await asyncio.sleep(0.4)
|
||||
|
||||
async with websockets.connect(f"{ws_base}/ws/station/{session_id}", **options) as station:
|
||||
card = await receive_type(station, "card.received")
|
||||
snapshot = (await receive_type(station, "station.state"))["snapshot"]
|
||||
service = snapshot["services"][0]
|
||||
crew = snapshot["crew_options"][0]
|
||||
await station.send(json.dumps({
|
||||
"type": "card.status", "service": service, "status": "accepted",
|
||||
}))
|
||||
await receive_type(station, "station.state")
|
||||
await station.send(json.dumps({"type": "crew.select", "crew": crew}))
|
||||
await receive_type(station, "station.state")
|
||||
await station.send(json.dumps({
|
||||
"type": "card.reply",
|
||||
"card_id": card["card"]["card_id"],
|
||||
"text": "Сообщение принято, бригада направлена.",
|
||||
}))
|
||||
latest = (await receive_type(station, "station.state"))["snapshot"]
|
||||
return {"service": service, "crew": crew, "card_id": card["card"]["card_id"],
|
||||
"before": latest}
|
||||
|
||||
|
||||
async def verify_and_finish(ws_base: str, cookie: str, session_id: str, expected: dict) -> dict:
|
||||
options = {"additional_headers": {"Cookie": cookie}}
|
||||
async with websockets.connect(f"{ws_base}/ws/station/{session_id}", **options) as station:
|
||||
card = await receive_type(station, "card.received")
|
||||
snapshot = (await receive_type(station, "station.state"))["snapshot"]
|
||||
assert card["card"]["card_id"] == expected["card_id"]
|
||||
assert snapshot["statuses"][expected["service"]] == "accepted"
|
||||
assert snapshot["crew_selected"] == expected["crew"]
|
||||
assert snapshot["reply_text"] == "Сообщение принято, бригада направлена."
|
||||
await station.send(json.dumps({"type": "station.finish"}))
|
||||
await receive_type(station, "score.ready")
|
||||
return snapshot
|
||||
|
||||
|
||||
async def run(args) -> dict:
|
||||
http_base = f"http://127.0.0.1:{args.port}"
|
||||
ws_base = f"ws://127.0.0.1:{args.port}"
|
||||
session_id = str(uuid4())
|
||||
async with backend_process(args.port, args.database_url, hard_stop=True):
|
||||
_, cookie = request_json(f"{http_base}/api/auth/dev-token", method="POST")
|
||||
expected = await start_and_edit(ws_base, cookie, session_id)
|
||||
|
||||
# Небольшая пауза делает проверяемым продолжение активного таймера.
|
||||
await asyncio.sleep(1.2)
|
||||
async with backend_process(args.port, args.database_url):
|
||||
health = wait_ready(http_base)
|
||||
assert health["sessions_restored"] >= 1
|
||||
# Та же подписанная cookie должна пережить restart. При смене роли,
|
||||
# пароля или блокировке её отзовёт сохранённый auth_version.
|
||||
after = await verify_and_finish(ws_base, cookie, session_id, expected)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"session_id": session_id,
|
||||
"sessions_restored": health["sessions_restored"],
|
||||
"card_id": after["card_id"],
|
||||
"status": after["statuses"][expected["service"]],
|
||||
"crew": after["crew_selected"],
|
||||
"reply_text": after["reply_text"],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--port", type=int, default=18116)
|
||||
parser.add_argument(
|
||||
"--database-url",
|
||||
default=os.environ.get(
|
||||
"DATABASE_URL", "postgresql+asyncpg://lct:lct@127.0.0.1:5434/lct"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
print(json.dumps(asyncio.run(run(args)), ensure_ascii=False, indent=2))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
343
scripts/smoke_mobile.py
Normal file
343
scripts/smoke_mobile.py
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Browser smoke for the local demo, including mobile layout and DDS workflow."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from load_browser import local_url
|
||||
|
||||
|
||||
def layout(page) -> dict:
|
||||
return page.evaluate(
|
||||
"""() => {
|
||||
const root = document.documentElement;
|
||||
const visible = [...document.querySelectorAll('body *')].filter((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
const closed = element.closest('details:not([open])');
|
||||
return (!closed || element.tagName === 'SUMMARY') && style.display !== 'none'
|
||||
&& style.visibility !== 'hidden' && rect.width > 0;
|
||||
});
|
||||
return {
|
||||
viewport: innerWidth,
|
||||
document_width: root.scrollWidth,
|
||||
overflowing: visible.filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.right > innerWidth + 2 || rect.left < -2;
|
||||
}).slice(0, 20).map((element) => ({
|
||||
tag: element.tagName.toLowerCase(),
|
||||
class: element.className || '',
|
||||
text: (element.textContent || '').trim().slice(0, 80),
|
||||
left: Math.round(element.getBoundingClientRect().left),
|
||||
right: Math.round(element.getBoundingClientRect().right),
|
||||
})),
|
||||
small_controls: [...document.querySelectorAll(
|
||||
'button, select, textarea, input:not([type=radio]):not([type=checkbox]), summary'
|
||||
)].filter((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return style.display !== 'none' && style.visibility !== 'hidden'
|
||||
&& !element.disabled && (rect.height < 40 || rect.width < 40);
|
||||
}).slice(0, 20).map((element) => ({
|
||||
tag: element.tagName.toLowerCase(),
|
||||
label: element.getAttribute('aria-label') || element.textContent?.trim().slice(0, 60)
|
||||
|| element.getAttribute('placeholder') || '',
|
||||
width: Math.round(element.getBoundingClientRect().width),
|
||||
height: Math.round(element.getBoundingClientRect().height),
|
||||
})),
|
||||
};
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
def open_instructor(page, base_url: str) -> None:
|
||||
page.goto(f"{base_url}/instructor", wait_until="networkidle")
|
||||
demo_login = page.get_by_role("button", name="Войти в демо одним нажатием")
|
||||
if demo_login.is_visible():
|
||||
demo_login.click()
|
||||
page.locator("#ai-scenario-editor").wait_for()
|
||||
|
||||
|
||||
def stop_exercise_from_instructor(page, base_url: str, exercise_href: str) -> None:
|
||||
session_id = parse_qs(urlparse(exercise_href).query).get("session", [None])[0]
|
||||
if not session_id:
|
||||
raise RuntimeError(f"в ссылке АРМ нет session id: {exercise_href}")
|
||||
page.goto(f"{base_url}/instructor?session={session_id}", wait_until="networkidle")
|
||||
page.get_by_role("button", name="Завершить занятие").click()
|
||||
page.get_by_text("занятие завершено", exact=True).wait_for()
|
||||
|
||||
|
||||
def choose_trainee(page) -> None:
|
||||
# Match the label cell, not any row whose body happens to mention a
|
||||
# trainee (the scenario/category tables include that word too).
|
||||
exercise_table = page.locator(".instructor-layout > section").first.locator(
|
||||
"table.grid"
|
||||
).first
|
||||
trainee_row = exercise_table.locator("th").get_by_text(
|
||||
"Курсант", exact=True
|
||||
).locator("xpath=..")
|
||||
trainee_select = trainee_row.locator("select")
|
||||
if trainee_select.count():
|
||||
if trainee_select.locator("option").count() < 2:
|
||||
raise RuntimeError("Список курсантов не содержит доступного выбора")
|
||||
trainee_select.select_option(index=1)
|
||||
else:
|
||||
trainee_row.locator('input[type="text"]').fill("Мобильный smoke")
|
||||
|
||||
|
||||
def start_exercise(page, label: str, link_row: str) -> str:
|
||||
page.get_by_role("radio", name=label).check()
|
||||
choose_trainee(page)
|
||||
page.get_by_role("button", name="Начать занятие").click()
|
||||
row = page.locator("tr").filter(has_text=link_row).last
|
||||
link = row.get_by_role("link", name="открыть", exact=False)
|
||||
link.wait_for()
|
||||
href = link.get_attribute("href")
|
||||
if not href:
|
||||
raise RuntimeError(f"не получена ссылка {link_row}")
|
||||
return href
|
||||
|
||||
|
||||
def exercise_dds_flow(page, scenario_title: str) -> dict:
|
||||
"""Accept the card, select its crew and complete the training briefing."""
|
||||
service_button = page.locator(".dds-service-dock button").first
|
||||
service_title = service_button.locator("b").inner_text()
|
||||
service_button.click()
|
||||
|
||||
next_status = page.locator(
|
||||
'.dds-status-editor select[aria-label^="Следующий статус"]'
|
||||
)
|
||||
next_status.wait_for()
|
||||
labels = next_status.locator("option").all_text_contents()
|
||||
if not any("Принята" in label for label in labels):
|
||||
raise RuntimeError(f"статус «Принята» недоступен для {service_title}: {labels}")
|
||||
next_status.select_option(value="accepted")
|
||||
page.locator('button[aria-label="Сохранить статус"]').click()
|
||||
page.wait_for_function("""() => [...document.querySelectorAll(
|
||||
'.dds-service-dock button')].some((button) => button.innerText.includes('Принята'))""")
|
||||
|
||||
page.locator(".dds-service-dock button").filter(has_text="Связь").click()
|
||||
crew_select = page.locator("#dds-crew")
|
||||
crew_select.wait_for()
|
||||
options = crew_select.locator("option").all_text_contents()
|
||||
crew = next((option.strip() for option in options
|
||||
if option.strip().startswith(service_title)), None)
|
||||
if not crew:
|
||||
raise RuntimeError(f"для {service_title} нет доступной бригады: {options}")
|
||||
crew_select.select_option(label=crew)
|
||||
page.get_by_role("button", name="Позвонить старшему группы").click()
|
||||
|
||||
address = page.locator(".dds-case-address > strong").inner_text().strip()
|
||||
page.locator("#dds-phone-address").fill(address)
|
||||
page.locator("#dds-phone-incident").fill(scenario_title)
|
||||
page.locator("#dds-phone-request").fill(
|
||||
"Подтвердите выезд и доложите о прибытии."
|
||||
)
|
||||
page.get_by_role("button", name="Передать сообщение").click()
|
||||
transcript = page.locator(".dds-phone-transcript")
|
||||
transcript.wait_for()
|
||||
page.wait_for_function("""() => {
|
||||
const transcript = document.querySelector('.dds-phone-transcript');
|
||||
const alert = document.querySelector('.dds-alert[role="alert"]');
|
||||
return transcript?.innerText.includes('Адрес:') || Boolean(alert?.innerText.trim());
|
||||
}""", timeout=10_000)
|
||||
text = transcript.inner_text()
|
||||
if "Адрес:" not in text or "Старший группы" not in text:
|
||||
alert = page.locator('.dds-alert[role="alert"]')
|
||||
error = alert.inner_text() if alert.count() else "нет сообщения об ошибке"
|
||||
raise RuntimeError(
|
||||
f"учебный доклад не попал в журнал: {text}; ошибка: {error}; "
|
||||
f"адрес={address!r}, происшествие={scenario_title!r}"
|
||||
)
|
||||
return {"service": service_title, "status": "Принята", "crew": crew,
|
||||
"brief_recorded": True}
|
||||
|
||||
|
||||
def smoke_dds_queue(page, base_url: str, output: Path, screenshot_suffix: str) -> dict:
|
||||
"""Check per-card state isolation while a second card arrives in queue."""
|
||||
open_instructor(page, base_url)
|
||||
queue = page.locator("details.scenario-queue")
|
||||
queue.locator("summary").click()
|
||||
queue.locator('input[type="checkbox"]').first.check()
|
||||
page.get_by_label("Интервал поступления карточек ДДС, секунд").fill("6")
|
||||
page.get_by_label("Максимум ожидающих карточек ДДС").fill("1")
|
||||
dds_href = start_exercise(page, "Диспетчер ДДС · готовая карточка", "АРМ ДДС")
|
||||
page.goto(dds_href, wait_until="networkidle")
|
||||
page.locator(".dds-workspace").wait_for()
|
||||
first_row = page.locator('.dds-registry tbody tr[aria-current="true"]')
|
||||
first_row.wait_for()
|
||||
first_number = first_row.locator('td[data-label="Номер"] button').inner_text()
|
||||
first_row.get_by_role("button", name="Открыть карточку").click()
|
||||
page.locator(".dds-case-layout").wait_for()
|
||||
first_status_button = page.locator(".dds-service-dock button").first
|
||||
service_title = first_status_button.locator("b").inner_text()
|
||||
first_status_button.click()
|
||||
next_status = page.locator(
|
||||
'.dds-status-editor select[aria-label^="Следующий статус"]'
|
||||
)
|
||||
next_status.wait_for()
|
||||
service_name = next_status.get_attribute("aria-label").removeprefix("Следующий статус ")
|
||||
if not any("Принята" in option for option in next_status.locator("option").all_text_contents()):
|
||||
raise RuntimeError(f"для первой карточки недоступен статус «Принята»: {service_title}")
|
||||
next_status.select_option(value="accepted")
|
||||
page.locator('button[aria-label="Сохранить статус"]').click()
|
||||
page.wait_for_function(
|
||||
"""(service) => [...document.querySelectorAll('.dds-service-dock button')]
|
||||
.some((button) => button.innerText.includes(service)
|
||||
&& button.innerText.includes('Принята'))""",
|
||||
arg=service_title,
|
||||
timeout=5_000,
|
||||
)
|
||||
page.locator(".dds-case-top button").click()
|
||||
page.wait_for_function(
|
||||
"document.querySelectorAll('.dds-registry tbody tr').length === 2",
|
||||
timeout=20_000,
|
||||
)
|
||||
rows = page.locator(".dds-registry tbody tr")
|
||||
if rows.count() != 2 or page.locator('.dds-registry tbody tr[aria-current="true"]').count() != 1:
|
||||
raise RuntimeError("ожидались две карточки в очереди и ровно одна активная")
|
||||
second_row = rows.nth(1)
|
||||
second_number = second_row.locator('td[data-label="Номер"] button').inner_text()
|
||||
second_row.get_by_role("button", name="Открыть карточку").click()
|
||||
page.locator(".dds-case-top").get_by_text(second_number, exact=False).wait_for()
|
||||
page.locator(".dds-case-top button").click()
|
||||
page.locator(".dds-registry tbody tr").filter(has_text=first_number).get_by_role(
|
||||
"button", name="Открыть карточку"
|
||||
).click()
|
||||
page.locator(".dds-case-top").get_by_text(first_number, exact=False).wait_for()
|
||||
restored_status = page.locator(".dds-service-dock button").filter(
|
||||
has_text=service_title
|
||||
).first.inner_text()
|
||||
if "Принята" not in restored_status:
|
||||
raise RuntimeError(f"статус первой карточки потерян после поступления/переключения: {restored_status}")
|
||||
page.locator(".dds-case-top button").click()
|
||||
screen = layout(page)
|
||||
page.screenshot(path=output / f"dds-queue-{screenshot_suffix}.png", full_page=True)
|
||||
stop_exercise_from_instructor(page, base_url, dds_href)
|
||||
return {"cards_visible": 2, "active_cards": 1,
|
||||
"arrival_interval_seconds": 6, "max_waiting": 1,
|
||||
"managed_service": service_name,
|
||||
"first_card_status_preserved_after_second_arrival": True,
|
||||
"switched_to_second_and_back": True,
|
||||
"session_finished": True, "screen": screen}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-url", default="http://127.0.0.1:5175")
|
||||
parser.add_argument("--output", type=Path, default=Path("/tmp/lct-mobile-smoke"))
|
||||
parser.add_argument("--report", type=Path)
|
||||
parser.add_argument("--login")
|
||||
parser.add_argument("--password")
|
||||
parser.add_argument("--width", type=int, default=390)
|
||||
parser.add_argument("--height", type=int, default=844)
|
||||
parser.add_argument("--device-scale-factor", type=float, default=2)
|
||||
args = parser.parse_args()
|
||||
if args.width < 320 or args.height < 480 or args.device_scale_factor <= 0:
|
||||
parser.error("viewport must be at least 320×480 and scale factor must be positive")
|
||||
screenshot_suffix = "mobile" if args.width <= 600 else "desktop"
|
||||
args.base_url = local_url(args.base_url, {"http", "https"})
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
report = {
|
||||
"checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"base_url": args.base_url,
|
||||
"viewport": {
|
||||
"width": args.width,
|
||||
"height": args.height,
|
||||
"device_scale_factor": args.device_scale_factor,
|
||||
"touch_target_minimum_px": 40 if args.width <= 600 else None,
|
||||
},
|
||||
"screens": {},
|
||||
"workflow": {},
|
||||
}
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch()
|
||||
context = browser.new_context(
|
||||
viewport={"width": args.width, "height": args.height},
|
||||
device_scale_factor=args.device_scale_factor,
|
||||
locale="ru-RU",
|
||||
)
|
||||
page = context.new_page()
|
||||
page.goto(f"{args.base_url}/instructor", wait_until="networkidle")
|
||||
demo_login = page.get_by_role("button", name="Войти в демо одним нажатием")
|
||||
if demo_login.is_visible():
|
||||
demo_login.click()
|
||||
elif args.login and args.password:
|
||||
page.locator('input:not([type="password"])').first.fill(args.login)
|
||||
page.locator('input[type="password"]').fill(args.password)
|
||||
page.get_by_role("button", name="Войти", exact=True).click()
|
||||
elif page.get_by_role("heading", name="Учебный симулятор системы-112").is_visible():
|
||||
raise RuntimeError("stand requires --login and --password")
|
||||
page.locator("#ai-scenario-editor").wait_for()
|
||||
page.screenshot(path=args.output / f"instructor-{screenshot_suffix}.png", full_page=True)
|
||||
report["screens"]["instructor"] = layout(page)
|
||||
|
||||
trainee_href = start_exercise(
|
||||
page, "Оператор 112 · текстовая вводная", "АРМ курсанта"
|
||||
)
|
||||
page.goto(trainee_href, wait_until="networkidle")
|
||||
page.locator(".kio-arm").wait_for()
|
||||
page.screenshot(path=args.output / f"trainee-{screenshot_suffix}.png", full_page=True)
|
||||
report["screens"]["trainee"] = layout(page)
|
||||
stop_exercise_from_instructor(page, args.base_url, trainee_href)
|
||||
|
||||
open_instructor(page, args.base_url)
|
||||
dds_href = start_exercise(
|
||||
page, "Диспетчер ДДС · готовая карточка", "АРМ ДДС"
|
||||
)
|
||||
page.goto(dds_href, wait_until="networkidle")
|
||||
page.locator(".dds-workspace").wait_for()
|
||||
page.screenshot(path=args.output / f"dds-{screenshot_suffix}.png", full_page=True)
|
||||
report["screens"]["dds"] = layout(page)
|
||||
|
||||
active_row = page.locator(".dds-registry tbody tr.dds-selected")
|
||||
scenario_title = active_row.locator(
|
||||
'td[data-label="Тип происшествия"] .dds-cell-value > span'
|
||||
).inner_text().split(" · ", 1)[0]
|
||||
page.get_by_role("button", name="Открыть карточку").first.click()
|
||||
page.locator(".dds-case-layout").wait_for()
|
||||
page.screenshot(path=args.output / f"dds-card-{screenshot_suffix}.png", full_page=True)
|
||||
report["screens"]["dds_card"] = layout(page)
|
||||
report["workflow"] = exercise_dds_flow(page, scenario_title)
|
||||
page.screenshot(path=args.output / f"dds-workflow-{screenshot_suffix}.png", full_page=True)
|
||||
report["screens"]["dds_workflow"] = layout(page)
|
||||
page.locator(".dds-service-dock button").last.click()
|
||||
page.locator(".dds-done").wait_for()
|
||||
report["workflow"]["session_finished"] = True
|
||||
|
||||
queue = smoke_dds_queue(page, args.base_url, args.output, screenshot_suffix)
|
||||
report["screens"]["dds_queue"] = queue.pop("screen")
|
||||
report["queue"] = queue
|
||||
browser.close()
|
||||
|
||||
problems = {
|
||||
name: {
|
||||
"horizontal_overflow": data["document_width"] > data["viewport"] + 2,
|
||||
"small_controls": len(data["small_controls"]),
|
||||
"touch_target_check_applies": args.width <= 600,
|
||||
}
|
||||
for name, data in report["screens"].items()
|
||||
}
|
||||
report["problems"] = problems
|
||||
report["passed"] = all(
|
||||
not item["horizontal_overflow"] and (
|
||||
not item["touch_target_check_applies"] or item["small_controls"] == 0
|
||||
)
|
||||
for item in problems.values()
|
||||
)
|
||||
rendered = json.dumps(report, ensure_ascii=False, indent=2)
|
||||
print(rendered)
|
||||
if args.report:
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(rendered + "\n", encoding="utf-8")
|
||||
return 0 if report["passed"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
218
scripts/smoke_persisted_report.py
Normal file
218
scripts/smoke_persisted_report.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Проверка: подробный JSON/CSV/PDF переживает рестарт backend и укладывается в 30 с."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import websockets
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.api.auth import hash_password # noqa: E402
|
||||
from app.db.models import AuditLog, Session, Trainee, User # noqa: E402
|
||||
|
||||
|
||||
def local_http(value: str) -> str:
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme != "http" or parsed.hostname not in {"127.0.0.1", "localhost", "::1"}:
|
||||
raise ValueError("backend должен быть локальным HTTP URL")
|
||||
return value.rstrip("/")
|
||||
|
||||
|
||||
def login_cookie(base: str, login: str, password: str) -> str:
|
||||
request = urllib.request.Request(
|
||||
f"{base}/api/auth/login",
|
||||
data=json.dumps({"login": login, "password": password}).encode(),
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
cookie = response.headers.get("Set-Cookie", "").split(";", 1)[0]
|
||||
if not cookie.startswith("lct_session="):
|
||||
raise RuntimeError("login не выдал cookie")
|
||||
return cookie
|
||||
|
||||
|
||||
def get_timed(base: str, path: str, cookie: str) -> tuple[bytes, str, float]:
|
||||
request = urllib.request.Request(f"{base}{path}", headers={"Cookie": cookie})
|
||||
started = time.perf_counter()
|
||||
with urllib.request.urlopen(request, timeout=35) as response:
|
||||
body = response.read()
|
||||
content_type = response.headers.get_content_type()
|
||||
return body, content_type, (time.perf_counter() - started) * 1000
|
||||
|
||||
|
||||
async def receive_type(socket, wanted: str, limit: int = 40) -> dict:
|
||||
for _ in range(limit):
|
||||
raw = await asyncio.wait_for(socket.recv(), timeout=10)
|
||||
if isinstance(raw, str):
|
||||
event = json.loads(raw)
|
||||
if event.get("type") == wanted:
|
||||
return event
|
||||
raise RuntimeError(f"не получено событие {wanted}")
|
||||
|
||||
|
||||
async def create_temp_user(database_url: str, login: str, password: str) -> None:
|
||||
engine = create_async_engine(database_url)
|
||||
try:
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with factory() as db:
|
||||
db.add(User(
|
||||
login=login,
|
||||
full_name="Проверка архивного отчёта",
|
||||
password_hash=hash_password(password),
|
||||
role="instructor",
|
||||
blocked=False,
|
||||
))
|
||||
await db.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def cleanup(database_url: str, login: str, trainee_name: str, session_id) -> None:
|
||||
engine = create_async_engine(database_url)
|
||||
try:
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with factory() as db:
|
||||
await db.execute(delete(Session).where(Session.id == session_id))
|
||||
await db.execute(delete(User).where(User.login == login))
|
||||
await db.execute(delete(Trainee).where(Trainee.name == trainee_name))
|
||||
await db.execute(delete(AuditLog).where(AuditLog.actor == login))
|
||||
await db.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def complete_card(base: str, cookie: str, session_id: str, trainee_name: str) -> None:
|
||||
ws_base = "ws" + base.removeprefix("http")
|
||||
header = "additional_headers" if "additional_headers" in inspect.signature(
|
||||
websockets.connect
|
||||
).parameters else "extra_headers"
|
||||
options = {header: {"Cookie": cookie}, "open_timeout": 10, "ping_interval": None}
|
||||
async with websockets.connect(f"{ws_base}/ws/control/{session_id}", **options) as control:
|
||||
await control.send(json.dumps({
|
||||
"type": "scenario.start",
|
||||
"scenario_id": "fire-apartment-l2",
|
||||
"trainee": trainee_name,
|
||||
"mode": "training",
|
||||
"exercise": "card",
|
||||
}, ensure_ascii=False))
|
||||
# Control-сокет только отправляет директивы; подтверждение старта
|
||||
# получают рабочие АРМ, поэтому ждём обработки, а не несуществующий ACK.
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
async with websockets.connect(f"{ws_base}/ws/call/{session_id}", **options) as trainee:
|
||||
await receive_type(trainee, "card.briefing")
|
||||
await trainee.send(json.dumps({
|
||||
"type": "kio.patch",
|
||||
"fields": {
|
||||
"address": "улица Ленина, 14",
|
||||
"floor": "5",
|
||||
"incident_type": "fire",
|
||||
"victims_count": 2,
|
||||
"description": "горит балкон",
|
||||
"signs": ["жилой дом", "балкон", "открытое пламя"],
|
||||
},
|
||||
}, ensure_ascii=False))
|
||||
await receive_type(trainee, "kio.patch")
|
||||
await trainee.send(json.dumps({"type": "card.submit"}))
|
||||
await receive_type(trainee, "score.ready")
|
||||
|
||||
|
||||
def restart_backend(base: str) -> float:
|
||||
started = time.perf_counter()
|
||||
subprocess.run(["docker", "compose", "restart", "backend"], cwd=ROOT, check=True,
|
||||
capture_output=True, text=True, timeout=60)
|
||||
deadline = time.monotonic() + 60
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(f"{base}/api/health", timeout=2) as response:
|
||||
if response.status == 200:
|
||||
return (time.perf_counter() - started) * 1000
|
||||
except (OSError, urllib.error.URLError):
|
||||
pass
|
||||
time.sleep(0.25)
|
||||
raise TimeoutError("backend не восстановился после restart")
|
||||
|
||||
|
||||
async def run(args: argparse.Namespace) -> int:
|
||||
base = local_http(args.backend_url)
|
||||
session_id = uuid4()
|
||||
suffix = uuid4().hex[:10]
|
||||
login = f"report-smoke-{suffix}"
|
||||
trainee_name = f"report-smoke-trainee-{suffix}"
|
||||
password = secrets.token_urlsafe(24)
|
||||
await create_temp_user(args.database_url, login, password)
|
||||
try:
|
||||
cookie = login_cookie(base, login, password)
|
||||
await complete_card(base, cookie, str(session_id), trainee_name)
|
||||
live_json, _, live_ms = get_timed(base, f"/api/sessions/{session_id}/report", cookie)
|
||||
if json.loads(live_json)["session_id"] != str(session_id):
|
||||
raise RuntimeError("живой отчёт относится к другой сессии")
|
||||
|
||||
restart_ms = await asyncio.to_thread(restart_backend, base)
|
||||
cookie = login_cookie(base, login, password)
|
||||
json_body, json_type, json_ms = get_timed(base, f"/api/sessions/{session_id}/report", cookie)
|
||||
csv_body, csv_type, csv_ms = get_timed(base, f"/api/sessions/{session_id}/report.csv", cookie)
|
||||
pdf_body, pdf_type, pdf_ms = get_timed(base, f"/api/sessions/{session_id}/report.pdf", cookie)
|
||||
decoded = json.loads(json_body)
|
||||
times = [json_ms, csv_ms, pdf_ms]
|
||||
passed = (
|
||||
decoded["session_id"] == str(session_id)
|
||||
and decoded["score_final"] == 100
|
||||
and csv_body.startswith(b"\xef\xbb\xbf")
|
||||
and pdf_body.startswith(b"%PDF-")
|
||||
and json_type == "application/json"
|
||||
and csv_type == "text/csv"
|
||||
and pdf_type == "application/pdf"
|
||||
and max(times) <= 30_000
|
||||
)
|
||||
result = {
|
||||
"checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"session_id": str(session_id),
|
||||
"backend_restart_ms": round(restart_ms, 3),
|
||||
"live_json_ms": round(live_ms, 3),
|
||||
"persisted_json_ms": round(json_ms, 3),
|
||||
"persisted_csv_ms": round(csv_ms, 3),
|
||||
"persisted_pdf_ms": round(pdf_ms, 3),
|
||||
"pdf_bytes": len(pdf_body),
|
||||
"requirement_ms": 30_000,
|
||||
"passed": passed,
|
||||
"scope": "card exercise; real PostgreSQL; report fetched after backend restart",
|
||||
}
|
||||
rendered = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
print(rendered)
|
||||
if args.output:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(rendered + "\n", encoding="utf-8")
|
||||
return 0 if passed else 1
|
||||
finally:
|
||||
await cleanup(args.database_url, login, trainee_name, session_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--backend-url", default="http://127.0.0.1:8000")
|
||||
parser.add_argument(
|
||||
"--database-url",
|
||||
default="postgresql+asyncpg://lct:lct@127.0.0.1:5432/lct",
|
||||
)
|
||||
parser.add_argument("--output", type=Path)
|
||||
raise SystemExit(asyncio.run(run(parser.parse_args())))
|
||||
121
scripts/smoke_recording.py
Normal file
121
scripts/smoke_recording.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Живая проверка WAV-записи на локальном backend без микрофона."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import io
|
||||
import inspect
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import wave
|
||||
from uuid import uuid4
|
||||
|
||||
import websockets
|
||||
|
||||
from load_browser import control, local_url, login_cookie
|
||||
|
||||
|
||||
async def receive_type(socket, event_type: str, limit: int = 30) -> dict:
|
||||
for _ in range(limit):
|
||||
message = await asyncio.wait_for(socket.recv(), timeout=10)
|
||||
if isinstance(message, str):
|
||||
payload = json.loads(message)
|
||||
if payload.get("type") == event_type:
|
||||
return payload
|
||||
raise RuntimeError(f"событие {event_type} не получено")
|
||||
|
||||
|
||||
async def run(args: argparse.Namespace) -> int:
|
||||
backend = local_url(args.backend_url, {"http"})
|
||||
cookie = login_cookie(backend, args.login, args.password)
|
||||
ws_base = "ws" + backend.removeprefix("http")
|
||||
session_id = str(uuid4())
|
||||
await control(ws_base, session_id, cookie, {
|
||||
"type": "scenario.start",
|
||||
"scenario_id": args.scenario,
|
||||
"trainee": "recording-smoke",
|
||||
"mode": "training",
|
||||
"exercise": "call",
|
||||
})
|
||||
header_name = "additional_headers" if "additional_headers" in inspect.signature(
|
||||
websockets.connect
|
||||
).parameters else "extra_headers"
|
||||
async with websockets.connect(
|
||||
f"{ws_base}/ws/call/{session_id}",
|
||||
**{header_name: {"Cookie": cookie}},
|
||||
open_timeout=10,
|
||||
ping_interval=None,
|
||||
) as socket:
|
||||
await receive_type(socket, "call.incoming")
|
||||
await socket.send(json.dumps({"type": "call.answer"}))
|
||||
await receive_type(socket, "call.started")
|
||||
for frame_index in range(50):
|
||||
frame = b"".join(
|
||||
struct.pack("<h", round(5000 * math.sin(2 * math.pi * 440 * (frame_index * 320 + i) / 16000)))
|
||||
for i in range(320)
|
||||
)
|
||||
await socket.send(frame)
|
||||
await asyncio.sleep(0.02)
|
||||
await socket.send(json.dumps({"type": "call.hangup"}))
|
||||
await receive_type(socket, "call.ended")
|
||||
|
||||
request = urllib.request.Request(
|
||||
f"{backend}/api/sessions/{session_id}/recording.wav",
|
||||
headers={"Cookie": cookie},
|
||||
)
|
||||
for _ in range(20):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
content_type = response.headers.get_content_type()
|
||||
payload = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code not in {404, 409}:
|
||||
raise
|
||||
await asyncio.sleep(0.1)
|
||||
else:
|
||||
raise RuntimeError("готовая WAV-запись не появилась")
|
||||
|
||||
with wave.open(io.BytesIO(payload), "rb") as source:
|
||||
channels = source.getnchannels()
|
||||
sample_rate = source.getframerate()
|
||||
frames = source.getnframes()
|
||||
duration = frames / sample_rate
|
||||
result = {
|
||||
"backend": backend,
|
||||
"session_id": session_id,
|
||||
"content_type": content_type,
|
||||
"bytes": len(payload),
|
||||
"channels": channels,
|
||||
"sample_rate": sample_rate,
|
||||
"frames": frames,
|
||||
"duration_seconds": duration,
|
||||
"pass_wav_recording": (
|
||||
content_type == "audio/wav" and channels == 1 and sample_rate == 16_000
|
||||
and duration >= 0.9 and payload.startswith(b"RIFF")
|
||||
),
|
||||
"scope": "synthetic 440-Hz operator audio; VOICE_ENABLED=false, therefore no TTS track",
|
||||
}
|
||||
rendered = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
print(rendered)
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as stream:
|
||||
stream.write(rendered + "\n")
|
||||
return 0 if result["pass_wav_recording"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--backend-url", default="http://127.0.0.1:8000")
|
||||
parser.add_argument("--login", required=True)
|
||||
parser.add_argument("--password", required=True)
|
||||
parser.add_argument("--scenario", default="fire-apartment-l2")
|
||||
parser.add_argument("--output")
|
||||
try:
|
||||
raise SystemExit(asyncio.run(run(parser.parse_args())))
|
||||
except Exception as exc:
|
||||
print(f"recording smoke failed: {type(exc).__name__}: {exc}")
|
||||
raise SystemExit(2)
|
||||
286
scripts/smoke_sip.py
Normal file
286
scripts/smoke_sip.py
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Local SIP/RTP acceptance check for the bundled Asterisk service.
|
||||
|
||||
The check registers extension 6001, calls the Echo application at 7000 and
|
||||
measures round-trip time for PCMU RTP packets. It intentionally uses only the
|
||||
Python standard library so the offline delivery does not need a SIP test tool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import socket
|
||||
import statistics
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CRLF = "\r\n"
|
||||
|
||||
|
||||
def md5(value: str) -> str:
|
||||
return hashlib.md5(value.encode("utf-8")).hexdigest() # noqa: S324 - SIP Digest requires MD5
|
||||
|
||||
|
||||
def parse_message(data: bytes) -> tuple[str, dict[str, str], str]:
|
||||
text = data.decode("utf-8", "replace")
|
||||
head, _, body = text.partition(CRLF + CRLF)
|
||||
lines = head.split(CRLF)
|
||||
headers: dict[str, str] = {}
|
||||
for line in lines[1:]:
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
headers[key.strip().lower()] = value.strip()
|
||||
return lines[0], headers, body
|
||||
|
||||
|
||||
def digest_header(challenge: str, user: str, password: str, method: str, uri: str) -> str:
|
||||
parsed = {key: quoted or plain for key, quoted, plain in re.findall(
|
||||
r'(\w+)=(?:"([^"]*)"|([^,\s]+))', challenge
|
||||
)}
|
||||
realm = parsed["realm"]
|
||||
nonce = parsed["nonce"]
|
||||
response_parts = [md5(f"{user}:{realm}:{password}"), nonce]
|
||||
attrs = [
|
||||
f'username="{user}"', f'realm="{realm}"', f'nonce="{nonce}"',
|
||||
f'uri="{uri}"',
|
||||
]
|
||||
qop = parsed.get("qop", "").split(",")[0]
|
||||
if qop:
|
||||
nc = "00000001"
|
||||
cnonce = uuid.uuid4().hex[:16]
|
||||
response_parts.extend([nc, cnonce, qop])
|
||||
attrs.extend([f"qop={qop}", f"nc={nc}", f'cnonce="{cnonce}"'])
|
||||
response_parts.append(md5(f"{method}:{uri}"))
|
||||
attrs.extend([f'response="{md5(":".join(response_parts))}"', "algorithm=MD5"])
|
||||
return "Digest " + ", ".join(attrs)
|
||||
|
||||
|
||||
def request(
|
||||
method: str,
|
||||
uri: str,
|
||||
host: str,
|
||||
local_port: int,
|
||||
user: str,
|
||||
call_id: str,
|
||||
cseq: int,
|
||||
branch: str,
|
||||
*,
|
||||
authorization: str | None = None,
|
||||
body: str = "",
|
||||
to_user: str | None = None,
|
||||
from_tag: str | None = None,
|
||||
to_header: str | None = None,
|
||||
) -> bytes:
|
||||
target = to_user or user
|
||||
from_tag = from_tag or uuid.uuid4().hex[:10]
|
||||
headers = [
|
||||
f"{method} {uri} SIP/2.0",
|
||||
f"Via: SIP/2.0/UDP 127.0.0.1:{local_port};branch={branch};rport",
|
||||
"Max-Forwards: 70",
|
||||
f'From: <sip:{user}@{host}>;tag={from_tag}',
|
||||
f"To: {to_header or f'<sip:{target}@{host}>'}",
|
||||
f"Call-ID: {call_id}",
|
||||
f"CSeq: {cseq} {method}",
|
||||
f"Contact: <sip:{user}@127.0.0.1:{local_port};transport=udp>",
|
||||
"User-Agent: LCT-offline-smoke/1.0",
|
||||
]
|
||||
if authorization:
|
||||
headers.append(f"Authorization: {authorization}")
|
||||
if body:
|
||||
headers.append("Content-Type: application/sdp")
|
||||
headers.extend([f"Content-Length: {len(body.encode())}", "", body])
|
||||
return CRLF.join(headers).encode()
|
||||
|
||||
|
||||
def receive_final(sock: socket.socket, timeout: float = 3.0) -> tuple[str, dict[str, str], str]:
|
||||
deadline = time.monotonic() + timeout
|
||||
last = None
|
||||
while time.monotonic() < deadline:
|
||||
sock.settimeout(max(0.05, deadline - time.monotonic()))
|
||||
parsed = parse_message(sock.recvfrom(65535)[0])
|
||||
last = parsed
|
||||
status = parsed[0]
|
||||
if status.startswith("SIP/2.0") and not status.startswith("SIP/2.0 1"):
|
||||
return parsed
|
||||
raise TimeoutError(f"No final SIP response; last={last and last[0]}")
|
||||
|
||||
|
||||
def compose_password(user: str) -> str:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker", "compose", "-f", "docker-compose.yml", "-f", "docker-compose.sip.yml",
|
||||
"exec", "-T", "sip", "cat", "/var/lib/lct-sip/credentials.env",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
prefix = f"SIP_{user}_PASSWORD="
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith(prefix):
|
||||
return line[len(prefix):]
|
||||
raise RuntimeError(f"No password for SIP extension {user}")
|
||||
|
||||
|
||||
def send_ack(
|
||||
sock: socket.socket, host: str, port: int, user: str, call_id: str, cseq: int,
|
||||
target: str, from_tag: str, to_header: str | None = None,
|
||||
) -> None:
|
||||
payload = request(
|
||||
"ACK", f"sip:{target}@{host}", host, sock.getsockname()[1], user, call_id, cseq,
|
||||
f"z9hG4bK{uuid.uuid4().hex}", to_user=target, from_tag=from_tag,
|
||||
to_header=to_header,
|
||||
)
|
||||
sock.sendto(payload, (host, port))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=5060)
|
||||
parser.add_argument("--rtp-host-offset", type=int, default=0,
|
||||
help="Host-to-container RTP port offset (1000 for 11000:10000 mappings)")
|
||||
parser.add_argument("--user", default="6001")
|
||||
parser.add_argument("--password", default=os.getenv("SIP_PASSWORD"))
|
||||
parser.add_argument("--packets", type=int, default=30)
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
password = args.password or compose_password(args.user)
|
||||
|
||||
sip = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sip.bind(("127.0.0.1", 0))
|
||||
sip_port = sip.getsockname()[1]
|
||||
registrar = f"sip:{args.host}"
|
||||
|
||||
register_id = f"{uuid.uuid4().hex}@lct-smoke"
|
||||
packet = request("REGISTER", registrar, args.host, sip_port, args.user, register_id, 1,
|
||||
f"z9hG4bK{uuid.uuid4().hex}")
|
||||
sip.sendto(packet, (args.host, args.port))
|
||||
status, headers, _ = receive_final(sip)
|
||||
if "401" not in status:
|
||||
raise RuntimeError(f"Expected REGISTER challenge, got {status}")
|
||||
challenge = headers.get("www-authenticate")
|
||||
if not challenge:
|
||||
raise RuntimeError("REGISTER challenge has no WWW-Authenticate header")
|
||||
auth = digest_header(challenge, args.user, password, "REGISTER", registrar)
|
||||
packet = request("REGISTER", registrar, args.host, sip_port, args.user, register_id, 2,
|
||||
f"z9hG4bK{uuid.uuid4().hex}", authorization=auth)
|
||||
sip.sendto(packet, (args.host, args.port))
|
||||
status, _, _ = receive_final(sip)
|
||||
if "200" not in status:
|
||||
raise RuntimeError(f"REGISTER failed: {status}")
|
||||
|
||||
rtp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
rtp.bind(("0.0.0.0", 0))
|
||||
rtp.settimeout(1.0)
|
||||
rtp_port = rtp.getsockname()[1]
|
||||
sdp = CRLF.join([
|
||||
"v=0", f"o=lct 1 1 IN IP4 host.docker.internal", "s=LCT RTP smoke",
|
||||
"c=IN IP4 host.docker.internal", "t=0 0", f"m=audio {rtp_port} RTP/AVP 0",
|
||||
"a=rtpmap:0 PCMU/8000", "a=sendrecv", "",
|
||||
])
|
||||
call_id = f"{uuid.uuid4().hex}@lct-smoke"
|
||||
from_tag = uuid.uuid4().hex[:10]
|
||||
invite_uri = f"sip:7000@{args.host}"
|
||||
invite_cseq = 1
|
||||
packet = request("INVITE", invite_uri, args.host, sip_port, args.user, call_id, invite_cseq,
|
||||
f"z9hG4bK{uuid.uuid4().hex}", body=sdp, to_user="7000",
|
||||
from_tag=from_tag)
|
||||
sip.sendto(packet, (args.host, args.port))
|
||||
status, headers, _ = receive_final(sip)
|
||||
if "401" in status or "407" in status:
|
||||
send_ack(sip, args.host, args.port, args.user, call_id, invite_cseq, "7000",
|
||||
from_tag, headers.get("to"))
|
||||
challenge = headers.get("www-authenticate") or headers.get("proxy-authenticate")
|
||||
if not challenge:
|
||||
raise RuntimeError("INVITE challenge has no authentication header")
|
||||
invite_cseq += 1
|
||||
auth = digest_header(challenge, args.user, password, "INVITE", invite_uri)
|
||||
packet = request("INVITE", invite_uri, args.host, sip_port, args.user, call_id, invite_cseq,
|
||||
f"z9hG4bK{uuid.uuid4().hex}", authorization=auth, body=sdp,
|
||||
to_user="7000", from_tag=from_tag)
|
||||
sip.sendto(packet, (args.host, args.port))
|
||||
status, answer_headers, answer_sdp = receive_final(sip)
|
||||
else:
|
||||
answer_headers = headers
|
||||
answer_sdp = ""
|
||||
if "200" not in status:
|
||||
raise RuntimeError(f"INVITE failed: {status}")
|
||||
send_ack(sip, args.host, args.port, args.user, call_id, invite_cseq, "7000",
|
||||
from_tag, answer_headers.get("to"))
|
||||
|
||||
match = re.search(r"^m=audio\s+(\d+)", answer_sdp, re.MULTILINE)
|
||||
if not match:
|
||||
raise RuntimeError("SIP 200 response contains no RTP audio port")
|
||||
asterisk_rtp_port = int(match.group(1))
|
||||
target = (args.host, asterisk_rtp_port + args.rtp_host_offset)
|
||||
ssrc = random.getrandbits(32)
|
||||
sequence = random.getrandbits(16)
|
||||
timestamp = random.getrandbits(32)
|
||||
samples: list[float] = []
|
||||
|
||||
# Prime symmetric RTP, then measure matched echoed sequence numbers.
|
||||
for index in range(args.packets + 3):
|
||||
seq = (sequence + index) & 0xFFFF
|
||||
packet = struct.pack("!BBHII", 0x80, 0x00, seq, (timestamp + index * 160) & 0xFFFFFFFF, ssrc)
|
||||
marker = struct.pack("!I", index)
|
||||
packet += marker + b"\xff" * 156
|
||||
started = time.perf_counter()
|
||||
rtp.sendto(packet, target)
|
||||
deadline = started + 0.5
|
||||
while time.perf_counter() < deadline:
|
||||
try:
|
||||
response, _ = rtp.recvfrom(2048)
|
||||
except socket.timeout:
|
||||
break
|
||||
if len(response) >= 16 and response[12:16] == marker:
|
||||
if index >= 3:
|
||||
samples.append((time.perf_counter() - started) * 1000)
|
||||
break
|
||||
rtp.settimeout(max(0.001, deadline - time.perf_counter()))
|
||||
rtp.settimeout(0.5)
|
||||
time.sleep(0.02)
|
||||
|
||||
if not samples:
|
||||
raise RuntimeError(f"No echoed RTP received from {target}")
|
||||
ordered = sorted(samples)
|
||||
p95 = ordered[min(len(ordered) - 1, int(len(ordered) * 0.95))]
|
||||
result = {
|
||||
"checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"sip_registration": "ok",
|
||||
"sip_echo_call": "ok",
|
||||
"rtp_packets_received": len(samples),
|
||||
"rtp_rtt_ms_median": round(statistics.median(samples), 3),
|
||||
"rtp_rtt_ms_p95": round(p95, 3),
|
||||
"requirement_ms": 150,
|
||||
"passed": p95 <= 150,
|
||||
}
|
||||
|
||||
bye = request(
|
||||
"BYE", invite_uri, args.host, sip_port, args.user, call_id, invite_cseq + 1,
|
||||
f"z9hG4bK{uuid.uuid4().hex}", to_user="7000", from_tag=from_tag,
|
||||
to_header=answer_headers.get("to"),
|
||||
)
|
||||
sip.sendto(bye, (args.host, args.port))
|
||||
bye_status, _, _ = receive_final(sip)
|
||||
result["sip_hangup"] = "ok" if "200" in bye_status else bye_status
|
||||
result["passed"] = result["passed"] and result["sip_hangup"] == "ok"
|
||||
rendered = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
print(rendered)
|
||||
if args.output:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(rendered + "\n", encoding="utf-8")
|
||||
return 0 if result["passed"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
134
scripts/smoke_sip_websocket.py
Normal file
134
scripts/smoke_sip_websocket.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Проверка Digest REGISTER браузерного SIP-транспорта Asterisk по WebSocket."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import ssl
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import websockets
|
||||
|
||||
from smoke_sip import CRLF, compose_password, digest_header, parse_message
|
||||
|
||||
|
||||
def local_ws(value: str) -> str:
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme not in {"ws", "wss"} or parsed.hostname not in {
|
||||
"127.0.0.1", "localhost", "::1"
|
||||
}:
|
||||
raise ValueError("WebSocket URL должен вести на локальный стенд")
|
||||
return value
|
||||
|
||||
|
||||
def register_message(
|
||||
host: str, user: str, call_id: str, cseq: int, branch: str,
|
||||
authorization: str | None = None, expires: int = 300,
|
||||
) -> str:
|
||||
uri = f"sip:{host}"
|
||||
tag = call_id[:10]
|
||||
lines = [
|
||||
f"REGISTER {uri} SIP/2.0",
|
||||
f"Via: SIP/2.0/WSS browser.invalid;branch={branch};rport",
|
||||
"Max-Forwards: 70",
|
||||
f"From: <sip:{user}@{host}>;tag={tag}",
|
||||
f"To: <sip:{user}@{host}>",
|
||||
f"Call-ID: {call_id}@lct-websocket-smoke",
|
||||
f"CSeq: {cseq} REGISTER",
|
||||
f"Contact: <sip:{user}@browser.invalid;transport=ws>;expires={expires}",
|
||||
f"Expires: {expires}",
|
||||
"User-Agent: LCT-WebSocket-smoke/1.0",
|
||||
]
|
||||
if authorization:
|
||||
lines.append(f"Authorization: {authorization}")
|
||||
lines.extend(["Content-Length: 0", "", ""])
|
||||
return CRLF.join(lines)
|
||||
|
||||
|
||||
async def receive_response(socket, timeout: float = 5) -> tuple[str, dict[str, str], str]:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
raw = await asyncio.wait_for(socket.recv(), timeout=deadline - time.monotonic())
|
||||
parsed = parse_message(raw.encode() if isinstance(raw, str) else raw)
|
||||
if parsed[0].startswith("SIP/2.0"):
|
||||
return parsed
|
||||
raise TimeoutError("нет ответа SIP по WebSocket")
|
||||
|
||||
|
||||
async def run(args: argparse.Namespace) -> int:
|
||||
url = local_ws(args.url)
|
||||
password = args.password or compose_password(args.user)
|
||||
host = args.domain
|
||||
call_id = uuid.uuid4().hex
|
||||
registrar = f"sip:{host}"
|
||||
started = time.perf_counter()
|
||||
tls = None
|
||||
if url.startswith("wss:"):
|
||||
tls = ssl._create_unverified_context() if args.insecure else True
|
||||
async with websockets.connect(
|
||||
url,
|
||||
subprotocols=["sip"],
|
||||
open_timeout=args.timeout,
|
||||
close_timeout=2,
|
||||
ping_interval=None,
|
||||
ssl=tls,
|
||||
) as socket:
|
||||
await socket.send(register_message(
|
||||
host, args.user, call_id, 1, f"z9hG4bK{uuid.uuid4().hex}"
|
||||
))
|
||||
status, headers, _ = await receive_response(socket, args.timeout)
|
||||
if "401" not in status:
|
||||
raise RuntimeError(f"ожидался Digest challenge, получено {status}")
|
||||
challenge = headers.get("www-authenticate")
|
||||
if not challenge:
|
||||
raise RuntimeError("в challenge отсутствует WWW-Authenticate")
|
||||
auth = digest_header(challenge, args.user, password, "REGISTER", registrar)
|
||||
await socket.send(register_message(
|
||||
host, args.user, call_id, 2, f"z9hG4bK{uuid.uuid4().hex}", auth
|
||||
))
|
||||
status, _, _ = await receive_response(socket, args.timeout)
|
||||
if "200" not in status:
|
||||
raise RuntimeError(f"WebSocket REGISTER отклонён: {status}")
|
||||
elapsed_ms = (time.perf_counter() - started) * 1000
|
||||
# Снимаем регистрацию тем же аутентифицированным диалогом, чтобы smoke
|
||||
# не оставлял контакт и не мешал браузерному абоненту.
|
||||
await socket.send(register_message(
|
||||
host, args.user, call_id, 3, f"z9hG4bK{uuid.uuid4().hex}", auth, expires=0
|
||||
))
|
||||
unregister_status, _, _ = await receive_response(socket, args.timeout)
|
||||
|
||||
result = {
|
||||
"checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"url": url,
|
||||
"extension": args.user,
|
||||
"websocket_subprotocol": "sip",
|
||||
"digest_challenge": "ok",
|
||||
"registration": "ok",
|
||||
"unregister": "ok" if "200" in unregister_status else unregister_status,
|
||||
"registration_ms": round(elapsed_ms, 3),
|
||||
"passed": "200" in unregister_status,
|
||||
}
|
||||
rendered = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
print(rendered)
|
||||
if args.output:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(rendered + "\n", encoding="utf-8")
|
||||
return 0 if result["passed"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--url", default="ws://127.0.0.1:8088/ws")
|
||||
parser.add_argument("--domain", default="localhost")
|
||||
parser.add_argument("--user", default="6101")
|
||||
parser.add_argument("--password")
|
||||
parser.add_argument("--timeout", type=float, default=8)
|
||||
parser.add_argument("--insecure", action="store_true",
|
||||
help="доверять только self-signed сертификату локального стенда")
|
||||
parser.add_argument("--output", type=Path)
|
||||
raise SystemExit(asyncio.run(run(parser.parse_args())))
|
||||
200
scripts/smoke_webrtc_browser.py
Normal file
200
scripts/smoke_webrtc_browser.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Два Chromium-клиента: WSS-регистрация, WebRTC-вызов 6101↔6102 и запись."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from load_browser import local_url, login_cookie
|
||||
from smoke_sip import compose_password
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.api.auth import hash_password # noqa: E402
|
||||
from app.db.models import AuditLog, User # noqa: E402
|
||||
|
||||
|
||||
async def create_account(database_url: str, login: str, password: str) -> None:
|
||||
engine = create_async_engine(database_url)
|
||||
try:
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with factory() as db:
|
||||
db.add(User(
|
||||
login=login,
|
||||
full_name="WebRTC browser smoke",
|
||||
password_hash=hash_password(password),
|
||||
role="instructor",
|
||||
blocked=False,
|
||||
))
|
||||
await db.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def cleanup_account(database_url: str, login: str) -> None:
|
||||
engine = create_async_engine(database_url)
|
||||
try:
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with factory() as db:
|
||||
await db.execute(delete(User).where(User.login == login))
|
||||
await db.execute(delete(AuditLog).where(AuditLog.actor == login))
|
||||
await db.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def recordings() -> dict[str, int]:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker", "compose", "-f", "docker-compose.yml", "-f", "docker-compose.sip.yml",
|
||||
"exec", "-T", "sip", "find", "/recordings", "-maxdepth", "1", "-type", "f",
|
||||
"-name", "*.wav", "-printf", "%f %s\\n",
|
||||
],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
found: dict[str, int] = {}
|
||||
for line in result.stdout.splitlines():
|
||||
name, _, size = line.rpartition(" ")
|
||||
if name and size.isdigit():
|
||||
found[name] = int(size)
|
||||
return found
|
||||
|
||||
|
||||
async def configure(page, extension: str, password: str, target: str, timeout_ms: float) -> None:
|
||||
await page.get_by_label("SIP внутренний номер").fill(extension)
|
||||
await page.get_by_label("Пароль SIP").fill(password)
|
||||
await page.get_by_role("button", name="Подключить телефон").click()
|
||||
await page.get_by_text("зарегистрирован в Asterisk", exact=False).wait_for(timeout=timeout_ms)
|
||||
await page.get_by_label("Номер SIP собеседника").fill(target)
|
||||
|
||||
|
||||
async def run(args: argparse.Namespace) -> int:
|
||||
frontend = local_url(args.frontend_url, {"https"})
|
||||
backend = local_url(args.backend_url, {"http"})
|
||||
login = f"webrtc-smoke-{secrets.token_hex(5)}"
|
||||
app_password = secrets.token_urlsafe(24)
|
||||
await create_account(args.database_url, login, app_password)
|
||||
try:
|
||||
cookie = login_cookie(backend, login, app_password)
|
||||
cookie_name, cookie_value = cookie.split("=", 1)
|
||||
sip_6101 = compose_password("6101")
|
||||
sip_6102 = compose_password("6102")
|
||||
before = recordings()
|
||||
errors: list[str] = []
|
||||
started = time.perf_counter()
|
||||
timeout_ms = args.timeout * 1000
|
||||
|
||||
async with async_playwright() as playwright:
|
||||
browser = await playwright.chromium.launch(headless=True, args=[
|
||||
"--use-fake-ui-for-media-stream",
|
||||
"--use-fake-device-for-media-stream",
|
||||
])
|
||||
contexts = []
|
||||
pages = []
|
||||
for _ in range(2):
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
await context.grant_permissions(["microphone"], origin=frontend)
|
||||
await context.add_cookies([{
|
||||
"name": cookie_name,
|
||||
"value": cookie_value,
|
||||
"url": frontend,
|
||||
"httpOnly": True,
|
||||
"secure": True,
|
||||
"sameSite": "Lax",
|
||||
}])
|
||||
page = await context.new_page()
|
||||
page.on("pageerror", lambda error: errors.append(str(error)))
|
||||
await page.goto(f"{frontend}/phone", wait_until="networkidle", timeout=timeout_ms)
|
||||
contexts.append(context)
|
||||
pages.append(page)
|
||||
|
||||
await asyncio.gather(
|
||||
configure(pages[0], "6101", sip_6101, "6102", timeout_ms),
|
||||
configure(pages[1], "6102", sip_6102, "6101", timeout_ms),
|
||||
)
|
||||
registered_ms = (time.perf_counter() - started) * 1000
|
||||
await pages[0].get_by_role("button", name="Позвонить").click()
|
||||
await pages[1].get_by_role("button", name="Ответить · 6101").wait_for(timeout=timeout_ms)
|
||||
await pages[1].get_by_role("button", name="Ответить · 6101").click()
|
||||
await asyncio.gather(
|
||||
pages[0].get_by_text("разговор идёт", exact=False).wait_for(timeout=timeout_ms),
|
||||
pages[1].get_by_text("разговор идёт", exact=False).wait_for(timeout=timeout_ms),
|
||||
)
|
||||
await asyncio.gather(
|
||||
pages[0].wait_for_function(
|
||||
"document.querySelector('audio')?.srcObject?.getAudioTracks().length > 0",
|
||||
timeout=timeout_ms,
|
||||
),
|
||||
pages[1].wait_for_function(
|
||||
"document.querySelector('audio')?.srcObject?.getAudioTracks().length > 0",
|
||||
timeout=timeout_ms,
|
||||
),
|
||||
)
|
||||
connected_ms = (time.perf_counter() - started) * 1000
|
||||
await asyncio.sleep(1)
|
||||
await pages[0].get_by_role("button", name="Завершить").click()
|
||||
await asyncio.gather(
|
||||
pages[0].get_by_text("зарегистрирован в Asterisk", exact=False).wait_for(timeout=timeout_ms),
|
||||
pages[1].get_by_text("зарегистрирован в Asterisk", exact=False).wait_for(timeout=timeout_ms),
|
||||
)
|
||||
for context in contexts:
|
||||
await context.close()
|
||||
await browser.close()
|
||||
|
||||
deadline = time.monotonic() + 10
|
||||
after = recordings()
|
||||
new_recordings = {name: size for name, size in after.items() if name not in before and size > 44}
|
||||
while not new_recordings and time.monotonic() < deadline:
|
||||
await asyncio.sleep(0.5)
|
||||
after = recordings()
|
||||
new_recordings = {name: size for name, size in after.items() if name not in before and size > 44}
|
||||
|
||||
passed = not errors and bool(new_recordings)
|
||||
result = {
|
||||
"checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"frontend": frontend,
|
||||
"extensions": ["6101", "6102"],
|
||||
"registered_clients": 2,
|
||||
"registered_ms": round(registered_ms, 3),
|
||||
"call_connected_ms": round(connected_ms, 3),
|
||||
"remote_audio_tracks": [1, 1],
|
||||
"browser_errors": errors,
|
||||
"new_recordings": new_recordings,
|
||||
"passed": passed,
|
||||
"scope": "two isolated Chromium contexts; WSS/SIP Digest; WebRTC audio tracks; Asterisk MixMonitor",
|
||||
}
|
||||
rendered = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
print(rendered)
|
||||
if args.output:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(rendered + "\n", encoding="utf-8")
|
||||
return 0 if passed else 1
|
||||
finally:
|
||||
await cleanup_account(args.database_url, login)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--frontend-url", default="https://127.0.0.1:5443")
|
||||
parser.add_argument("--backend-url", default="http://127.0.0.1:8000")
|
||||
parser.add_argument(
|
||||
"--database-url", default="postgresql+asyncpg://lct:lct@127.0.0.1:5432/lct"
|
||||
)
|
||||
parser.add_argument("--timeout", type=float, default=30)
|
||||
parser.add_argument("--output", type=Path)
|
||||
raise SystemExit(asyncio.run(run(parser.parse_args())))
|
||||
298
scripts/test_recovery.py
Normal file
298
scripts/test_recovery.py
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Замер переподключения браузерного WSS после перезапуска frontend-proxy."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from load_browser import control, local_url, login_cookie
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.api.auth import hash_password # noqa: E402
|
||||
from app.db.models import AuditLog, Session, Trainee, User # noqa: E402
|
||||
|
||||
|
||||
async def create_temp_accounts(
|
||||
database_url: str,
|
||||
instructor_login: str,
|
||||
instructor_password: str,
|
||||
trainee_login: str,
|
||||
trainee_password: str,
|
||||
trainee_name: str,
|
||||
):
|
||||
"""Создать изолированную пару ролей и вернуть ID привязанного курсанта."""
|
||||
engine = create_async_engine(database_url)
|
||||
try:
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with factory() as db:
|
||||
trainee = Trainee(name=trainee_name)
|
||||
db.add(trainee)
|
||||
await db.flush()
|
||||
db.add_all([
|
||||
User(
|
||||
login=instructor_login,
|
||||
full_name="Преподаватель проверки восстановления",
|
||||
password_hash=hash_password(instructor_password),
|
||||
role="instructor",
|
||||
blocked=False,
|
||||
),
|
||||
User(
|
||||
login=trainee_login,
|
||||
full_name=trainee_name,
|
||||
password_hash=hash_password(trainee_password),
|
||||
role="trainee",
|
||||
trainee_id=trainee.id,
|
||||
blocked=False,
|
||||
),
|
||||
])
|
||||
await db.commit()
|
||||
return trainee.id
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def cleanup_temp_accounts(
|
||||
database_url: str,
|
||||
logins: list[str],
|
||||
trainee_name: str,
|
||||
session_id,
|
||||
) -> None:
|
||||
engine = create_async_engine(database_url)
|
||||
try:
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with factory() as db:
|
||||
await db.execute(delete(Session).where(Session.id == session_id))
|
||||
await db.execute(delete(User).where(User.login.in_(logins)))
|
||||
await db.execute(delete(Trainee).where(Trainee.name == trainee_name))
|
||||
await db.execute(delete(AuditLog).where(AuditLog.actor.in_(logins)))
|
||||
await db.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def compose_service(action: str, service: str) -> None:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"docker", "compose", action, service,
|
||||
cwd=ROOT,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
if process.returncode:
|
||||
detail = (stderr or stdout).decode(errors="replace").strip()
|
||||
raise RuntimeError(f"docker compose {action} {service}: {detail}")
|
||||
|
||||
|
||||
async def wait_service_healthy(service: str, timeout: float = 45) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"docker", "compose", "ps", service,
|
||||
cwd=ROOT,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await process.communicate()
|
||||
if process.returncode == 0 and b"(healthy)" in stdout:
|
||||
return
|
||||
await asyncio.sleep(0.5)
|
||||
raise TimeoutError(f"контейнер {service} не стал healthy за {timeout} с")
|
||||
|
||||
|
||||
async def run(args: argparse.Namespace) -> int:
|
||||
frontend = local_url(args.frontend_url, {"https"})
|
||||
backend = local_url(args.backend_url, {"http"})
|
||||
ephemeral = not args.login and not args.password
|
||||
if bool(args.login) != bool(args.password):
|
||||
raise ValueError("login и password задаются вместе либо не задаются")
|
||||
instructor_login = args.login
|
||||
instructor_password = args.password
|
||||
trainee_login = args.trainee_login
|
||||
trainee_password = args.trainee_password
|
||||
trainee_name = "recovery-check"
|
||||
trainee_id = None
|
||||
session_uuid = uuid4()
|
||||
if ephemeral:
|
||||
suffix = session_uuid.hex[:10]
|
||||
instructor_login = f"recovery-instructor-{suffix}"
|
||||
instructor_password = secrets.token_urlsafe(24)
|
||||
trainee_login = f"recovery-trainee-{suffix}"
|
||||
trainee_password = secrets.token_urlsafe(24)
|
||||
trainee_name = f"recovery-trainee-{suffix}"
|
||||
trainee_id = await create_temp_accounts(
|
||||
args.database_url,
|
||||
instructor_login,
|
||||
instructor_password,
|
||||
trainee_login,
|
||||
trainee_password,
|
||||
trainee_name,
|
||||
)
|
||||
elif not trainee_login or not trainee_password:
|
||||
raise ValueError("для существующих аккаунтов нужны trainee-login и trainee-password")
|
||||
assert instructor_login and instructor_password and trainee_login and trainee_password
|
||||
instructor_cookie = login_cookie(backend, instructor_login, instructor_password)
|
||||
trainee_cookie = login_cookie(backend, trainee_login, trainee_password)
|
||||
cookie_name, cookie_value = trainee_cookie.split("=", 1)
|
||||
ws_base = "ws" + backend.removeprefix("http")
|
||||
session_id = str(session_uuid)
|
||||
started = False
|
||||
frontend_stopped = False
|
||||
backend_paused = False
|
||||
try:
|
||||
await control(ws_base, session_id, instructor_cookie, {
|
||||
"type": "scenario.start",
|
||||
"scenario_id": args.scenario,
|
||||
"trainee": trainee_name,
|
||||
"trainee_id": str(trainee_id) if trainee_id else None,
|
||||
"mode": "training",
|
||||
"exercise": "card",
|
||||
})
|
||||
started = True
|
||||
async with async_playwright() as playwright:
|
||||
browser = await playwright.chromium.launch(headless=True)
|
||||
context = await browser.new_context(
|
||||
viewport={"width": 1280, "height": 720},
|
||||
ignore_https_errors=args.ignore_https_errors,
|
||||
)
|
||||
await context.add_cookies([{
|
||||
"name": cookie_name,
|
||||
"value": cookie_value,
|
||||
"url": frontend,
|
||||
"httpOnly": True,
|
||||
"secure": True,
|
||||
"sameSite": "Lax",
|
||||
}])
|
||||
page = await context.new_page()
|
||||
errors: list[str] = []
|
||||
page.on("pageerror", lambda error: errors.append(str(error)))
|
||||
await page.goto(
|
||||
f"{frontend}/trainee?session={session_id}",
|
||||
wait_until="domcontentloaded",
|
||||
timeout=args.timeout * 1000,
|
||||
)
|
||||
address = page.locator(".kio-arm-row-address input")
|
||||
await address.wait_for(
|
||||
timeout=args.timeout * 1000,
|
||||
)
|
||||
await page.locator(".arm-topbar-state .state-ok").wait_for(
|
||||
timeout=args.timeout * 1000,
|
||||
)
|
||||
await page.evaluate("""() => {
|
||||
const node = document.querySelector('.arm-topbar-state');
|
||||
window.__lctRecoveryStates = [node?.textContent ?? 'missing'];
|
||||
new MutationObserver(() => window.__lctRecoveryStates.push(
|
||||
node?.textContent ?? 'missing'
|
||||
)).observe(node, {attributes: true, childList: true, subtree: true});
|
||||
}""")
|
||||
|
||||
buffered_value = "улица Буферная, дом 30"
|
||||
started_at = time.perf_counter()
|
||||
# Держим существующее WSS-соединение открытым, но не даём backend
|
||||
# подтвердить правку. Это воспроизводит пакет, зависший ровно в
|
||||
# момент отказа прокси, без искусственного доступа к JS-сокету.
|
||||
await compose_service("pause", "backend")
|
||||
backend_paused = True
|
||||
await address.fill(buffered_value)
|
||||
await page.locator(".kio-arm-row-address.kio-arm-pending").wait_for(
|
||||
timeout=args.timeout * 1000,
|
||||
)
|
||||
await compose_service("stop", "frontend")
|
||||
frontend_stopped = True
|
||||
await page.wait_for_function(
|
||||
"window.__lctRecoveryStates.some(value => !value.includes('АРМ подключён'))",
|
||||
timeout=args.timeout * 1000,
|
||||
)
|
||||
await compose_service("unpause", "backend")
|
||||
backend_paused = False
|
||||
await wait_service_healthy("backend")
|
||||
await compose_service("start", "frontend")
|
||||
frontend_stopped = False
|
||||
await page.wait_for_function(
|
||||
"window.__lctRecoveryStates.at(-1).includes('АРМ подключён')",
|
||||
timeout=args.timeout * 1000,
|
||||
)
|
||||
await page.wait_for_function(
|
||||
"!document.querySelector('.kio-arm-row-address')?.classList.contains('kio-arm-pending')",
|
||||
timeout=args.timeout * 1000,
|
||||
)
|
||||
buffered_value_after = await page.locator(".kio-arm-row-address input").input_value()
|
||||
recovery_seconds = time.perf_counter() - started_at
|
||||
states = await page.evaluate("window.__lctRecoveryStates")
|
||||
await context.close()
|
||||
await browser.close()
|
||||
|
||||
result = {
|
||||
"frontend": frontend,
|
||||
"session_id": session_id,
|
||||
"component_restarted": "frontend (Nginx TLS/WSS proxy) container stop/start",
|
||||
"observed_states": states,
|
||||
"recovery_seconds": recovery_seconds,
|
||||
"browser_errors": errors,
|
||||
"buffered_patch": {
|
||||
"field": "address",
|
||||
"value": buffered_value_after,
|
||||
"confirmed_by_server": buffered_value_after == buffered_value,
|
||||
},
|
||||
"pass_recovery_30s": (
|
||||
recovery_seconds <= 30 and not errors and buffered_value_after == buffered_value
|
||||
),
|
||||
"scope": (
|
||||
"one live card exercise; backend acknowledgement is stalled after address edit; "
|
||||
"Nginx is stopped; after recovery reconnect repeats only the idempotent KIO patch"
|
||||
),
|
||||
}
|
||||
rendered = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
print(rendered)
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as stream:
|
||||
stream.write(rendered + "\n")
|
||||
return 0 if result["pass_recovery_30s"] else 1
|
||||
finally:
|
||||
if backend_paused:
|
||||
await compose_service("unpause", "backend")
|
||||
await wait_service_healthy("backend")
|
||||
if frontend_stopped:
|
||||
await compose_service("start", "frontend")
|
||||
if started:
|
||||
await control(ws_base, session_id, instructor_cookie, {"type": "session.stop"})
|
||||
if ephemeral:
|
||||
await cleanup_temp_accounts(
|
||||
args.database_url,
|
||||
[instructor_login, trainee_login],
|
||||
trainee_name,
|
||||
session_uuid,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--frontend-url", default="https://127.0.0.1:5443")
|
||||
parser.add_argument("--backend-url", default="http://127.0.0.1:8000")
|
||||
parser.add_argument("--login")
|
||||
parser.add_argument("--password")
|
||||
parser.add_argument("--trainee-login")
|
||||
parser.add_argument("--trainee-password")
|
||||
parser.add_argument(
|
||||
"--database-url",
|
||||
default="postgresql+asyncpg://lct:lct@127.0.0.1:5432/lct",
|
||||
)
|
||||
parser.add_argument("--scenario", default="fire-apartment-l2")
|
||||
parser.add_argument("--timeout", type=float, default=30)
|
||||
parser.add_argument("--ignore-https-errors", action="store_true")
|
||||
parser.add_argument("--output")
|
||||
try:
|
||||
raise SystemExit(asyncio.run(run(parser.parse_args())))
|
||||
except Exception as exc:
|
||||
print(f"recovery check failed: {type(exc).__name__}: {exc}")
|
||||
raise SystemExit(2)
|
||||
Loading…
Reference in a new issue