Compare commits
15 Commits
0f5a581ead
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 41df7e6f9f | |||
| 7d2a309d0f | |||
| d0ed79a986 | |||
| 4d1f1d3165 | |||
| 6d805a4fb8 | |||
| b454f37fc2 | |||
| ad3ebb2f85 | |||
| 811d0ea845 | |||
| 2249571dbd | |||
| 643732b46c | |||
| 89aff79050 | |||
| b582ad6c46 | |||
| 7aca8f8798 | |||
| c93853709f | |||
| 5e2107d398 |
@@ -68,4 +68,4 @@ jobs:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/arm64
|
||||
platforms: linux/arm64, linux/amd64
|
||||
|
||||
@@ -34,6 +34,16 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
|
||||
|
||||
## AI Quiz Generation
|
||||
|
||||
ClassQuiz-AI includes an OpenAI-compatible API endpoint for generating quizzes dynamically using LLMs.
|
||||
|
||||
- **Endpoint**: `POST /api/v1/ai/generate`
|
||||
- **Environment Variables**:
|
||||
- `AI_API_KEY`: API key for your LLM provider.
|
||||
- `AI_BASE_URL`: Base URL (defaults to `https://api.openai.com/v1`).
|
||||
- `AI_MODEL`: Model name (defaults to `gpt-4o-mini`).
|
||||
|
||||
## About ClassQuiz
|
||||
|
||||
ClassQuiz is a quiz app to learn interactively for students,
|
||||
|
||||
@@ -34,6 +34,7 @@ from classquiz.routers import (
|
||||
quiztivity,
|
||||
pixabay,
|
||||
moderation,
|
||||
ai,
|
||||
)
|
||||
from classquiz.socket_server import sio
|
||||
from classquiz.helpers import meilisearch_init
|
||||
@@ -104,6 +105,7 @@ app.include_router(login.router, tags=["auth"], prefix="/api/v1/login", include_
|
||||
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
|
||||
app.include_router(users.router, tags=["users"], prefix="/api/v1/users", include_in_schema=True)
|
||||
app.include_router(quiz.router, tags=["quiz"], prefix="/api/v1/quiz", include_in_schema=True)
|
||||
app.include_router(ai.router, tags=["ai"], prefix="/api/v1/ai", include_in_schema=True)
|
||||
app.include_router(utils.router, tags=["utils"], prefix="/api/v1/utils", include_in_schema=True)
|
||||
app.include_router(stats.router, tags=["stats"], prefix="/api/v1/stats", include_in_schema=True)
|
||||
app.include_router(storage.router, tags=["storage"], prefix="/api/v1/storage", include_in_schema=True)
|
||||
|
||||
@@ -47,29 +47,17 @@ class OpenIDResponse(BaseModel):
|
||||
|
||||
@router.get("/login")
|
||||
async def openid_login(req: Request):
|
||||
if (
|
||||
settings.custom_openid_provider.client_id is None
|
||||
or settings.custom_openid_provider.client_secret is None
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=501, detail="Custom-OAuth-Login isn't available on this server"
|
||||
)
|
||||
if settings.custom_openid_provider.client_id is None or settings.custom_openid_provider.client_secret is None:
|
||||
raise HTTPException(status_code=501, detail="Custom-OAuth-Login isn't available on this server")
|
||||
oauth = init_oauth()
|
||||
|
||||
return await oauth.custom.authorize_redirect(
|
||||
req, f"{settings.root_address}/api/v1/users/oauth/custom/auth"
|
||||
)
|
||||
return await oauth.custom.authorize_redirect(req, f"{settings.root_address}/api/v1/users/oauth/custom/auth")
|
||||
|
||||
|
||||
@router.get("/auth")
|
||||
async def auth(request: Request, response: Response):
|
||||
if (
|
||||
settings.custom_openid_provider.client_id is None
|
||||
or settings.custom_openid_provider.client_secret is None
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=501, detail="Custom-OAuth-Login isn't available on this server"
|
||||
)
|
||||
if settings.custom_openid_provider.client_id is None or settings.custom_openid_provider.client_secret is None:
|
||||
raise HTTPException(status_code=501, detail="Custom-OAuth-Login isn't available on this server")
|
||||
access_token = request.cookies.get("access_token")
|
||||
rememberme_token = request.cookies.get("rememberme_token")
|
||||
if access_token is not None:
|
||||
@@ -80,9 +68,7 @@ async def auth(request: Request, response: Response):
|
||||
except HTTPException:
|
||||
pass
|
||||
if rememberme_token is not None:
|
||||
return await rememberme_check(
|
||||
rememberme_token=rememberme_token, response=response
|
||||
)
|
||||
return await rememberme_check(rememberme_token=rememberme_token, response=response)
|
||||
oauth = init_oauth()
|
||||
|
||||
user_data = await oauth.custom.authorize_access_token(request)
|
||||
|
||||
@@ -76,21 +76,15 @@ class GitHubOauthResponse(BaseModel):
|
||||
@router.get("/login")
|
||||
async def github_login(req: Request):
|
||||
if settings.github_client_id is None or settings.github_client_secret is None:
|
||||
raise HTTPException(
|
||||
status_code=501, detail="GitHub-Login isn't available on this server"
|
||||
)
|
||||
raise HTTPException(status_code=501, detail="GitHub-Login isn't available on this server")
|
||||
oauth = init_oauth()
|
||||
return await oauth.github.authorize_redirect(
|
||||
req, f"{settings.root_address}/api/v1/users/oauth/github/auth"
|
||||
)
|
||||
return await oauth.github.authorize_redirect(req, f"{settings.root_address}/api/v1/users/oauth/github/auth")
|
||||
|
||||
|
||||
@router.get("/auth")
|
||||
async def auth(request: Request, response: Response):
|
||||
if settings.github_client_id is None or settings.github_client_secret is None:
|
||||
raise HTTPException(
|
||||
status_code=501, detail="GitHub-Login isn't available on this server"
|
||||
)
|
||||
raise HTTPException(status_code=501, detail="GitHub-Login isn't available on this server")
|
||||
access_token = request.cookies.get("access_token")
|
||||
rememberme_token = request.cookies.get("rememberme_token")
|
||||
if access_token is not None:
|
||||
@@ -101,9 +95,7 @@ async def auth(request: Request, response: Response):
|
||||
except HTTPException:
|
||||
pass
|
||||
if rememberme_token is not None:
|
||||
return await rememberme_check(
|
||||
rememberme_token=rememberme_token, response=response
|
||||
)
|
||||
return await rememberme_check(rememberme_token=rememberme_token, response=response)
|
||||
oauth = init_oauth()
|
||||
try:
|
||||
token = await oauth.github.authorize_access_token(request)
|
||||
|
||||
@@ -51,22 +51,16 @@ class OauthGoogleResponse(BaseModel):
|
||||
@router.get("/login")
|
||||
async def google_login(req: Request):
|
||||
if settings.google_client_secret is None or settings.google_client_id is None:
|
||||
raise HTTPException(
|
||||
status_code=501, detail="Google-Login isn't available on this server"
|
||||
)
|
||||
raise HTTPException(status_code=501, detail="Google-Login isn't available on this server")
|
||||
oauth = init_oauth()
|
||||
|
||||
return await oauth.google.authorize_redirect(
|
||||
req, f"{settings.root_address}/api/v1/users/oauth/google/auth"
|
||||
)
|
||||
return await oauth.google.authorize_redirect(req, f"{settings.root_address}/api/v1/users/oauth/google/auth")
|
||||
|
||||
|
||||
@router.get("/auth")
|
||||
async def auth(request: Request, response: Response):
|
||||
if settings.google_client_secret is None or settings.google_client_id is None:
|
||||
raise HTTPException(
|
||||
status_code=501, detail="Google-Login isn't available on this server"
|
||||
)
|
||||
raise HTTPException(status_code=501, detail="Google-Login isn't available on this server")
|
||||
access_token = request.cookies.get("access_token")
|
||||
rememberme_token = request.cookies.get("rememberme_token")
|
||||
if access_token is not None:
|
||||
@@ -77,9 +71,7 @@ async def auth(request: Request, response: Response):
|
||||
except HTTPException:
|
||||
pass
|
||||
if rememberme_token is not None:
|
||||
return await rememberme_check(
|
||||
rememberme_token=rememberme_token, response=response
|
||||
)
|
||||
return await rememberme_check(rememberme_token=rememberme_token, response=response)
|
||||
oauth = init_oauth()
|
||||
|
||||
user_data = await oauth.google.authorize_access_token(request)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# SPDX-FileCopyrightText: 2026 LobotomyLabs
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
import os
|
||||
import json
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from classquiz.auth import get_current_user
|
||||
from classquiz.db.models import User, QuizInput
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class AIGenerateRequest(BaseModel):
|
||||
topic: str
|
||||
num_questions: int = 5
|
||||
|
||||
@router.post("/generate", response_model=QuizInput)
|
||||
async def generate_ai_quiz(req: AIGenerateRequest, user: User = Depends(get_current_user)):
|
||||
api_key = os.getenv("AI_API_KEY", "")
|
||||
base_url = os.getenv("AI_BASE_URL", "https://api.openai.com/v1")
|
||||
model = os.getenv("AI_MODEL", "gpt-4o-mini")
|
||||
|
||||
prompt = f"Generate a quiz about '{req.topic}' with exactly {req.num_questions} multiple-choice questions (ABCD type). Return ONLY valid JSON with keys: title, description, questions (list of {{question, time: '20', type: 'ABCD', answers: [{{answer, right: bool, color}}]}})."
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
res = await client.post(
|
||||
f"{base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
json={"model": model, "messages": [{"role": "user", "content": prompt}], "temperature": 0.7},
|
||||
)
|
||||
if res.status_code != 200:
|
||||
raise HTTPException(status_code=502, detail=f"AI provider error: {res.text}")
|
||||
content = res.json()["choices"][0]["message"]["content"].strip()
|
||||
if content.startswith("```"):
|
||||
content = content.split("```")[1]
|
||||
if content.startswith("json"):
|
||||
content = content[4:]
|
||||
content = content.strip("` \n")
|
||||
return QuizInput(**json.loads(content))
|
||||
@@ -0,0 +1,12 @@
|
||||
# SPDX-FileCopyrightText: 2026 LobotomyLabs
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from classquiz import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
def test_ai_router_exists():
|
||||
response = client.post("/api/v1/ai/generate", json={"topic": "Python", "num_questions": 1})
|
||||
assert response.status_code in [200, 401, 502]
|
||||
@@ -0,0 +1,51 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 LobotomyLabs
|
||||
SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
<script lang="ts">
|
||||
import BrownButton from '$lib/components/buttons/brown.svelte';
|
||||
let { data = $bindable() } = $props();
|
||||
let open = $state(false);
|
||||
let topic = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
async function generate() {
|
||||
if (!topic) return;
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch('/api/v1/ai/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, num_questions: 5 })
|
||||
});
|
||||
if (res.ok) {
|
||||
const quiz = await res.json();
|
||||
data.title = quiz.title;
|
||||
data.description = quiz.description;
|
||||
data.questions = quiz.questions;
|
||||
open = false;
|
||||
} else {
|
||||
alert('Generation failed');
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="my-2 px-6">
|
||||
<BrownButton onclick={() => (open = true)}>✨ Generate with AI</BrownButton>
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div class="fixed z-50 inset-0 flex items-center justify-center bg-black/50">
|
||||
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-xl w-96">
|
||||
<h3 class="text-lg font-bold mb-4 dark:text-white">Generate Quiz with AI</h3>
|
||||
<input type="text" bind:value={topic} placeholder="Enter topic (e.g. World History)" class="w-full p-2 border rounded mb-4 dark:bg-gray-700 dark:text-white" />
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="px-4 py-2 bg-gray-300 rounded" onclick={() => (open = false)}>Cancel</button>
|
||||
<button class="px-4 py-2 bg-indigo-600 text-white rounded" onclick={generate} disabled={loading}>{loading ? 'Generating...' : 'Generate'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -13,6 +13,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
import { getLocalization } from '$lib/i18n';
|
||||
import AddNewQuestionPopup from '$lib/editor/AddNewQuestionPopup.svelte';
|
||||
import BrownButton from '$lib/components/buttons/brown.svelte';
|
||||
import AIModal from '$lib/editor/AIModal.svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const { t } = getLocalization();
|
||||
@@ -74,7 +75,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</script>
|
||||
|
||||
<div class="h-screen relative">
|
||||
<div class="h-10 flex justify-center w-full p-1 absolute z-20">
|
||||
<div class="h-10 flex justify-center w-full p-1 absolute z-20 gap-2">
|
||||
<div>
|
||||
<BrownButton onclick={() => (reorder_mode = !reorder_mode)}
|
||||
>{#if reorder_mode}{$t('editor.disable_reorder')}{:else}{$t(
|
||||
@@ -82,6 +83,9 @@ SPDX-License-Identifier: MPL-2.0
|
||||
)}{/if}</BrownButton
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<AIModal bind:data />
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-r-2 pt-6 px-6 overflow-scroll h-full">
|
||||
<div
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
"play_quiz": "2. Resol el qüestionari",
|
||||
"see_all_quizzes": "Mostra tots els teus qüestionaris",
|
||||
"completely_free": "Completament gratuït",
|
||||
"completely_free_content": "",
|
||||
"completely_free_content": "ClassQuiz és completament gratuït (per a l'usuari), sense cap pla de pagament o redireccions molestes a pàgines d'actualitzacions. Les donacions són molt apreciades.",
|
||||
"user_friendly_content": "ClassQuiz pretén ser senzill, per tal de poder ser emprat per tothom.",
|
||||
"no_tracking_content": "Altres aplicacions registren el que feu i envien aquesta informació a terceres parts. ClassQuiz no ho fa.",
|
||||
"multilingual_content": "",
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
"backup_code": "Código de la copia de seguridad",
|
||||
"totp": "contraseña de un solo uso (Totp)",
|
||||
"text": "Texto",
|
||||
"order": "Ordenar",
|
||||
"order": "Pedido",
|
||||
"results": "Resultados",
|
||||
"note": "Nota",
|
||||
"player_plural": "Jugadores",
|
||||
@@ -173,7 +173,7 @@
|
||||
"finish": "Finalizar",
|
||||
"normal": "Normal",
|
||||
"selected": "Seleccionado",
|
||||
"select": "Seleccionar",
|
||||
"select": "Selección",
|
||||
"quiz": "Cuestionario",
|
||||
"quiztivity": "Quiztivity",
|
||||
"next": "Siguiente",
|
||||
@@ -288,7 +288,7 @@
|
||||
"order_description": "Las respuestas se pueden poner en el orden correcto",
|
||||
"text_description": "Los jugadores pueden introducir texto",
|
||||
"range_description": "Se puede seleccionar un intervalo de números con un control deslizante",
|
||||
"check_choice_description": "Todas las respuestas correctas deben seleccionarse para obtener puntos",
|
||||
"check_choice_description": "Hay que elegir todas las respuestas correctas para sumar puntos",
|
||||
"need_more_help": "¿Necesitas más ayuda?",
|
||||
"enter_answer": "Escribe una respuesta",
|
||||
"visit_docs": "Visita los documentos.",
|
||||
@@ -362,7 +362,7 @@
|
||||
"player_count": "Número de jugadores",
|
||||
"no_results_so_far": "No hay resultados guardados hasta ahora...",
|
||||
"general_overview": {
|
||||
"sentence": "El cuestionario \"{{title}}\", el cual tuvo {{date}} jugadores, tenía {{player_count}} jugadores con una puntuación media de {{average_score}}."
|
||||
"sentence": "La prueba \"{{title}}\", el cual tuvo jugadores en {{date}} tenía {{player_count}} jugadores con una puntuación media de {{average_score}}."
|
||||
}
|
||||
},
|
||||
"result_page": {
|
||||
@@ -406,7 +406,7 @@
|
||||
"enable_totp": "Activar TOTP"
|
||||
},
|
||||
"view_quiz_page": {
|
||||
"made_by": "Hecho por",
|
||||
"made_by": "Realizado por Anonimous",
|
||||
"view_on_kahoot": "Ver en el original"
|
||||
},
|
||||
"start_game": {
|
||||
|
||||
@@ -1,36 +1,192 @@
|
||||
{
|
||||
"index_page": {
|
||||
"get_ranking_and_winners": "צפו בניקוד וראו מי זכה",
|
||||
"completely_free": "לגמרי ללא עלות",
|
||||
"create_a_quiz_from_scratch": "צור חידון מאפס בעזרת העורך וכלול תמונות ועוד",
|
||||
"see_how_many_true_and_false": "צפו בכמה צדקו או טעו",
|
||||
"see_all_quizzes": "צפו בכל החידונים שלכם",
|
||||
"get_ranking_and_winners": "צפייה בניקוד והצגת הזוכה",
|
||||
"completely_free": "לגמרי בחינם",
|
||||
"create_a_quiz_from_scratch": "אפשר ליצור חידון מאפס בעזרת העורך ולכלול תמונות ועוד",
|
||||
"see_how_many_true_and_false": "לראות כמה צדקו או טעו",
|
||||
"see_all_quizzes": "צפייה בכל החידונים שיצרת",
|
||||
"meta": {
|
||||
"description": "ClassQuiz היא אפליקציית חידון ללמידה אינטראקטיבית עבור תלמידים, בקוד פתוח וחינמית לשימוש",
|
||||
"description": "ClassQuiz היא יישום ליצירת חידונים ללמידה אינטראקטיבית לתלמידים, הזמין בקוד פתוח ובאופן חופשי",
|
||||
"title": "בית"
|
||||
},
|
||||
"stats": "כבר יש {{user_count}} משתמשים ו-{{quiz_count}} חידונים ב-ClassQuiz.",
|
||||
"see_what_true_and_false": "צפו במה היה נכון או לא נכון",
|
||||
"teachers_site": "אתר המורה",
|
||||
"students_site": "אתר התלמיד",
|
||||
"stats": "כבר יש {{user_count}} משתמשים ו־{{quiz_count}} חידונים ב־ClassQuiz.",
|
||||
"see_what_true_and_false": "לראות היכן צדקנו או טעינו",
|
||||
"teachers_site": "אתר למורים",
|
||||
"students_site": "אתר לתלמידים",
|
||||
"no_tracking": "ללא מעקב",
|
||||
"self_hostable": "ניתן לאחסון מקומי",
|
||||
"self_hostable": "ניתן לאירוח מקומי",
|
||||
"german_server": "שרת גרמני",
|
||||
"user_friendly": "ידידותי למשתמש",
|
||||
"quiz_results_downloadable": "תוצאות החידון ניתנות להורדה",
|
||||
"multilingual": "רב לשוני",
|
||||
"dark_mode": "מצב כהה",
|
||||
"get_a_quiz": "1. קבל חידון",
|
||||
"play_quiz": "2. שחקו את החידון",
|
||||
"select_answer": "בחר את התשובה",
|
||||
"choose_answer_wisely": "בחר את התשובה בחכמה",
|
||||
"get_a_quiz": "1. השגת חידון",
|
||||
"play_quiz": "2. הפעלת החידון",
|
||||
"select_answer": "בחירת התשובה",
|
||||
"choose_answer_wisely": "יש לבחור את התשובה בתבונה",
|
||||
"view_results": "צפייה בתוצאות",
|
||||
"check_if_chosen_wisely": "בדקו, אם בחרת בחוכמה",
|
||||
"check_if_chosen_wisely": "אפשר לבדוק אם בחרת תשובה בתבונה",
|
||||
"list_winners": "רשימת הזוכים",
|
||||
"why_classquiz": "למה ClassQuiz?",
|
||||
"no_tracking_content": "אחרים עוקבים אחריך ושולחים את המידע הזה לצד שלישי, אבל ClassQuiz לא.",
|
||||
"slogan": "פלטפורמת חידון בקוד פתוח!",
|
||||
"create_or_import": "צור או יבא",
|
||||
"find_or_explore": "מצא (או גלה) חידונים שנוצרו או יובאו על ידי אנשים אחרים"
|
||||
"no_tracking_content": "אתרים אחרים עוקבים אחריך ושולחים את המידע הזה לצד שלישי, אבל ClassQuiz לא.",
|
||||
"slogan": "פלטפורמת הקוד הפתוח ליצירת חידונים!",
|
||||
"create_or_import": "יצירה או ייבוא",
|
||||
"find_or_explore": "אפשר למצוא (או לגלות) חידונים שנוצרו או יובאו על ידי אנשים אחרים",
|
||||
"no_player_limit": "אין מגבלה על כמות השחקנים",
|
||||
"no_player_limit_content": "ל־ClassQuiz אין מגבלה מלאכותית על כמות השחקנים. תיאורטית, יותר מאלף שחקנים יכולים להשתתף באותו החידון, אבל בפועל נבדקה כמות של עד 300 שחקנים לחידון.",
|
||||
"import_quiz_from_kahoot_and_edit": "אפשר לייבא חידוני Kahoot! ולערוך אותם ב־ClassQuiz"
|
||||
},
|
||||
"overview_page": {
|
||||
"created_at": "מועד יצירה",
|
||||
"question_count": "כמות שאלות",
|
||||
"no_quizzes": "יש ללחוץ על הכפתור „יצירה” או לייבא חידון כדי להתחיל."
|
||||
},
|
||||
"edit_page": {
|
||||
"success_update_title": "החידון התעדכן."
|
||||
},
|
||||
"create_page": {
|
||||
"success": {
|
||||
"title": "החידון נוצר."
|
||||
}
|
||||
},
|
||||
"register_page": {
|
||||
"greeting": "נעים להכיר!",
|
||||
"create_account": "יצירת חשבון",
|
||||
"forgot_password?": "שכחת את הסיסמה?",
|
||||
"already_have_account?": "כבר יש לך חשבון?"
|
||||
},
|
||||
"login_page": {
|
||||
"welcome_back": "ברוך שובך.",
|
||||
"login_or_create_account": "התחברות או יצירת חשבון",
|
||||
"already_have_account": "אין לך חשבון?",
|
||||
"email_or_username": "דוא״ל או שם משתמש",
|
||||
"modal": {
|
||||
"success": {
|
||||
"success_check_mail": "התחברת. נא לבדוק את תיבת הדואר הנכנס.",
|
||||
"success": "התחברת.",
|
||||
"description": {
|
||||
"success": "התחברת."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"wrong_creds": "כתובת דוא״ל או סיסמה שגויות.",
|
||||
"unexpected": "שגיאה לא צפויה!"
|
||||
}
|
||||
},
|
||||
"use_backup_code": "שימוש בקוד גיבוי"
|
||||
},
|
||||
"words": {
|
||||
"question": "שאלה",
|
||||
"answer": "תשובה",
|
||||
"stats": "סטטיסטיקה",
|
||||
"features": "תכונות",
|
||||
"login": "התחברות",
|
||||
"email": "כתובת דוא״ל",
|
||||
"username": "שם משתמש",
|
||||
"password": "סיסמה",
|
||||
"play": "הפעלה",
|
||||
"edit": "עריכה",
|
||||
"delete": "מחיקה",
|
||||
"public": "ציבורי",
|
||||
"start": "התחלה",
|
||||
"create": "יצירה",
|
||||
"import": "ייבוא",
|
||||
"logout": "התנתקות",
|
||||
"title": "כותרת",
|
||||
"url": "כתובת",
|
||||
"submit": "שליחה",
|
||||
"connect": "התחברות",
|
||||
"kick": "סילוק",
|
||||
"register": "הרשמה",
|
||||
"docs": "תיעוד",
|
||||
"close": "סגירה",
|
||||
"save": "שמירה",
|
||||
"description": "תיאור",
|
||||
"image": "תמונה",
|
||||
"settings": "הגדרות",
|
||||
"repeat_password": "חזרה על הסיסמה",
|
||||
"overview": "סקירה כוללת",
|
||||
"report": "דיווח",
|
||||
"explore": "היחשפות",
|
||||
"search": "חיפוש",
|
||||
"screenshot": "צילום מסך",
|
||||
"screenshot_plural": "צילומי מסך",
|
||||
"browser": "דפדפן",
|
||||
"view": "תצוגה",
|
||||
"view_plural": "תצוגות",
|
||||
"result": "תוצאה",
|
||||
"result_plural": "תוצאות",
|
||||
"count": "כמות",
|
||||
"range": "טווח",
|
||||
"multiple_choice": "בחירה מרובה",
|
||||
"private": "פרטי",
|
||||
"question_plural": "שאלות",
|
||||
"find": "איתור",
|
||||
"error": "שגיאה",
|
||||
"download": "הורדה",
|
||||
"continue": "המשך",
|
||||
"backup_code": "קוד גיבוי",
|
||||
"text": "טקסט",
|
||||
"order": "סדר",
|
||||
"note": "הערה",
|
||||
"player_plural": "שחקנים",
|
||||
"results": "תוצאות",
|
||||
"unknown": "לא ידוע",
|
||||
"never": "אף פעם",
|
||||
"update": "עדכון",
|
||||
"score": "ניקוד",
|
||||
"slide": "שקופית",
|
||||
"name": "שם",
|
||||
"point": "נקודה",
|
||||
"point_plural": "נקודות",
|
||||
"back": "חזרה",
|
||||
"finish": "סיום",
|
||||
"select": "בחירה",
|
||||
"quiz": "חידון",
|
||||
"video": "סרטון",
|
||||
"library": "ספרייה",
|
||||
"progress": "התקדמות",
|
||||
"speed": "מהירות",
|
||||
"upload": "העלאה",
|
||||
"files_library": "ספריית קבצים",
|
||||
"answer_plural": "תשובות",
|
||||
"yes": "כן",
|
||||
"no": "לא",
|
||||
"analytics": "ניתוח נתונים",
|
||||
"rating": "דירוג",
|
||||
"like": "לייק",
|
||||
"like_plural": "לייקים",
|
||||
"play_plural": "הפעלות",
|
||||
"info": "מידע",
|
||||
"player": "שחקן"
|
||||
},
|
||||
"editor": {
|
||||
"time_in_seconds": "זמן בשניות",
|
||||
"add_new_answer": "הוספת תשובה חדשה",
|
||||
"add_new_question": "הוספת שאלה חדשה",
|
||||
"delete_question": "מחיקת שאלה",
|
||||
"delete_answer": "מחיקת תשובה",
|
||||
"no_title": "אין כותרת...",
|
||||
"empty": "ריק...",
|
||||
"bg_image": "תמונת רקע",
|
||||
"slide": {
|
||||
"text": "טקסט",
|
||||
"rectangle": "מלבן",
|
||||
"rectangle_description": "פשוט מלבן"
|
||||
},
|
||||
"visit_docs": "נא לעיין בתיעוד.",
|
||||
"advanced_settings": "הגדרות מתקדמות"
|
||||
},
|
||||
"import_page": {
|
||||
"a_kahoot_quiz": "חידון Kahoot!",
|
||||
"url_should_look_like_this": "הכתובת צריכה להיראות כך: https://create.kahoot.it/details/...",
|
||||
"classquiz_quiz": "חידון ClassQuiz"
|
||||
},
|
||||
"admin_page": {
|
||||
"start_game": "התחלת המשחק",
|
||||
"time_left": "זמן נותר",
|
||||
"get_results": "קבלת התוצאות",
|
||||
"get_final_results": "קבלת התוצאות הסופיות",
|
||||
"show_next_question": "הצגת השאלה הבאה"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"how_does_classquiz_work": "கிளாச் க்விச் கூட எவ்வாறு செயல்படுகிறது?",
|
||||
"no_player_limit": "வீரர் வரம்பு இல்லை",
|
||||
"no_player_limit_content": "ClassQuiz க்கு செயற்கை வீரர் வரம்பு இல்லை. கோட்பாட்டில், 1000+ வீரர்கள் ஒரே நொடி வினாவை விளையாட முடியும், ஆனால் இது ஒரு நொடி வினா வரை 300 வீரர்களுடன் சோதிக்கப்படுகிறது.",
|
||||
"import_quiz_from_kahoot_and_edit": "ஏற்றுமதி"
|
||||
"import_quiz_from_kahoot_and_edit": "கஊட்டிலிருந்து நொடி வினாவை இறக்குமதி செய்! வகுப்பு வினாடிவினாவில் அவற்றைத் திருத்தவும்"
|
||||
},
|
||||
"play_page": {
|
||||
"join_description": "{{pin} at இல் சேரவும், {{url}}} ஐ உள்ளிடவும்.",
|
||||
@@ -247,7 +247,9 @@
|
||||
"visit_docs": "டாக்சைப் பார்வையிடவும்.",
|
||||
"enter_answer": "ஒரு பதிலை உள்ளிடவும்",
|
||||
"enable_reorder": "மறுதொடக்கம் பயன்முறையை இயக்கவும்",
|
||||
"disable_reorder": "மறுதொடக்கம் பயன்முறையை முடக்கு"
|
||||
"disable_reorder": "மறுதொடக்கம் பயன்முறையை முடக்கு",
|
||||
"advanced_settings": "மேம்பட்ட அமைப்புகள்",
|
||||
"hide_question_results": "கேள்வி முடிவுகளை மறைக்கவா?"
|
||||
},
|
||||
"import_page": {
|
||||
"need_help": "உதவி தேவையா?",
|
||||
@@ -314,7 +316,8 @@
|
||||
"search_for_own_quizzes": "உங்கள் சொந்த நொடி வினாக்களைத் தேடுங்கள்",
|
||||
"views_n_plays": "காட்சிகள் மற்றும் நாடகங்கள்",
|
||||
"info_analytics": "\"நாடகங்கள்\" நொடி வினா எவ்வளவு அடிக்கடி தொடங்கப்பட்டது என்பதை மட்டுமே காட்டுகிறது (நீங்கள் சேர்த்துள்ளீர்கள்), அதேசமயம் \"காட்சிகள்\" \"பார்வை\"-பக்கம் எவ்வளவு அடிக்கடி பார்வையிடப்பட்டது என்பதை கணக்கிடுகிறது, எனவே இது போட்களையும் கணக்கிடுகிறது (அதற்காக மன்னிக்கவும்).",
|
||||
"commandpalette_notice": "கட்டளைத் தட்டுகளைத் திறக்க <kbd> ctr </kbd>+<kbd> k </kbd> அல்லது <kbd> மேவு </kbd>+<kbd> k </kbd> ஐ அழுத்தவும்"
|
||||
"commandpalette_notice": "கட்டளைத் தட்டுகளைத் திறக்க <kbd> ctr </kbd>+<kbd> k </kbd> அல்லது <kbd> மேவு </kbd>+<kbd> k </kbd> ஐ அழுத்தவும்",
|
||||
"create_quiz": "புதிய நொடி வினாவை உருவாக்கவும்"
|
||||
},
|
||||
"footer": {
|
||||
"self_ads": "{{mawoka_link} by மற்றும் {{others_link} of உதவியுடன் தயாரிக்கப்பட்டது.",
|
||||
@@ -370,7 +373,8 @@
|
||||
"correct_answer": "{{count}} சரியான பதில்",
|
||||
"correct_answer_plural": "{{count}} சரியான பதில்கள்",
|
||||
"time_taken": "எடுக்கப்பட்ட நேரம்",
|
||||
"player_score": "பிளேயர் ச்கோர்"
|
||||
"player_score": "பிளேயர் ச்கோர்",
|
||||
"player_correct_questions": "சரியான பதில்கள்"
|
||||
},
|
||||
"controllers": {
|
||||
"add_new_controller": "புதிய கட்டுப்படுத்தியைச் சேர்க்கவும்",
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"multilingual_content": "",
|
||||
"dark_mode": "暗黑模式",
|
||||
"why_classquiz": "为什么选择ClassQuiz?",
|
||||
"community_driven": "",
|
||||
"community_driven": "社区驱动",
|
||||
"play_quiz": "2. 开始测验",
|
||||
"see_all_quizzes": "查看您的所有测验",
|
||||
"download_quizzes_content": ""
|
||||
@@ -233,7 +233,7 @@
|
||||
},
|
||||
"overview_page": {
|
||||
"question_count": "",
|
||||
"created_at": "",
|
||||
"created_at": "创建于",
|
||||
"no_quizzes": ""
|
||||
},
|
||||
"settings_page": {
|
||||
@@ -382,15 +382,15 @@
|
||||
"login_page": {
|
||||
"use_backup_code": "",
|
||||
"email_or_username": "",
|
||||
"already_have_account": "",
|
||||
"already_have_account": "没有账号吗?",
|
||||
"modal": {
|
||||
"error": {
|
||||
"description": {
|
||||
"unexpected": "",
|
||||
"wrong_creds": ""
|
||||
"wrong_creds": "请确保你的密码和电子邮件地址是正确的。"
|
||||
},
|
||||
"wrong_creds": "",
|
||||
"unexpected": ""
|
||||
"wrong_creds": "电子邮件地址或密码错误。",
|
||||
"unexpected": "发生了未预料的错误!"
|
||||
},
|
||||
"success": {
|
||||
"success": "",
|
||||
@@ -401,8 +401,8 @@
|
||||
"success_check_mail": ""
|
||||
}
|
||||
},
|
||||
"login_or_create_account": "",
|
||||
"welcome_back": ""
|
||||
"login_or_create_account": "登录或创建账户",
|
||||
"welcome_back": "欢迎回来。"
|
||||
},
|
||||
"password_reset_page": {
|
||||
"reset_password": ""
|
||||
|
||||
@@ -65,12 +65,7 @@ export interface Question {
|
||||
}
|
||||
|
||||
export type Answers =
|
||||
| Answer[]
|
||||
| RangeQuizAnswer
|
||||
| VotingAnswer[]
|
||||
| string
|
||||
| TextQuizAnswer[]
|
||||
| OrderQuizAnswer[];
|
||||
Answer[] | RangeQuizAnswer | VotingAnswer[] | string | TextQuizAnswer[] | OrderQuizAnswer[];
|
||||
|
||||
export interface Answer {
|
||||
right: boolean;
|
||||
|
||||
+591
@@ -0,0 +1,591 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2026 Marlon W (Mawoka)
|
||||
#
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""
|
||||
Quiz App Load Tester (Playwright)
|
||||
|
||||
- Launches N concurrent browser contexts (>=200).
|
||||
- Workflow per client:
|
||||
1) Open http://localhost:8000/play
|
||||
2) Fill game key (default "620306"), submit
|
||||
3) Wait for name field, enter "client-<id>", click "Abschicken"
|
||||
4) Wait for game start (#progress-circle or answer grid)
|
||||
5) Repeatedly click a random answer button; wait for next question (buttons enabled again)
|
||||
- Measures:
|
||||
* Page load time for /play (goto -> networkidle/domcontentloaded)
|
||||
* All REST calls (resource_type in {'xhr','fetch'}) with durations (request -> finished)
|
||||
- Outputs summary tables (page loads + REST endpoints) at the end.
|
||||
|
||||
Usage examples:
|
||||
python load_quiz.py --clients 250 --key 620306 --rounds 20
|
||||
python load_quiz.py --duration 120 # run answers for ~120s per client
|
||||
python load_quiz.py --csv metrics.csv --json metrics.json
|
||||
|
||||
Tips:
|
||||
- If your "Submit"/"Abschicken" button texts differ, adjust selectors below.
|
||||
- For heavy loads, consider --ramp-up 20 to spread start times.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from playwright.async_api import Browser, Page, Request, async_playwright
|
||||
|
||||
# ---------- Config defaults ----------
|
||||
DEFAULT_BASE = "http://localhost:8080"
|
||||
DEFAULT_PATH = "/play"
|
||||
|
||||
# ---------- Data structures ----------
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestMetric:
|
||||
client_id: int
|
||||
method: str
|
||||
url: str
|
||||
path: str
|
||||
status: Optional[int]
|
||||
resource_type: str
|
||||
start_ms: float
|
||||
end_ms: float
|
||||
duration_ms: float
|
||||
ok: bool
|
||||
phase: str = "rest" # "rest" or "page_load"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Aggregator:
|
||||
# all metrics
|
||||
metrics: List[RequestMetric] = field(default_factory=list)
|
||||
|
||||
def add(self, m: RequestMetric) -> None:
|
||||
self.metrics.append(m)
|
||||
|
||||
# --- Stats helpers ---
|
||||
@staticmethod
|
||||
def _percentile(values: List[float], pct: float) -> float:
|
||||
if not values:
|
||||
return float("nan")
|
||||
values_sorted = sorted(values)
|
||||
# Nearest-rank method
|
||||
k = max(1, math.ceil(pct * len(values_sorted))) - 1
|
||||
return values_sorted[k]
|
||||
|
||||
@staticmethod
|
||||
def _fmt_ms(x: float) -> str:
|
||||
if math.isnan(x):
|
||||
return "nan"
|
||||
return f"{x:.1f}"
|
||||
|
||||
def summarize(self) -> Tuple[str, str]:
|
||||
"""
|
||||
Returns (page_load_table_str, rest_table_str)
|
||||
"""
|
||||
# Group page loads by path
|
||||
page_loads = [m for m in self.metrics if m.phase == "page_load"]
|
||||
rest_calls = [m for m in self.metrics if m.phase == "rest" and m.resource_type in {"xhr", "fetch"}]
|
||||
|
||||
def group_by(items: List[RequestMetric], key_fn):
|
||||
g: Dict[str, List[RequestMetric]] = {}
|
||||
for it in items:
|
||||
g.setdefault(key_fn(it), []).append(it)
|
||||
return g
|
||||
|
||||
# PAGE LOADS
|
||||
by_page = group_by(page_loads, lambda m: m.path or urlparse(m.url).path)
|
||||
page_lines = []
|
||||
header = [
|
||||
"Page",
|
||||
"Count",
|
||||
"Avg ms",
|
||||
"p50",
|
||||
"p90",
|
||||
"p99",
|
||||
"Min",
|
||||
"Max",
|
||||
"Errors",
|
||||
]
|
||||
page_lines.append(" | ".join(header))
|
||||
page_lines.append("-" * (len(" | ".join(header)) + 4))
|
||||
for path, items in sorted(by_page.items(), key=lambda kv: kv[0]):
|
||||
durs = [m.duration_ms for m in items if not math.isnan(m.duration_ms)]
|
||||
errs = sum(1 for m in items if not m.ok)
|
||||
avg = sum(durs) / len(durs) if durs else float("nan")
|
||||
p50 = self._percentile(durs, 0.50)
|
||||
p90 = self._percentile(durs, 0.90)
|
||||
p99 = self._percentile(durs, 0.99)
|
||||
mn = min(durs) if durs else float("nan")
|
||||
mx = max(durs) if durs else float("nan")
|
||||
page_lines.append(
|
||||
" | ".join(
|
||||
[
|
||||
path or "(unknown)",
|
||||
str(len(items)),
|
||||
self._fmt_ms(avg),
|
||||
self._fmt_ms(p50),
|
||||
self._fmt_ms(p90),
|
||||
self._fmt_ms(p99),
|
||||
self._fmt_ms(mn),
|
||||
self._fmt_ms(mx),
|
||||
f"{errs}",
|
||||
]
|
||||
)
|
||||
)
|
||||
page_table = "\n".join(page_lines) if len(page_lines) > 2 else "No page load metrics captured."
|
||||
|
||||
# REST CALLS
|
||||
by_endpoint = group_by(rest_calls, lambda m: f"{m.method} {m.path or urlparse(m.url).path}")
|
||||
rest_lines = []
|
||||
header_r = [
|
||||
"Endpoint (method path)",
|
||||
"Count",
|
||||
"Avg ms",
|
||||
"p50",
|
||||
"p90",
|
||||
"p99",
|
||||
"Min",
|
||||
"Max",
|
||||
"Err%",
|
||||
]
|
||||
rest_lines.append(" | ".join(header_r))
|
||||
rest_lines.append("-" * (len(" | ".join(header_r)) + 4))
|
||||
for ep, items in sorted(by_endpoint.items(), key=lambda kv: kv[0]):
|
||||
durs = [m.duration_ms for m in items if not math.isnan(m.duration_ms)]
|
||||
errs = sum(1 for m in items if not m.ok)
|
||||
avg = sum(durs) / len(durs) if durs else float("nan")
|
||||
p50 = self._percentile(durs, 0.50)
|
||||
p90 = self._percentile(durs, 0.90)
|
||||
p99 = self._percentile(durs, 0.99)
|
||||
mn = min(durs) if durs else float("nan")
|
||||
mx = max(durs) if durs else float("nan")
|
||||
err_pct = (errs / len(items) * 100.0) if items else 0.0
|
||||
rest_lines.append(
|
||||
" | ".join(
|
||||
[
|
||||
ep,
|
||||
str(len(items)),
|
||||
self._fmt_ms(avg),
|
||||
self._fmt_ms(p50),
|
||||
self._fmt_ms(p90),
|
||||
self._fmt_ms(p99),
|
||||
self._fmt_ms(mn),
|
||||
self._fmt_ms(mx),
|
||||
f"{err_pct:.1f}%",
|
||||
]
|
||||
)
|
||||
)
|
||||
rest_table = "\n".join(rest_lines) if len(rest_lines) > 2 else "No REST (xhr/fetch) metrics captured."
|
||||
|
||||
return page_table, rest_table
|
||||
|
||||
|
||||
# ---------- Client logic ----------
|
||||
|
||||
|
||||
async def wait_for_game_ui(page: Page, timeout_ms: int) -> None:
|
||||
"""
|
||||
Wait until the game screen appears: either #progress-circle
|
||||
or the answer grid with buttons.
|
||||
"""
|
||||
try:
|
||||
await page.wait_for_selector("#progress-circle", state="visible", timeout=timeout_ms)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
# Answer grid fallback
|
||||
await page.wait_for_selector("div.grid button", state="visible", timeout=timeout_ms)
|
||||
|
||||
|
||||
async def click_random_answer(page: Page, timeout_ms: int) -> None:
|
||||
"""
|
||||
Click a random enabled answer button, then wait for the next question
|
||||
by waiting for buttons to be disabled then re-enabled.
|
||||
"""
|
||||
# Ensure buttons are visible
|
||||
await page.wait_for_selector("div.grid button", state="visible", timeout=timeout_ms)
|
||||
# Pick among currently enabled buttons
|
||||
enabled_locator = page.locator("div.grid button:not([disabled])")
|
||||
count = await enabled_locator.count()
|
||||
# If none enabled (edge), wait briefly and retry once
|
||||
if count == 0:
|
||||
await page.wait_for_timeout(200)
|
||||
count = await enabled_locator.count()
|
||||
if count == 0:
|
||||
# As a last resort click first button if present
|
||||
any_btns = page.locator("div.grid button")
|
||||
if await any_btns.count() > 0:
|
||||
await any_btns.nth(0).click()
|
||||
return
|
||||
idx = random.randrange(count)
|
||||
await enabled_locator.nth(idx).click()
|
||||
|
||||
# Wait for transient disabled state (server evaluating question)
|
||||
# Then wait for next set to become enabled again
|
||||
try:
|
||||
await page.wait_for_selector("div.grid button[disabled]", state="attached", timeout=timeout_ms)
|
||||
except Exception:
|
||||
# Sometimes they never disable; just proceed
|
||||
pass
|
||||
# Now wait until enabled buttons appear again (next question)
|
||||
try:
|
||||
await page.wait_for_selector("div.grid button:not([disabled])", state="visible", timeout=timeout_ms)
|
||||
except Exception:
|
||||
# If not re-enabled, at least ensure buttons are visible again
|
||||
await page.wait_for_selector("div.grid button", state="visible", timeout=timeout_ms)
|
||||
|
||||
|
||||
def just_path(url: str) -> str:
|
||||
try:
|
||||
return urlparse(url).path or "/"
|
||||
except Exception:
|
||||
return url
|
||||
|
||||
|
||||
async def run_client(
|
||||
client_id: int,
|
||||
browser: Browser,
|
||||
agg: Aggregator,
|
||||
base_url: str,
|
||||
start_path: str,
|
||||
key_value: str,
|
||||
rounds: int,
|
||||
duration_s: Optional[int],
|
||||
think_time_ms: Tuple[int, int],
|
||||
timeouts: Dict[str, int],
|
||||
headful_name: Optional[str] = None,
|
||||
) -> None:
|
||||
context = await browser.new_context(
|
||||
viewport={"width": 1280, "height": 800},
|
||||
user_agent=f"QuizLoadTester/1.0 Client/{client_id}",
|
||||
java_script_enabled=True,
|
||||
ignore_https_errors=True,
|
||||
bypass_csp=True,
|
||||
)
|
||||
page = await context.new_page()
|
||||
|
||||
# ---- Network timing hooks ----
|
||||
req_start: Dict[Request, float] = {}
|
||||
|
||||
def on_request(req: Request):
|
||||
req_start[req] = time.perf_counter() * 1000.0 # ms
|
||||
|
||||
async def finalize_metric(req: Request, ok: bool):
|
||||
start_ms = req_start.pop(req, time.perf_counter() * 1000.0)
|
||||
end_ms = time.perf_counter() * 1000.0
|
||||
dur = max(0.0, end_ms - start_ms)
|
||||
resp = await req.response() if ok else None
|
||||
status = resp.status if resp else None
|
||||
agg.add(
|
||||
RequestMetric(
|
||||
client_id=client_id,
|
||||
method=req.method,
|
||||
url=req.url,
|
||||
path=just_path(req.url),
|
||||
status=status,
|
||||
resource_type=req.resource_type,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
duration_ms=dur,
|
||||
ok=(ok and (status is not None) and (status < 400)),
|
||||
phase="rest" if req.resource_type in {"xhr", "fetch"} else "other",
|
||||
)
|
||||
)
|
||||
|
||||
page.on("request", on_request)
|
||||
page.on("requestfinished", lambda req: asyncio.create_task(finalize_metric(req, True)))
|
||||
page.on("requestfailed", lambda req: asyncio.create_task(finalize_metric(req, False)))
|
||||
|
||||
# ---- Flow ----
|
||||
url = f"{base_url.rstrip('/')}{start_path}"
|
||||
# Measure initial page load
|
||||
t0 = time.perf_counter() * 1000.0
|
||||
try:
|
||||
# Try networkidle first, fallback to domcontentloaded
|
||||
try:
|
||||
await page.goto(url, wait_until="networkidle", timeout=timeouts["goto"])
|
||||
except Exception:
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=timeouts["goto"])
|
||||
t1 = time.perf_counter() * 1000.0
|
||||
agg.add(
|
||||
RequestMetric(
|
||||
client_id=client_id,
|
||||
method="GET",
|
||||
url=url,
|
||||
path=start_path,
|
||||
status=200,
|
||||
resource_type="document",
|
||||
start_ms=t0,
|
||||
end_ms=t1,
|
||||
duration_ms=max(0.0, t1 - t0),
|
||||
ok=True,
|
||||
phase="page_load",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
t1 = time.perf_counter() * 1000.0
|
||||
agg.add(
|
||||
RequestMetric(
|
||||
client_id=client_id,
|
||||
method="GET",
|
||||
url=url,
|
||||
path=start_path,
|
||||
status=None,
|
||||
resource_type="document",
|
||||
start_ms=t0,
|
||||
end_ms=t1,
|
||||
duration_ms=max(0.0, t1 - t0),
|
||||
ok=False,
|
||||
phase="page_load",
|
||||
)
|
||||
)
|
||||
await context.close()
|
||||
return
|
||||
# Step 2: Enter key and submit
|
||||
try:
|
||||
# Fill any visible text input (assumed "key")
|
||||
await page.wait_for_selector('input[inputmode="numeric"]:visible', timeout=timeouts["ui"])
|
||||
key_input = page.locator('input[inputmode="numeric"]:visible').first
|
||||
await key_input.fill(key_value)
|
||||
except Exception:
|
||||
# If this fails, client can't proceed
|
||||
await context.close()
|
||||
return
|
||||
# Step 3: Wait for name field and click "Abschicken"
|
||||
try:
|
||||
await page.wait_for_selector('button:has-text("Submit")', state="visible", timeout=timeouts["ui"])
|
||||
name_btn = page.locator('button:has-text("Submit")').first
|
||||
# Fill the visible text input again (assumed "name")
|
||||
await page.wait_for_selector('input[maxlength="17"]:visible', timeout=timeouts["ui"])
|
||||
name_input = page.locator('input[maxlength="17"]:visible').first
|
||||
await name_input.fill(f"client-{client_id}")
|
||||
await name_btn.click()
|
||||
except Exception:
|
||||
# If localized differently, try pressing Enter as fallback
|
||||
try:
|
||||
await page.keyboard.press("Enter")
|
||||
except Exception:
|
||||
await context.close()
|
||||
return
|
||||
|
||||
# Step 4: Wait for game start
|
||||
try:
|
||||
await wait_for_game_ui(page, timeout_ms=timeouts["game"])
|
||||
except Exception:
|
||||
|
||||
await context.close()
|
||||
return
|
||||
|
||||
# Step 5: Answer loop
|
||||
start_play = time.perf_counter()
|
||||
q_count = 0
|
||||
try:
|
||||
while True:
|
||||
# Optional stopping conditions
|
||||
if rounds > 0 and q_count >= rounds:
|
||||
break
|
||||
if duration_s is not None and (time.perf_counter() - start_play) >= duration_s:
|
||||
break
|
||||
|
||||
# "Think" a bit to avoid synchronized clicks
|
||||
await page.wait_for_timeout(random.randint(*think_time_ms))
|
||||
await click_random_answer(page, timeout_ms=timeouts["question"])
|
||||
q_count += 1
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
|
||||
# ---------- Main / CLI ----------
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="Load test for Quiz app with real browsers (Playwright).")
|
||||
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("--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(
|
||||
"--rounds",
|
||||
type=int,
|
||||
default=15,
|
||||
help="Max questions to answer per client (0 = unlimited)",
|
||||
)
|
||||
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("--json", default=None, help="Write raw metrics to JSON file")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def write_outputs(agg: Aggregator, csv_path: Optional[str], json_path: Optional[str]) -> None:
|
||||
if json_path:
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump([m.__dict__ for m in agg.metrics], f, indent=2)
|
||||
print(f"\nWrote JSON metrics to: {json_path}")
|
||||
|
||||
if csv_path:
|
||||
import csv
|
||||
|
||||
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(
|
||||
[
|
||||
"client_id",
|
||||
"phase",
|
||||
"method",
|
||||
"url",
|
||||
"path",
|
||||
"status",
|
||||
"resource_type",
|
||||
"start_ms",
|
||||
"end_ms",
|
||||
"duration_ms",
|
||||
"ok",
|
||||
]
|
||||
)
|
||||
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 "",
|
||||
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}")
|
||||
|
||||
|
||||
async def main_async():
|
||||
args = parse_args()
|
||||
agg = Aggregator()
|
||||
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(
|
||||
headless=not args.headful,
|
||||
args=[
|
||||
"--disable-dev-shm-usage",
|
||||
"--no-sandbox",
|
||||
],
|
||||
)
|
||||
|
||||
# Schedule all clients with ramp-up
|
||||
tasks = []
|
||||
for i in range(args.clients):
|
||||
delay = (args.ramp_up * (i / max(1, args.clients - 1))) if args.ramp_up > 0 else 0.0
|
||||
|
||||
async def starter(idx=i, dly=delay):
|
||||
if dly > 0:
|
||||
await asyncio.sleep(dly)
|
||||
return await run_client(
|
||||
client_id=idx + 1,
|
||||
browser=browser,
|
||||
agg=agg,
|
||||
base_url=args.base,
|
||||
start_path=args.path,
|
||||
key_value=args.key,
|
||||
rounds=args.rounds if args.duration is None else 0,
|
||||
duration_s=args.duration,
|
||||
think_time_ms=(args.min_think, args.max_think),
|
||||
timeouts={
|
||||
"goto": args.goto_timeout,
|
||||
"ui": args.ui_timeout,
|
||||
"game": args.game_timeout,
|
||||
"question": args.question_timeout,
|
||||
},
|
||||
)
|
||||
|
||||
tasks.append(asyncio.create_task(starter()))
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
await browser.close()
|
||||
|
||||
# ----- Output -----
|
||||
page_table, rest_table = agg.summarize()
|
||||
print("\n=== Page Loads ===")
|
||||
print(page_table)
|
||||
print("\n=== REST (XHR/Fetch) ===")
|
||||
print(rest_table)
|
||||
|
||||
write_outputs(agg, args.csv, args.json)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
asyncio.run(main_async())
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted.", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user