Complete DDS training workflow and delivery package

This commit is contained in:
andreysk0304 2026-09-24 01:10:49 +03:00
commit 4c4b91064f
229 changed files with 11969 additions and 1024 deletions

View file

@ -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
View 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
View 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)

View 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
View 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())

View 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
View 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
View 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())

View 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())))

View 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
View 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)