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

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)