lct-hack/scripts/smoke_webrtc_browser.py

228 lines
9.8 KiB
Python

#!/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 sip_recording_cleanup import remove_smoke_recordings
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", "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, {"http", "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)
recordings_before: set[str] | None = None
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()
recordings_before = set(before)
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()
try:
await pages[1].get_by_role("button", name="Ответить · 6101").wait_for(timeout=timeout_ms)
except Exception as exc:
diagnostics = []
for page in pages:
diagnostics.append({
"url": page.url,
"body": (await page.locator("body").inner_text())[-3000:],
})
raise RuntimeError(
"incoming SIP call was not displayed; browser states: "
+ json.dumps(diagnostics, ensure_ascii=False)
) from exc
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:
try:
if recordings_before is not None:
try:
current_recordings = recordings()
except (OSError, subprocess.CalledProcessError):
current_recordings = {}
print("WARNING: не удалось проверить очистку временной WAV-записи WebRTC smoke", file=sys.stderr)
created = set(current_recordings) - recordings_before
# Only this exercise's direction; preserve older files and any
# unrelated recordings in the stack's durable volume.
created = {name for name in created if "-6101-6102-" in name}
if created and not remove_smoke_recordings(ROOT, created):
print("WARNING: не удалось удалить временную WAV-запись WebRTC smoke", file=sys.stderr)
finally:
await cleanup_account(args.database_url, login)
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(
"--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())))