226 lines
11 KiB
Python
226 lines
11 KiB
Python
|
|
#!/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)
|