Fix load test
This commit is contained in:
+138
-47
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user