2026-09-24 01:10:49 +03:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""Локальная браузерная проверка 20 занятий / 100 Chromium-контекстов."""
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
import json
|
|
|
|
|
|
import math
|
2026-09-26 18:12:27 +03:00
|
|
|
|
import secrets
|
|
|
|
|
|
import ssl
|
2026-09-24 01:10:49 +03:00
|
|
|
|
import statistics
|
2026-09-26 18:12:27 +03:00
|
|
|
|
import subprocess
|
|
|
|
|
|
import sys
|
2026-09-24 01:10:49 +03:00
|
|
|
|
import time
|
|
|
|
|
|
import urllib.parse
|
|
|
|
|
|
import urllib.request
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from uuid import UUID, uuid4
|
2026-09-24 01:10:49 +03:00
|
|
|
|
|
|
|
|
|
|
import websockets
|
|
|
|
|
|
from playwright.async_api import async_playwright
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
sys.path.insert(0, str(ROOT / "backend"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def create_distinct_users(
|
|
|
|
|
|
database_url: str, sessions: int, observers: int, *, create_controller: bool
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""Provision short-lived local accounts for a literal 100-user load test."""
|
|
|
|
|
|
from sqlalchemy.engine import make_url
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
|
|
|
|
|
|
|
|
from app.api.auth import hash_password
|
|
|
|
|
|
from app.db.models import Trainee, User
|
|
|
|
|
|
|
|
|
|
|
|
db_host = make_url(database_url).host
|
|
|
|
|
|
if db_host not in {"127.0.0.1", "localhost", "::1"}:
|
|
|
|
|
|
raise ValueError("--distinct-users разрешён только для loopback PostgreSQL")
|
|
|
|
|
|
|
|
|
|
|
|
suffix = uuid4().hex[:12]
|
|
|
|
|
|
engine = create_async_engine(database_url)
|
|
|
|
|
|
users: list[dict] = []
|
|
|
|
|
|
trainee_ids: list[str] = []
|
|
|
|
|
|
trainee_names: list[str] = []
|
|
|
|
|
|
controller = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
|
|
async with factory() as db:
|
|
|
|
|
|
if create_controller:
|
|
|
|
|
|
controller = {
|
|
|
|
|
|
"login": f"load-i-{suffix}",
|
|
|
|
|
|
"password": secrets.token_urlsafe(24),
|
|
|
|
|
|
}
|
|
|
|
|
|
db.add(User(
|
|
|
|
|
|
login=controller["login"],
|
|
|
|
|
|
full_name=f"Нагрузочный преподаватель {suffix}",
|
|
|
|
|
|
password_hash=hash_password(controller["password"]),
|
|
|
|
|
|
role="instructor",
|
|
|
|
|
|
blocked=False,
|
|
|
|
|
|
))
|
|
|
|
|
|
for index in range(sessions):
|
|
|
|
|
|
name = f"Нагрузочный курсант {suffix}-{index:02d}"
|
|
|
|
|
|
login = f"load-t-{suffix}-{index:02d}"
|
|
|
|
|
|
password = secrets.token_urlsafe(24)
|
|
|
|
|
|
trainee = Trainee(name=name)
|
|
|
|
|
|
db.add(trainee)
|
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
db.add(User(
|
|
|
|
|
|
login=login,
|
|
|
|
|
|
full_name=name,
|
|
|
|
|
|
password_hash=hash_password(password),
|
|
|
|
|
|
role="trainee",
|
|
|
|
|
|
trainee_id=trainee.id,
|
|
|
|
|
|
blocked=False,
|
|
|
|
|
|
))
|
|
|
|
|
|
users.append({"login": login, "password": password})
|
|
|
|
|
|
trainee_ids.append(str(trainee.id))
|
|
|
|
|
|
trainee_names.append(name)
|
|
|
|
|
|
for index in range(observers):
|
|
|
|
|
|
login = f"load-a-{suffix}-{index:03d}"
|
|
|
|
|
|
password = secrets.token_urlsafe(24)
|
|
|
|
|
|
db.add(User(
|
|
|
|
|
|
login=login,
|
|
|
|
|
|
full_name=f"Нагрузочный наблюдатель {suffix}-{index:03d}",
|
|
|
|
|
|
password_hash=hash_password(password),
|
|
|
|
|
|
role="admin",
|
|
|
|
|
|
blocked=False,
|
|
|
|
|
|
))
|
|
|
|
|
|
users.append({"login": login, "password": password})
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
raise
|
|
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
return {
|
|
|
|
|
|
"suffix": suffix,
|
|
|
|
|
|
"users": users,
|
|
|
|
|
|
"controller": controller,
|
|
|
|
|
|
"trainee_ids": trainee_ids,
|
|
|
|
|
|
"trainee_names": trainee_names,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def delete_distinct_users(database_url: str, fixtures: dict, session_ids: list[str]) -> None:
|
|
|
|
|
|
from sqlalchemy import delete
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
|
|
|
|
|
|
|
|
from app.db.models import AuditLog, Session, Trainee, User
|
|
|
|
|
|
|
|
|
|
|
|
engine = create_async_engine(database_url)
|
|
|
|
|
|
try:
|
|
|
|
|
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
|
|
async with factory() as db:
|
|
|
|
|
|
ids = [UUID(value) for value in session_ids]
|
|
|
|
|
|
logins = [item["login"] for item in fixtures["users"]]
|
|
|
|
|
|
if fixtures["controller"]:
|
|
|
|
|
|
logins.append(fixtures["controller"]["login"])
|
|
|
|
|
|
if ids:
|
|
|
|
|
|
await db.execute(delete(Session).where(Session.id.in_(ids)))
|
|
|
|
|
|
await db.execute(delete(AuditLog).where(
|
|
|
|
|
|
AuditLog.object_id.in_([str(value) for value in ids])
|
|
|
|
|
|
))
|
|
|
|
|
|
await db.execute(delete(AuditLog).where(AuditLog.actor.in_(logins)))
|
|
|
|
|
|
await db.execute(delete(User).where(User.login.in_(logins)))
|
|
|
|
|
|
await db.execute(delete(Trainee).where(
|
|
|
|
|
|
Trainee.name.in_(fixtures["trainee_names"])
|
|
|
|
|
|
))
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def session_node_distribution(database_url: str, session_ids: list[str]) -> dict[str, int]:
|
|
|
|
|
|
"""Read the durable node owner for this run's sessions before cleanup."""
|
|
|
|
|
|
from sqlalchemy import func, select
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
|
|
|
|
|
|
|
|
from app.db.models import Session
|
|
|
|
|
|
|
|
|
|
|
|
engine = create_async_engine(database_url)
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with async_sessionmaker(engine)() as db:
|
|
|
|
|
|
rows = (await db.execute(
|
|
|
|
|
|
select(Session.backend_node_id, func.count())
|
|
|
|
|
|
.where(Session.id.in_([UUID(value) for value in session_ids]))
|
|
|
|
|
|
.group_by(Session.backend_node_id)
|
|
|
|
|
|
)).all()
|
|
|
|
|
|
return {node or "<unassigned>": count for node, count in rows}
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def kill_cluster_service(project: str, service: str) -> None:
|
|
|
|
|
|
"""SIGKILL one backend only in an explicitly named disposable Compose project."""
|
|
|
|
|
|
if not project or not project.startswith("lct-"):
|
|
|
|
|
|
raise ValueError("fault injection requires an explicit lct-* disposable Compose project")
|
|
|
|
|
|
listing = subprocess.run(
|
|
|
|
|
|
["docker", "ps", "--filter", f"label=com.docker.compose.project={project}",
|
|
|
|
|
|
"--filter", f"label=com.docker.compose.service={service}", "--format", "{{.ID}}"],
|
|
|
|
|
|
check=False, capture_output=True, text=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
containers = [line.strip() for line in listing.stdout.splitlines() if line.strip()]
|
|
|
|
|
|
if listing.returncode or len(containers) != 1:
|
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
f"expected exactly one running {service} in disposable project {project}; "
|
|
|
|
|
|
f"found {len(containers)}"
|
|
|
|
|
|
)
|
|
|
|
|
|
completed = subprocess.run(
|
|
|
|
|
|
["docker", "kill", containers[0]], check=False, capture_output=True, text=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
if completed.returncode:
|
|
|
|
|
|
raise RuntimeError(f"Docker SIGKILL failed: {completed.stderr.strip()}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def wait_for_takeover(
|
|
|
|
|
|
database_url: str, session_ids: list[str], *, failed_node: str, timeout: float,
|
|
|
|
|
|
) -> tuple[dict[str, int], float]:
|
|
|
|
|
|
started = time.perf_counter()
|
|
|
|
|
|
deadline = started + timeout
|
|
|
|
|
|
distribution: dict[str, int] = {}
|
|
|
|
|
|
while time.perf_counter() < deadline:
|
|
|
|
|
|
distribution = await session_node_distribution(database_url, session_ids)
|
|
|
|
|
|
if sum(distribution.values()) == len(session_ids) and failed_node not in distribution:
|
|
|
|
|
|
return distribution, (time.perf_counter() - started) * 1000
|
|
|
|
|
|
await asyncio.sleep(0.5)
|
|
|
|
|
|
return distribution, (time.perf_counter() - started) * 1000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def login_distinct_users(base: str, users: list[dict]) -> list[str]:
|
|
|
|
|
|
semaphore = asyncio.Semaphore(8)
|
|
|
|
|
|
|
|
|
|
|
|
async def login(user: dict) -> str:
|
|
|
|
|
|
async with semaphore:
|
|
|
|
|
|
return await asyncio.to_thread(login_cookie, base, user["login"], user["password"])
|
|
|
|
|
|
|
|
|
|
|
|
return await asyncio.gather(*(login(user) for user in users))
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
async def control(
|
|
|
|
|
|
ws_base: str, session_id: str, cookie: str, payload: dict, *, ignore_https_errors: bool = False
|
|
|
|
|
|
) -> None:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
header_arg = ("additional_headers" if "additional_headers" in inspect.signature(
|
|
|
|
|
|
websockets.connect
|
|
|
|
|
|
).parameters else "extra_headers")
|
2026-09-26 18:12:27 +03:00
|
|
|
|
options = {header_arg: {"Cookie": cookie}, "open_timeout": 10, "ping_interval": None}
|
|
|
|
|
|
if ws_base.startswith("wss://") and ignore_https_errors:
|
|
|
|
|
|
options["ssl"] = ssl._create_unverified_context()
|
2026-09-24 01:10:49 +03:00
|
|
|
|
async with websockets.connect(
|
|
|
|
|
|
f"{ws_base}/ws/control/{session_id}",
|
2026-09-26 18:12:27 +03:00
|
|
|
|
**options,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
) as socket:
|
|
|
|
|
|
await socket.send(json.dumps(payload, ensure_ascii=False))
|
|
|
|
|
|
await asyncio.sleep(0.03)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
async def probe_call_ws(
|
|
|
|
|
|
ws_base: str, session_id: str, cookie: str, *, ignore_https_errors: bool,
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""Probe a new call-channel handshake without competing for Chromium sockets."""
|
|
|
|
|
|
header_arg = ("additional_headers" if "additional_headers" in inspect.signature(
|
|
|
|
|
|
websockets.connect
|
|
|
|
|
|
).parameters else "extra_headers")
|
|
|
|
|
|
options = {
|
|
|
|
|
|
header_arg: {"Cookie": cookie}, "open_timeout": 5, "ping_interval": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
if ws_base.startswith("wss://") and ignore_https_errors:
|
|
|
|
|
|
options["ssl"] = ssl._create_unverified_context()
|
|
|
|
|
|
started = time.perf_counter()
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with websockets.connect(f"{ws_base}/ws/call/{session_id}", **options):
|
|
|
|
|
|
return {"ok": True, "elapsed_ms": (time.perf_counter() - started) * 1000}
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"ok": False,
|
|
|
|
|
|
"elapsed_ms": (time.perf_counter() - started) * 1000,
|
|
|
|
|
|
"reason": f"{type(exc).__name__}: {str(exc)[:240]}",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def track_page_websockets(page, records: list[dict]) -> None:
|
|
|
|
|
|
"""Record only frame/close timestamps; never retain payloads or user data."""
|
|
|
|
|
|
def on_websocket(socket) -> None:
|
|
|
|
|
|
record = {
|
|
|
|
|
|
"url": socket.url,
|
|
|
|
|
|
"created_at": time.perf_counter(),
|
|
|
|
|
|
"frames": [],
|
|
|
|
|
|
"closed_at": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
records.append(record)
|
|
|
|
|
|
|
|
|
|
|
|
def on_frame(frame, item=record) -> None:
|
|
|
|
|
|
event_type = None
|
|
|
|
|
|
error_code = None
|
|
|
|
|
|
if isinstance(frame, str):
|
|
|
|
|
|
try:
|
|
|
|
|
|
value = json.loads(frame)
|
|
|
|
|
|
if isinstance(value, dict):
|
|
|
|
|
|
event_type = value.get("type")
|
|
|
|
|
|
error_code = value.get("code")
|
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
item["frames"].append({
|
|
|
|
|
|
"at": time.perf_counter(), "type": event_type, "code": error_code,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
socket.on("framereceived", on_frame)
|
|
|
|
|
|
socket.on("close", lambda _socket, item=record: item.update(
|
|
|
|
|
|
closed_at=time.perf_counter()
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
page.on("websocket", on_websocket)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def browser_channels_recovered(
|
|
|
|
|
|
records_by_page: list[list[dict]], expected_paths: list[str], after: float,
|
|
|
|
|
|
) -> tuple[list[int], list[float]]:
|
|
|
|
|
|
recovered: list[int] = []
|
|
|
|
|
|
first_frame_after: list[float] = []
|
|
|
|
|
|
for index, records in enumerate(records_by_page):
|
|
|
|
|
|
candidates = []
|
|
|
|
|
|
for record in records:
|
|
|
|
|
|
if expected_paths[index] not in record["url"]:
|
|
|
|
|
|
continue
|
|
|
|
|
|
post_failure_frames = [
|
|
|
|
|
|
frame["at"] for frame in record["frames"]
|
|
|
|
|
|
if frame["at"] >= after and frame["type"] is not None
|
|
|
|
|
|
and frame["type"] != "error" and frame["code"] is None
|
|
|
|
|
|
]
|
|
|
|
|
|
if (post_failure_frames
|
|
|
|
|
|
and (record["closed_at"] is None
|
|
|
|
|
|
or record["closed_at"] > post_failure_frames[-1])):
|
|
|
|
|
|
candidates.append(post_failure_frames[0])
|
|
|
|
|
|
if candidates:
|
|
|
|
|
|
recovered.append(index)
|
|
|
|
|
|
first_frame_after.append(min(candidates))
|
|
|
|
|
|
return recovered, first_frame_after
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def wait_for_browser_channels(
|
|
|
|
|
|
records_by_page: list[list[dict]], expected_paths: list[str], after: float, timeout: float,
|
|
|
|
|
|
) -> tuple[list[int], list[float]]:
|
|
|
|
|
|
deadline = time.perf_counter() + max(0, timeout)
|
|
|
|
|
|
while True:
|
|
|
|
|
|
recovered, first_frames = browser_channels_recovered(
|
|
|
|
|
|
records_by_page, expected_paths, after,
|
|
|
|
|
|
)
|
|
|
|
|
|
if len(recovered) == len(records_by_page) or time.perf_counter() >= deadline:
|
|
|
|
|
|
return recovered, first_frames
|
|
|
|
|
|
await asyncio.sleep(0.1)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
async def run(args: argparse.Namespace) -> int:
|
|
|
|
|
|
frontend = local_url(args.frontend_url, {"http", "https"})
|
|
|
|
|
|
backend = local_url(args.backend_url, {"http"})
|
2026-09-26 18:12:27 +03:00
|
|
|
|
control_origin = local_url(args.control_url or args.backend_url, {"http", "https"})
|
|
|
|
|
|
if bool(args.login) != bool(args.password):
|
|
|
|
|
|
raise ValueError("--login и --password задаются вместе")
|
|
|
|
|
|
if not args.distinct_users and not args.login:
|
|
|
|
|
|
raise ValueError("обычный режим требует --login и --password")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
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 занятий")
|
2026-09-26 18:12:27 +03:00
|
|
|
|
if args.distinct_users and (args.sessions != 20 or args.clients != 100):
|
|
|
|
|
|
raise ValueError("--distinct-users проверяет ровно 20 занятий и 100 отдельных пользователей")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if args.ramp_seconds < 0:
|
|
|
|
|
|
raise ValueError("ramp-seconds не может быть отрицательным")
|
2026-09-26 18:12:27 +03:00
|
|
|
|
if not 0 <= args.expect_backend_nodes <= args.sessions:
|
|
|
|
|
|
raise ValueError("expect-backend-nodes должен быть от 0 до числа занятий")
|
|
|
|
|
|
if args.kill_backend_b_after_pages:
|
|
|
|
|
|
if not args.distinct_users or args.sessions != 20 or args.clients != 100:
|
|
|
|
|
|
raise ValueError("cluster fault-injection requires --distinct-users with 20 sessions / 100 clients")
|
|
|
|
|
|
if not args.database_url or not args.compose_project:
|
|
|
|
|
|
raise ValueError("cluster fault-injection requires --database-url and --compose-project")
|
|
|
|
|
|
if args.expect_backend_nodes < 2:
|
|
|
|
|
|
raise ValueError("fault-injection requires --expect-backend-nodes 2")
|
|
|
|
|
|
if control_origin != frontend or not frontend.startswith("https://"):
|
|
|
|
|
|
raise ValueError("fault-injection requires TLS Nginx for both frontend and control-url")
|
|
|
|
|
|
if args.failure_timeout <= 0:
|
|
|
|
|
|
raise ValueError("failure-timeout must be positive")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
control_ws_base = control_origin.replace("https://", "wss://", 1).replace("http://", "ws://", 1)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
sessions = [str(uuid4()) for _ in range(args.sessions)]
|
|
|
|
|
|
started: list[str] = []
|
2026-09-26 18:12:27 +03:00
|
|
|
|
fixtures = None
|
|
|
|
|
|
teacher_cookie = None
|
2026-09-24 01:10:49 +03:00
|
|
|
|
try:
|
2026-09-26 18:12:27 +03:00
|
|
|
|
station_cookies: list[str] = []
|
|
|
|
|
|
observer_cookies: list[str] = []
|
|
|
|
|
|
if args.distinct_users:
|
|
|
|
|
|
if not args.database_url:
|
|
|
|
|
|
raise ValueError("--distinct-users требует --database-url")
|
|
|
|
|
|
fixtures = await create_distinct_users(
|
|
|
|
|
|
args.database_url,
|
|
|
|
|
|
args.sessions,
|
|
|
|
|
|
args.clients - args.sessions,
|
|
|
|
|
|
create_controller=not bool(args.login),
|
|
|
|
|
|
)
|
|
|
|
|
|
controller = fixtures["controller"]
|
|
|
|
|
|
teacher_cookie = (
|
|
|
|
|
|
login_cookie(backend, controller["login"], controller["password"])
|
|
|
|
|
|
if controller
|
|
|
|
|
|
else login_cookie(backend, args.login, args.password)
|
|
|
|
|
|
)
|
|
|
|
|
|
account_cookies = await login_distinct_users(backend, fixtures["users"])
|
|
|
|
|
|
station_cookies = account_cookies[:args.sessions]
|
|
|
|
|
|
observer_cookies = account_cookies[args.sessions:]
|
|
|
|
|
|
else:
|
|
|
|
|
|
if not args.login or not args.password:
|
|
|
|
|
|
raise ValueError("обычный режим требует --login и --password")
|
|
|
|
|
|
teacher_cookie = login_cookie(backend, args.login, args.password)
|
|
|
|
|
|
|
|
|
|
|
|
# Include partially started sessions in cleanup if one concurrent
|
|
|
|
|
|
# control connection fails midway through the preparation batch.
|
|
|
|
|
|
started.extend(sessions)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
await asyncio.gather(*(
|
2026-09-26 18:12:27 +03:00
|
|
|
|
control(control_ws_base, session_id, teacher_cookie, {
|
2026-09-24 01:10:49 +03:00
|
|
|
|
"type": "scenario.start", "scenario_id": args.scenario,
|
2026-09-26 18:12:27 +03:00
|
|
|
|
"trainee": (
|
|
|
|
|
|
fixtures["trainee_names"][index] if fixtures
|
|
|
|
|
|
else f"browser-load-{index:02d}"
|
|
|
|
|
|
),
|
|
|
|
|
|
"trainee_id": fixtures["trainee_ids"][index] if fixtures else None,
|
|
|
|
|
|
"mode": "training", "exercise": "dds",
|
|
|
|
|
|
}, ignore_https_errors=args.ignore_https_errors)
|
|
|
|
|
|
for index, session_id in enumerate(sessions)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
timings: list[float] = []
|
|
|
|
|
|
dds_timings: list[float] = []
|
|
|
|
|
|
observer_timings: list[float] = []
|
|
|
|
|
|
browser_navigation: list[dict] = []
|
2026-09-26 18:12:27 +03:00
|
|
|
|
page_readiness_profiles: list[dict] = []
|
2026-09-24 01:10:49 +03:00
|
|
|
|
errors: list[str] = []
|
|
|
|
|
|
pages = []
|
|
|
|
|
|
contexts = []
|
|
|
|
|
|
per_session = args.clients // args.sessions
|
2026-09-26 18:12:27 +03:00
|
|
|
|
observer_index = 0
|
|
|
|
|
|
failure_injection = None
|
|
|
|
|
|
pre_failure_distribution: dict[str, int] = {}
|
|
|
|
|
|
websocket_records: list[list[dict]] = [[] for _ in range(args.clients)]
|
|
|
|
|
|
expected_ws_paths: list[str] = []
|
2026-09-24 01:10:49 +03:00
|
|
|
|
async with async_playwright() as playwright:
|
|
|
|
|
|
browser = await playwright.chromium.launch(headless=True)
|
|
|
|
|
|
for index in range(args.clients):
|
2026-09-26 18:12:27 +03:00
|
|
|
|
session_index = index // per_session
|
|
|
|
|
|
role_index = index % per_session
|
|
|
|
|
|
if fixtures:
|
|
|
|
|
|
account_cookie = (
|
|
|
|
|
|
station_cookies[session_index]
|
|
|
|
|
|
if role_index == 0
|
|
|
|
|
|
else observer_cookies[observer_index]
|
|
|
|
|
|
)
|
|
|
|
|
|
if role_index != 0:
|
|
|
|
|
|
observer_index += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
account_cookie = teacher_cookie
|
|
|
|
|
|
cookie_name, cookie_value = account_cookie.split("=", 1)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
context = await browser.new_context(
|
|
|
|
|
|
viewport={"width": 1280, "height": 720},
|
|
|
|
|
|
ignore_https_errors=args.ignore_https_errors,
|
|
|
|
|
|
)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
await context.add_init_script("""
|
|
|
|
|
|
window.__lctLongTasks = [];
|
|
|
|
|
|
if (typeof PerformanceObserver !== "undefined") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
new PerformanceObserver((list) => {
|
|
|
|
|
|
for (const item of list.getEntries()) {
|
|
|
|
|
|
window.__lctLongTasks.push({ start: item.startTime, duration: item.duration });
|
|
|
|
|
|
}
|
|
|
|
|
|
}).observe({ type: "longtask", buffered: true });
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
""")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
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}"))
|
2026-09-26 18:12:27 +03:00
|
|
|
|
session_index = index // per_session
|
|
|
|
|
|
role_index = index % per_session
|
|
|
|
|
|
session_id = sessions[session_index]
|
|
|
|
|
|
expected_ws_paths.append(
|
|
|
|
|
|
f"/ws/station/{session_id}" if role_index == 0
|
|
|
|
|
|
else f"/ws/observe/{session_id}"
|
|
|
|
|
|
)
|
|
|
|
|
|
track_page_websockets(page, websocket_records[index])
|
2026-09-24 01:10:49 +03:00
|
|
|
|
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 'нет ответа'}")
|
2026-09-26 18:12:27 +03:00
|
|
|
|
document_ready_ms = (time.perf_counter() - started_at) * 1000
|
2026-09-24 01:10:49 +03:00
|
|
|
|
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)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
profile = await pages[index].evaluate("""() => {
|
|
|
|
|
|
const resources = performance.getEntriesByType("resource")
|
|
|
|
|
|
.map((item) => {
|
|
|
|
|
|
const path = new URL(item.name).pathname.replace(
|
|
|
|
|
|
/[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}/gi, ":id"
|
|
|
|
|
|
);
|
|
|
|
|
|
return {
|
|
|
|
|
|
path,
|
|
|
|
|
|
initiator: item.initiatorType,
|
|
|
|
|
|
duration_ms: item.duration,
|
|
|
|
|
|
transfer_bytes: item.transferSize
|
|
|
|
|
|
};
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter((item) => item.path.startsWith("/assets/")
|
|
|
|
|
|
|| item.path.startsWith("/api/"))
|
|
|
|
|
|
.sort((left, right) => right.duration_ms - left.duration_ms)
|
|
|
|
|
|
.slice(0, 30);
|
|
|
|
|
|
const tasks = window.__lctLongTasks ?? [];
|
|
|
|
|
|
return {
|
|
|
|
|
|
long_tasks: tasks.map((item) => ({
|
|
|
|
|
|
start_ms: item.start, duration_ms: item.duration
|
|
|
|
|
|
})),
|
|
|
|
|
|
resources
|
|
|
|
|
|
};
|
|
|
|
|
|
}""")
|
|
|
|
|
|
profile["websockets"] = []
|
|
|
|
|
|
for socket in websocket_records[index]:
|
|
|
|
|
|
socket_path = urllib.parse.urlsplit(socket["url"]).path
|
|
|
|
|
|
if socket_path.startswith("/ws/station/"):
|
|
|
|
|
|
socket_path = "/ws/station/:id"
|
|
|
|
|
|
elif socket_path.startswith("/ws/observe/"):
|
|
|
|
|
|
socket_path = "/ws/observe/:id"
|
|
|
|
|
|
elif socket_path.startswith("/ws/control/"):
|
|
|
|
|
|
socket_path = "/ws/control/:id"
|
|
|
|
|
|
frame_types = [frame["type"] for frame in socket["frames"]]
|
|
|
|
|
|
first_state = next((
|
|
|
|
|
|
frame for frame in socket["frames"]
|
|
|
|
|
|
if frame["type"] in {
|
|
|
|
|
|
"station.state", "observe.state", "session.snapshot",
|
|
|
|
|
|
"card.received",
|
|
|
|
|
|
}
|
|
|
|
|
|
), None)
|
|
|
|
|
|
profile["websockets"].append({
|
|
|
|
|
|
"path": socket_path,
|
|
|
|
|
|
"created_after_navigation_ms": round(
|
|
|
|
|
|
(socket["created_at"] - started_at) * 1000, 2
|
|
|
|
|
|
),
|
|
|
|
|
|
"first_frame_after_navigation_ms": round(
|
|
|
|
|
|
(socket["frames"][0]["at"] - started_at) * 1000, 2
|
|
|
|
|
|
) if socket["frames"] else None,
|
|
|
|
|
|
"first_state_after_navigation_ms": round(
|
|
|
|
|
|
(first_state["at"] - started_at) * 1000, 2
|
|
|
|
|
|
) if first_state else None,
|
|
|
|
|
|
"first_frame_type": frame_types[0] if frame_types else None,
|
|
|
|
|
|
})
|
|
|
|
|
|
page_readiness_profiles.append({
|
|
|
|
|
|
"page_index": index,
|
|
|
|
|
|
"role": "dds" if role_index == 0 else "wall",
|
|
|
|
|
|
"navigation_to_domcontentloaded_ms": round(document_ready_ms, 2),
|
|
|
|
|
|
"domcontentloaded_to_ready_ms": round(ready_ms - document_ready_ms, 2),
|
|
|
|
|
|
**profile,
|
|
|
|
|
|
})
|
2026-09-24 01:10:49 +03:00
|
|
|
|
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)))
|
2026-09-26 18:12:27 +03:00
|
|
|
|
|
|
|
|
|
|
session_api_ok = 0
|
|
|
|
|
|
call_ws_ok = 0
|
|
|
|
|
|
route_errors: list[str] = []
|
|
|
|
|
|
if args.kill_backend_b_after_pages:
|
|
|
|
|
|
if len(timings) != args.clients or errors:
|
|
|
|
|
|
raise RuntimeError("all 100 pages must be ready before failure injection")
|
|
|
|
|
|
initial_ws_connected = sum(
|
|
|
|
|
|
any(
|
|
|
|
|
|
expected_ws_paths[index] in record["url"]
|
|
|
|
|
|
and any(
|
|
|
|
|
|
frame["type"] is not None
|
|
|
|
|
|
and frame["type"] != "error" and frame["code"] is None
|
|
|
|
|
|
for frame in record["frames"]
|
|
|
|
|
|
)
|
|
|
|
|
|
for record in records
|
|
|
|
|
|
)
|
|
|
|
|
|
for index, records in enumerate(websocket_records)
|
|
|
|
|
|
)
|
|
|
|
|
|
if initial_ws_connected != args.clients:
|
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
f"only {initial_ws_connected}/{args.clients} pages received their initial WS frame"
|
|
|
|
|
|
)
|
|
|
|
|
|
pre_failure_distribution = await session_node_distribution(
|
|
|
|
|
|
args.database_url, sessions,
|
|
|
|
|
|
)
|
|
|
|
|
|
if set(pre_failure_distribution) != {"backend-a", "backend-b"}:
|
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
"pre-failure sessions must be owned by both backend-a and backend-b"
|
|
|
|
|
|
)
|
|
|
|
|
|
failure_started = time.perf_counter()
|
|
|
|
|
|
kill_cluster_service(args.compose_project, "backend-b")
|
|
|
|
|
|
post_failure_distribution, takeover_ms = await wait_for_takeover(
|
|
|
|
|
|
args.database_url, sessions, failed_node="backend-b",
|
|
|
|
|
|
timeout=args.failure_timeout,
|
|
|
|
|
|
)
|
|
|
|
|
|
recovery_budget = max(
|
|
|
|
|
|
0.0,
|
|
|
|
|
|
min(30.0, args.failure_timeout) - (time.perf_counter() - failure_started),
|
|
|
|
|
|
)
|
|
|
|
|
|
recovered_pages, first_frames = await wait_for_browser_channels(
|
|
|
|
|
|
websocket_records, expected_ws_paths, failure_started, recovery_budget,
|
|
|
|
|
|
)
|
|
|
|
|
|
failure_injection = {
|
|
|
|
|
|
"action": "SIGKILL backend-b after all pages became ready",
|
|
|
|
|
|
"compose_project": args.compose_project,
|
|
|
|
|
|
"owner_distribution_before": pre_failure_distribution,
|
|
|
|
|
|
"owner_distribution_after": post_failure_distribution,
|
|
|
|
|
|
"takeover_ms": round(takeover_ms, 2),
|
|
|
|
|
|
"concurrent_pages_kept_open_during_takeover": args.clients,
|
|
|
|
|
|
"takeover_passed": (
|
|
|
|
|
|
sum(post_failure_distribution.values()) == args.sessions
|
|
|
|
|
|
and post_failure_distribution.get("backend-a") == args.sessions
|
|
|
|
|
|
and "backend-b" not in post_failure_distribution
|
|
|
|
|
|
),
|
|
|
|
|
|
"browser_ws_channel_pages_recovered": len(recovered_pages),
|
|
|
|
|
|
"browser_ws_channel_pages_expected": args.clients,
|
|
|
|
|
|
"browser_ws_initial_pages_connected": initial_ws_connected,
|
|
|
|
|
|
"browser_ws_unrecovered_pages": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"page_index": index,
|
|
|
|
|
|
"channel": expected_ws_paths[index],
|
|
|
|
|
|
"session_id": sessions[index // per_session],
|
|
|
|
|
|
}
|
|
|
|
|
|
for index in range(args.clients) if index not in recovered_pages
|
|
|
|
|
|
],
|
|
|
|
|
|
"browser_ws_channels_recovered_within_30s": (
|
|
|
|
|
|
len(recovered_pages) == args.clients
|
|
|
|
|
|
and bool(first_frames)
|
|
|
|
|
|
and max(first_frames) - failure_started <= 30.0
|
|
|
|
|
|
),
|
|
|
|
|
|
"browser_ws_recovery_ms": (
|
|
|
|
|
|
round((max(first_frames) - failure_started) * 1000, 2)
|
|
|
|
|
|
if len(recovered_pages) == args.clients else None
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
failure_injection["elapsed_since_kill_ms"] = round(
|
|
|
|
|
|
(time.perf_counter() - failure_started) * 1000, 2
|
|
|
|
|
|
)
|
|
|
|
|
|
mounted = await asyncio.gather(*(
|
|
|
|
|
|
page.evaluate("Boolean(document.querySelector('#root')?.firstElementChild)")
|
|
|
|
|
|
for page in pages
|
|
|
|
|
|
))
|
|
|
|
|
|
failure_injection["react_pages_still_mounted"] = sum(bool(item) for item in mounted)
|
|
|
|
|
|
|
|
|
|
|
|
async def probe_session_api(session_index: int) -> dict:
|
|
|
|
|
|
page = pages[session_index * per_session]
|
|
|
|
|
|
return await page.evaluate("""async (sessionId) => {
|
|
|
|
|
|
const started = performance.now();
|
|
|
|
|
|
const response = await fetch(`/api/sessions/${sessionId}`, {
|
|
|
|
|
|
credentials: "same-origin"
|
|
|
|
|
|
});
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: response.status === 200,
|
|
|
|
|
|
status: response.status,
|
|
|
|
|
|
elapsed_ms: performance.now() - started
|
|
|
|
|
|
};
|
|
|
|
|
|
}""", sessions[session_index])
|
|
|
|
|
|
|
|
|
|
|
|
api_probes = await asyncio.gather(*(
|
|
|
|
|
|
probe_session_api(session_index) for session_index in range(args.sessions)
|
|
|
|
|
|
))
|
|
|
|
|
|
ws_probe_cookies = (
|
|
|
|
|
|
station_cookies if fixtures else [teacher_cookie] * args.sessions
|
|
|
|
|
|
)
|
|
|
|
|
|
ws_probes = await asyncio.gather(*(
|
|
|
|
|
|
probe_call_ws(
|
|
|
|
|
|
control_ws_base, sessions[index], ws_probe_cookies[index],
|
|
|
|
|
|
ignore_https_errors=args.ignore_https_errors,
|
|
|
|
|
|
)
|
|
|
|
|
|
for index in range(args.sessions)
|
|
|
|
|
|
))
|
|
|
|
|
|
if failure_injection:
|
|
|
|
|
|
failure_injection["route_probe_ms"] = {
|
|
|
|
|
|
"session_api_max": round(max(item["elapsed_ms"] for item in api_probes), 2),
|
|
|
|
|
|
"call_ws_max": round(max(item["elapsed_ms"] for item in ws_probes), 2),
|
|
|
|
|
|
"call_ws_probe_client": "Python websockets; Chromium pages remain open",
|
|
|
|
|
|
}
|
|
|
|
|
|
for session_index, (api_probe, ws_probe) in enumerate(zip(api_probes, ws_probes)):
|
|
|
|
|
|
if api_probe["ok"]:
|
|
|
|
|
|
session_api_ok += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
route_errors.append(
|
|
|
|
|
|
f"session {sessions[session_index]}: GET session returned {api_probe['status']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
if ws_probe["ok"]:
|
|
|
|
|
|
call_ws_ok += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
route_errors.append(
|
|
|
|
|
|
f"session {sessions[session_index]}: /ws/call route failed: {ws_probe}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
await asyncio.sleep(args.hold_seconds)
|
|
|
|
|
|
await asyncio.gather(*(context.close() for context in contexts))
|
|
|
|
|
|
await browser.close()
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
node_distribution = (
|
|
|
|
|
|
await session_node_distribution(args.database_url, sessions)
|
|
|
|
|
|
if args.database_url else {}
|
|
|
|
|
|
)
|
|
|
|
|
|
nodes_for_expectation = pre_failure_distribution if failure_injection else node_distribution
|
|
|
|
|
|
expected_nodes_ready = (
|
|
|
|
|
|
len(nodes_for_expectation) >= args.expect_backend_nodes
|
|
|
|
|
|
if args.expect_backend_nodes else True
|
|
|
|
|
|
)
|
|
|
|
|
|
if failure_injection:
|
|
|
|
|
|
failure_injection["session_rest_routes_recovered"] = session_api_ok == args.sessions
|
|
|
|
|
|
failure_injection["call_ws_routes_recovered"] = call_ws_ok == args.sessions
|
|
|
|
|
|
failure_injection["route_errors_after_takeover"] = list(route_errors)
|
|
|
|
|
|
failure_injection["all_probes_recovered"] = (
|
|
|
|
|
|
session_api_ok == args.sessions and call_ws_ok == args.sessions
|
|
|
|
|
|
and not route_errors
|
|
|
|
|
|
)
|
|
|
|
|
|
failure_injection["pass_20_session_takeover"] = (
|
|
|
|
|
|
failure_injection["takeover_passed"]
|
|
|
|
|
|
and failure_injection["all_probes_recovered"]
|
|
|
|
|
|
and failure_injection["browser_ws_channels_recovered_within_30s"]
|
|
|
|
|
|
and failure_injection["react_pages_still_mounted"] == args.clients
|
|
|
|
|
|
)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
result = {
|
2026-09-26 18:12:27 +03:00
|
|
|
|
"frontend": frontend, "backend": backend, "control": control_origin,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
"sessions_started": len(sessions), "browser_contexts": args.clients,
|
|
|
|
|
|
"dds_pages": args.sessions, "observer_pages": args.clients - args.sessions,
|
2026-09-26 18:12:27 +03:00
|
|
|
|
"distinct_authenticated_users": (
|
|
|
|
|
|
args.clients if fixtures else 1
|
|
|
|
|
|
),
|
|
|
|
|
|
"distinct_users_mode": bool(fixtures),
|
|
|
|
|
|
"backend_node_distribution": node_distribution,
|
|
|
|
|
|
"backend_node_count": len(node_distribution),
|
|
|
|
|
|
"backend_node_distribution_before_failure": (
|
|
|
|
|
|
pre_failure_distribution if failure_injection else None
|
|
|
|
|
|
),
|
|
|
|
|
|
"failure_injection": failure_injection,
|
|
|
|
|
|
"session_rest_routes_ok": session_api_ok,
|
|
|
|
|
|
"call_ws_routes_ok": call_ws_ok,
|
|
|
|
|
|
"route_errors": route_errors,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
"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),
|
|
|
|
|
|
},
|
2026-09-26 18:12:27 +03:00
|
|
|
|
"page_readiness_profiles": page_readiness_profiles,
|
|
|
|
|
|
"pass_wall_report_deferred": not any(
|
|
|
|
|
|
path == "/api/sessions/:id/report"
|
|
|
|
|
|
or path.startswith("/assets/Debrief-")
|
|
|
|
|
|
or path.startswith("/assets/Radar-")
|
|
|
|
|
|
for profile in page_readiness_profiles if profile.get("role") == "wall"
|
|
|
|
|
|
for path in (resource.get("path", "") for resource in profile.get("resources", []))
|
|
|
|
|
|
),
|
2026-09-24 01:10:49 +03:00
|
|
|
|
"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": (
|
2026-09-26 18:12:27 +03:00
|
|
|
|
len(timings) == args.clients
|
|
|
|
|
|
and not errors
|
|
|
|
|
|
and not route_errors
|
|
|
|
|
|
and session_api_ok == args.sessions
|
|
|
|
|
|
and call_ws_ok == args.sessions
|
|
|
|
|
|
and percentile(timings, 95) <= 2000
|
|
|
|
|
|
and len(dds_timings) == args.sessions
|
|
|
|
|
|
and len(observer_timings) == args.clients - args.sessions
|
|
|
|
|
|
and percentile(dds_timings, 95) <= 2000
|
|
|
|
|
|
and percentile(observer_timings, 95) <= 2000
|
|
|
|
|
|
and expected_nodes_ready
|
2026-09-24 01:10:49 +03:00
|
|
|
|
),
|
2026-09-26 18:12:27 +03:00
|
|
|
|
"pass_failover_under_20x100": bool(
|
|
|
|
|
|
failure_injection and failure_injection["pass_20_session_takeover"]
|
|
|
|
|
|
) if args.kill_backend_b_after_pages else None,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
"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; "
|
2026-09-26 18:12:27 +03:00
|
|
|
|
"all contexts remain active concurrently; "
|
|
|
|
|
|
+ ("unique trainee/admin accounts" if fixtures else "same instructor account")
|
|
|
|
|
|
+ ", local host, no VoIP, "
|
2026-09-24 01:10:49 +03:00
|
|
|
|
"not target 6-core/32-GB server"
|
2026-09-26 18:12:27 +03:00
|
|
|
|
+ (f"; at least {args.expect_backend_nodes} backend nodes required"
|
|
|
|
|
|
if args.expect_backend_nodes else "")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
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")
|
2026-09-26 18:12:27 +03:00
|
|
|
|
passed = result["pass_browser_ui_2s"] and (
|
|
|
|
|
|
result["pass_wall_report_deferred"]
|
|
|
|
|
|
and result["pass_failover_under_20x100"] is not False
|
|
|
|
|
|
)
|
|
|
|
|
|
return 0 if passed else 1
|
2026-09-24 01:10:49 +03:00
|
|
|
|
finally:
|
2026-09-26 18:12:27 +03:00
|
|
|
|
try:
|
|
|
|
|
|
if teacher_cookie and started:
|
|
|
|
|
|
await asyncio.gather(*(
|
|
|
|
|
|
control(control_ws_base, session_id, teacher_cookie, {"type": "session.stop"},
|
|
|
|
|
|
ignore_https_errors=args.ignore_https_errors)
|
|
|
|
|
|
for session_id in started
|
|
|
|
|
|
), return_exceptions=True)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
if fixtures:
|
|
|
|
|
|
await delete_distinct_users(args.database_url, fixtures, started)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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")
|
2026-09-26 18:12:27 +03:00
|
|
|
|
parser.add_argument("--control-url",
|
|
|
|
|
|
help="URL для управляющего WS; задайте frontend URL для проверки proxy-affinity")
|
|
|
|
|
|
parser.add_argument("--login", help="преподаватель; в --distinct-users создаётся временный")
|
|
|
|
|
|
parser.add_argument("--password", help="пароль преподавателя, если задан --login")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
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="равномерно распределить начало загрузок; все страницы остаются активны")
|
2026-09-26 18:12:27 +03:00
|
|
|
|
parser.add_argument("--distinct-users", action="store_true",
|
|
|
|
|
|
help="создать/удалить временные учётки и проверить разные логины")
|
|
|
|
|
|
parser.add_argument("--database-url",
|
|
|
|
|
|
help="PostgreSQL URL, обязателен для --distinct-users; только тестовая БД")
|
|
|
|
|
|
parser.add_argument("--expect-backend-nodes", type=int, default=0,
|
|
|
|
|
|
help="проверить, что занятия распределены минимум по N backend-узлам")
|
|
|
|
|
|
parser.add_argument("--kill-backend-b-after-pages", action="store_true",
|
|
|
|
|
|
help="SIGKILL backend-b after all 100 pages load; isolated cluster only")
|
|
|
|
|
|
parser.add_argument("--compose-project",
|
|
|
|
|
|
help="required unique lct-* project for --kill-backend-b-after-pages")
|
|
|
|
|
|
parser.add_argument("--failure-timeout", type=float, default=45,
|
|
|
|
|
|
help="maximum seconds to wait for leases/checkpoints to transfer")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
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)
|