197 lines
7.7 KiB
Python
197 lines
7.7 KiB
Python
#!/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())
|