Fix load test

This commit is contained in:
Mawoka
2026-07-21 13:59:56 +02:00
parent 6d805a4fb8
commit 4d1f1d3165
+138 -47
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Marlon W (Mawoka)
#
# SPDX-License-Identifier: MPL-2.0
""" """
Quiz App Load Tester (Playwright) Quiz App Load Tester (Playwright)
@@ -24,11 +27,10 @@ Tips:
- For heavy loads, consider --ramp-up 20 to spread start times. - For heavy loads, consider --ramp-up 20 to spread start times.
""" """
import asyncio
import argparse import argparse
import asyncio
import json import json
import math import math
import os
import random import random
import sys import sys
import time import time
@@ -36,10 +38,10 @@ from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
from playwright.async_api import async_playwright, Browser, Page, Request from playwright.async_api import Browser, Page, Request, async_playwright
# ---------- Config defaults ---------- # ---------- Config defaults ----------
DEFAULT_BASE = "http://localhost:8000" DEFAULT_BASE = "http://localhost:8080"
DEFAULT_PATH = "/play" DEFAULT_PATH = "/play"
# ---------- Data structures ---------- # ---------- Data structures ----------
@@ -101,7 +103,17 @@ class Aggregator:
# PAGE LOADS # PAGE LOADS
by_page = group_by(page_loads, lambda m: m.path or urlparse(m.url).path) by_page = group_by(page_loads, lambda m: m.path or urlparse(m.url).path)
page_lines = [] page_lines = []
header = ["Page", "Count", "Avg ms", "p50", "p90", "p99", "Min", "Max", "Errors"] header = [
"Page",
"Count",
"Avg ms",
"p50",
"p90",
"p99",
"Min",
"Max",
"Errors",
]
page_lines.append(" | ".join(header)) page_lines.append(" | ".join(header))
page_lines.append("-" * (len(" | ".join(header)) + 4)) page_lines.append("-" * (len(" | ".join(header)) + 4))
for path, items in sorted(by_page.items(), key=lambda kv: kv[0]): for path, items in sorted(by_page.items(), key=lambda kv: kv[0]):
@@ -124,7 +136,7 @@ class Aggregator:
self._fmt_ms(p99), self._fmt_ms(p99),
self._fmt_ms(mn), self._fmt_ms(mn),
self._fmt_ms(mx), self._fmt_ms(mx),
f"{errs}" f"{errs}",
] ]
) )
) )
@@ -133,7 +145,17 @@ class Aggregator:
# REST CALLS # REST CALLS
by_endpoint = group_by(rest_calls, lambda m: f"{m.method} {m.path or urlparse(m.url).path}") by_endpoint = group_by(rest_calls, lambda m: f"{m.method} {m.path or urlparse(m.url).path}")
rest_lines = [] rest_lines = []
header_r = ["Endpoint (method path)", "Count", "Avg ms", "p50", "p90", "p99", "Min", "Max", "Err%"] header_r = [
"Endpoint (method path)",
"Count",
"Avg ms",
"p50",
"p90",
"p99",
"Min",
"Max",
"Err%",
]
rest_lines.append(" | ".join(header_r)) rest_lines.append(" | ".join(header_r))
rest_lines.append("-" * (len(" | ".join(header_r)) + 4)) rest_lines.append("-" * (len(" | ".join(header_r)) + 4))
for ep, items in sorted(by_endpoint.items(), key=lambda kv: kv[0]): for ep, items in sorted(by_endpoint.items(), key=lambda kv: kv[0]):
@@ -157,7 +179,7 @@ class Aggregator:
self._fmt_ms(p99), self._fmt_ms(p99),
self._fmt_ms(mn), self._fmt_ms(mn),
self._fmt_ms(mx), self._fmt_ms(mx),
f"{err_pct:.1f}%" f"{err_pct:.1f}%",
] ]
) )
) )
@@ -165,6 +187,7 @@ class Aggregator:
return page_table, rest_table return page_table, rest_table
# ---------- Client logic ---------- # ---------- Client logic ----------
@@ -278,14 +301,8 @@ async def run_client(
) )
page.on("request", on_request) page.on("request", on_request)
page.on( page.on("requestfinished", lambda req: asyncio.create_task(finalize_metric(req, True)))
"requestfinished", page.on("requestfailed", lambda req: asyncio.create_task(finalize_metric(req, False)))
lambda req: asyncio.create_task(finalize_metric(req, True))
)
page.on(
"requestfailed",
lambda req: asyncio.create_task(finalize_metric(req, False))
)
# ---- Flow ---- # ---- Flow ----
url = f"{base_url.rstrip('/')}{start_path}" url = f"{base_url.rstrip('/')}{start_path}"
@@ -332,31 +349,23 @@ async def run_client(
) )
await context.close() await context.close()
return return
# Step 2: Enter key and submit # Step 2: Enter key and submit
try: try:
# Fill any visible text input (assumed "key") # Fill any visible text input (assumed "key")
await page.wait_for_selector('input[type="text"]:visible', timeout=timeouts["ui"]) await page.wait_for_selector('input[inputmode="numeric"]:visible', timeout=timeouts["ui"])
key_input = page.locator('input[type="text"]:visible').first key_input = page.locator('input[inputmode="numeric"]:visible').first
await key_input.fill(key_value) await key_input.fill(key_value)
# Prefer clicking a button with "Submit", else press Enter
submit_btn = page.locator('button:has-text("Submit")')
if await submit_btn.count() > 0:
await submit_btn.first.click()
else:
await key_input.press("Enter")
except Exception: except Exception:
# If this fails, client can't proceed # If this fails, client can't proceed
await context.close() await context.close()
return return
# Step 3: Wait for name field and click "Abschicken" # Step 3: Wait for name field and click "Abschicken"
try: try:
await page.wait_for_selector('button:has-text("Abschicken")', state="visible", timeout=timeouts["ui"]) await page.wait_for_selector('button:has-text("Submit")', state="visible", timeout=timeouts["ui"])
name_btn = page.locator('button:has-text("Abschicken")').first name_btn = page.locator('button:has-text("Submit")').first
# Fill the visible text input again (assumed "name") # Fill the visible text input again (assumed "name")
await page.wait_for_selector('input[type="text"]:visible', timeout=timeouts["ui"]) await page.wait_for_selector('input[maxlength="17"]:visible', timeout=timeouts["ui"])
name_input = page.locator('input[type="text"]:visible').first name_input = page.locator('input[maxlength="17"]:visible').first
await name_input.fill(f"client-{client_id}") await name_input.fill(f"client-{client_id}")
await name_btn.click() await name_btn.click()
except Exception: except Exception:
@@ -371,6 +380,7 @@ async def run_client(
try: try:
await wait_for_game_ui(page, timeout_ms=timeouts["game"]) await wait_for_game_ui(page, timeout_ms=timeouts["game"])
except Exception: except Exception:
await context.close() await context.close()
return return
@@ -392,6 +402,7 @@ async def run_client(
finally: finally:
await context.close() await context.close()
# ---------- Main / CLI ---------- # ---------- Main / CLI ----------
@@ -400,19 +411,71 @@ def parse_args() -> argparse.Namespace:
p.add_argument("--base", default=DEFAULT_BASE, help="Base URL, default http://localhost:8000") p.add_argument("--base", default=DEFAULT_BASE, help="Base URL, default http://localhost:8000")
p.add_argument("--path", default=DEFAULT_PATH, help="Start path, default /play") p.add_argument("--path", default=DEFAULT_PATH, help="Start path, default /play")
p.add_argument("--key", required=True, help="Quiz key to enter at first input") p.add_argument("--key", required=True, help="Quiz key to enter at first input")
p.add_argument("--clients", type=int, default=200, help="Number of concurrent clients (default 200)") p.add_argument(
p.add_argument("--rounds", type=int, default=15, help="Max questions to answer per client (0 = unlimited)") "--clients",
p.add_argument("--duration", type=int, default=None, type=int,
help="Max answering time per client in seconds (overrides rounds if provided)") default=200,
p.add_argument("--headful", action="store_true", help="Run headed (useful for debugging small client counts)") help="Number of concurrent clients (default 200)",
p.add_argument("--ramp-up", type=float, default=10.0, help="Seconds to spread client starts over (default 10s)") )
p.add_argument("--min-think", type=int, default=200, help="Min think time between answers in ms (default 200)") p.add_argument(
p.add_argument("--max-think", type=int, default=1000, help="Max think time between answers in ms (default 1200)") "--rounds",
p.add_argument("--goto-timeout", type=int, default=30000, help="Timeout for page.goto in ms (default 15000)") type=int,
p.add_argument("--ui-timeout", type=int, default=30000, help="Timeout for waiting UI elements in ms (default 15000)") default=15,
p.add_argument("--game-timeout", type=int, default=30000, help="Timeout for waiting game UI in ms (default 30000)") help="Max questions to answer per client (0 = unlimited)",
p.add_argument("--question-timeout", type=int, default=20000, )
help="Timeout around a question cycle in ms (default 20000)") p.add_argument(
"--duration",
type=int,
default=None,
help="Max answering time per client in seconds (overrides rounds if provided)",
)
p.add_argument(
"--headful",
action="store_true",
help="Run headed (useful for debugging small client counts)",
)
p.add_argument(
"--ramp-up",
type=float,
default=10.0,
help="Seconds to spread client starts over (default 10s)",
)
p.add_argument(
"--min-think",
type=int,
default=200,
help="Min think time between answers in ms (default 200)",
)
p.add_argument(
"--max-think",
type=int,
default=1000,
help="Max think time between answers in ms (default 1200)",
)
p.add_argument(
"--goto-timeout",
type=int,
default=30000,
help="Timeout for page.goto in ms (default 15000)",
)
p.add_argument(
"--ui-timeout",
type=int,
default=30000,
help="Timeout for waiting UI elements in ms (default 15000)",
)
p.add_argument(
"--game-timeout",
type=int,
default=30000,
help="Timeout for waiting game UI in ms (default 30000)",
)
p.add_argument(
"--question-timeout",
type=int,
default=20000,
help="Timeout around a question cycle in ms (default 20000)",
)
p.add_argument("--csv", default=None, help="Write raw metrics to CSV file") p.add_argument("--csv", default=None, help="Write raw metrics to CSV file")
p.add_argument("--json", default=None, help="Write raw metrics to JSON file") p.add_argument("--json", default=None, help="Write raw metrics to JSON file")
return p.parse_args() return p.parse_args()
@@ -426,13 +489,40 @@ def write_outputs(agg: Aggregator, csv_path: Optional[str], json_path: Optional[
if csv_path: if csv_path:
import csv import csv
with open(csv_path, "w", newline="", encoding="utf-8") as f: with open(csv_path, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f) w = csv.writer(f)
w.writerow(["client_id", "phase", "method", "url", "path", "status", w.writerow(
"resource_type", "start_ms", "end_ms", "duration_ms", "ok"]) [
"client_id",
"phase",
"method",
"url",
"path",
"status",
"resource_type",
"start_ms",
"end_ms",
"duration_ms",
"ok",
]
)
for m in agg.metrics: for m in agg.metrics:
w.writerow([m.client_id, m.phase, m.method, m.url, m.path, m.status if m.status is not None else "", w.writerow(
m.resource_type, f"{m.start_ms:.3f}", f"{m.end_ms:.3f}", f"{m.duration_ms:.3f}", int(m.ok)]) [
m.client_id,
m.phase,
m.method,
m.url,
m.path,
m.status if m.status is not None else "",
m.resource_type,
f"{m.start_ms:.3f}",
f"{m.end_ms:.3f}",
f"{m.duration_ms:.3f}",
int(m.ok),
]
)
print(f"Wrote CSV metrics to: {csv_path}") print(f"Wrote CSV metrics to: {csv_path}")
@@ -474,6 +564,7 @@ async def main_async():
"question": args.question_timeout, "question": args.question_timeout,
}, },
) )
tasks.append(asyncio.create_task(starter())) tasks.append(asyncio.create_task(starter()))
await asyncio.gather(*tasks, return_exceptions=True) await asyncio.gather(*tasks, return_exceptions=True)