Compare commits
12 Commits
0f5a581ead
...
4d1f1d3165
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d1f1d3165 | |||
| 6d805a4fb8 | |||
| b454f37fc2 | |||
| ad3ebb2f85 | |||
| 811d0ea845 | |||
| 2249571dbd | |||
| 643732b46c | |||
| 89aff79050 | |||
| b582ad6c46 | |||
| 7aca8f8798 | |||
| c93853709f | |||
| 5e2107d398 |
@@ -68,4 +68,4 @@ jobs:
|
|||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
platforms: linux/arm64
|
platforms: linux/arm64, linux/amd64
|
||||||
|
|||||||
@@ -47,29 +47,17 @@ class OpenIDResponse(BaseModel):
|
|||||||
|
|
||||||
@router.get("/login")
|
@router.get("/login")
|
||||||
async def openid_login(req: Request):
|
async def openid_login(req: Request):
|
||||||
if (
|
if settings.custom_openid_provider.client_id is None or settings.custom_openid_provider.client_secret is None:
|
||||||
settings.custom_openid_provider.client_id is None
|
raise HTTPException(status_code=501, detail="Custom-OAuth-Login isn't available on this server")
|
||||||
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()
|
oauth = init_oauth()
|
||||||
|
|
||||||
return await oauth.custom.authorize_redirect(
|
return await oauth.custom.authorize_redirect(req, f"{settings.root_address}/api/v1/users/oauth/custom/auth")
|
||||||
req, f"{settings.root_address}/api/v1/users/oauth/custom/auth"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/auth")
|
@router.get("/auth")
|
||||||
async def auth(request: Request, response: Response):
|
async def auth(request: Request, response: Response):
|
||||||
if (
|
if settings.custom_openid_provider.client_id is None or settings.custom_openid_provider.client_secret is None:
|
||||||
settings.custom_openid_provider.client_id is None
|
raise HTTPException(status_code=501, detail="Custom-OAuth-Login isn't available on this server")
|
||||||
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")
|
access_token = request.cookies.get("access_token")
|
||||||
rememberme_token = request.cookies.get("rememberme_token")
|
rememberme_token = request.cookies.get("rememberme_token")
|
||||||
if access_token is not None:
|
if access_token is not None:
|
||||||
@@ -80,9 +68,7 @@ async def auth(request: Request, response: Response):
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
pass
|
pass
|
||||||
if rememberme_token is not None:
|
if rememberme_token is not None:
|
||||||
return await rememberme_check(
|
return await rememberme_check(rememberme_token=rememberme_token, response=response)
|
||||||
rememberme_token=rememberme_token, response=response
|
|
||||||
)
|
|
||||||
oauth = init_oauth()
|
oauth = init_oauth()
|
||||||
|
|
||||||
user_data = await oauth.custom.authorize_access_token(request)
|
user_data = await oauth.custom.authorize_access_token(request)
|
||||||
|
|||||||
@@ -76,21 +76,15 @@ class GitHubOauthResponse(BaseModel):
|
|||||||
@router.get("/login")
|
@router.get("/login")
|
||||||
async def github_login(req: Request):
|
async def github_login(req: Request):
|
||||||
if settings.github_client_id is None or settings.github_client_secret is None:
|
if settings.github_client_id is None or settings.github_client_secret is None:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=501, detail="GitHub-Login isn't available on this server")
|
||||||
status_code=501, detail="GitHub-Login isn't available on this server"
|
|
||||||
)
|
|
||||||
oauth = init_oauth()
|
oauth = init_oauth()
|
||||||
return await oauth.github.authorize_redirect(
|
return await oauth.github.authorize_redirect(req, f"{settings.root_address}/api/v1/users/oauth/github/auth")
|
||||||
req, f"{settings.root_address}/api/v1/users/oauth/github/auth"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/auth")
|
@router.get("/auth")
|
||||||
async def auth(request: Request, response: Response):
|
async def auth(request: Request, response: Response):
|
||||||
if settings.github_client_id is None or settings.github_client_secret is None:
|
if settings.github_client_id is None or settings.github_client_secret is None:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=501, detail="GitHub-Login isn't available on this server")
|
||||||
status_code=501, detail="GitHub-Login isn't available on this server"
|
|
||||||
)
|
|
||||||
access_token = request.cookies.get("access_token")
|
access_token = request.cookies.get("access_token")
|
||||||
rememberme_token = request.cookies.get("rememberme_token")
|
rememberme_token = request.cookies.get("rememberme_token")
|
||||||
if access_token is not None:
|
if access_token is not None:
|
||||||
@@ -101,9 +95,7 @@ async def auth(request: Request, response: Response):
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
pass
|
pass
|
||||||
if rememberme_token is not None:
|
if rememberme_token is not None:
|
||||||
return await rememberme_check(
|
return await rememberme_check(rememberme_token=rememberme_token, response=response)
|
||||||
rememberme_token=rememberme_token, response=response
|
|
||||||
)
|
|
||||||
oauth = init_oauth()
|
oauth = init_oauth()
|
||||||
try:
|
try:
|
||||||
token = await oauth.github.authorize_access_token(request)
|
token = await oauth.github.authorize_access_token(request)
|
||||||
|
|||||||
@@ -51,22 +51,16 @@ class OauthGoogleResponse(BaseModel):
|
|||||||
@router.get("/login")
|
@router.get("/login")
|
||||||
async def google_login(req: Request):
|
async def google_login(req: Request):
|
||||||
if settings.google_client_secret is None or settings.google_client_id is None:
|
if settings.google_client_secret is None or settings.google_client_id is None:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=501, detail="Google-Login isn't available on this server")
|
||||||
status_code=501, detail="Google-Login isn't available on this server"
|
|
||||||
)
|
|
||||||
oauth = init_oauth()
|
oauth = init_oauth()
|
||||||
|
|
||||||
return await oauth.google.authorize_redirect(
|
return await oauth.google.authorize_redirect(req, f"{settings.root_address}/api/v1/users/oauth/google/auth")
|
||||||
req, f"{settings.root_address}/api/v1/users/oauth/google/auth"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/auth")
|
@router.get("/auth")
|
||||||
async def auth(request: Request, response: Response):
|
async def auth(request: Request, response: Response):
|
||||||
if settings.google_client_secret is None or settings.google_client_id is None:
|
if settings.google_client_secret is None or settings.google_client_id is None:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=501, detail="Google-Login isn't available on this server")
|
||||||
status_code=501, detail="Google-Login isn't available on this server"
|
|
||||||
)
|
|
||||||
access_token = request.cookies.get("access_token")
|
access_token = request.cookies.get("access_token")
|
||||||
rememberme_token = request.cookies.get("rememberme_token")
|
rememberme_token = request.cookies.get("rememberme_token")
|
||||||
if access_token is not None:
|
if access_token is not None:
|
||||||
@@ -77,9 +71,7 @@ async def auth(request: Request, response: Response):
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
pass
|
pass
|
||||||
if rememberme_token is not None:
|
if rememberme_token is not None:
|
||||||
return await rememberme_check(
|
return await rememberme_check(rememberme_token=rememberme_token, response=response)
|
||||||
rememberme_token=rememberme_token, response=response
|
|
||||||
)
|
|
||||||
oauth = init_oauth()
|
oauth = init_oauth()
|
||||||
|
|
||||||
user_data = await oauth.google.authorize_access_token(request)
|
user_data = await oauth.google.authorize_access_token(request)
|
||||||
|
|||||||
@@ -191,7 +191,7 @@
|
|||||||
"play_quiz": "2. Resol el qüestionari",
|
"play_quiz": "2. Resol el qüestionari",
|
||||||
"see_all_quizzes": "Mostra tots els teus qüestionaris",
|
"see_all_quizzes": "Mostra tots els teus qüestionaris",
|
||||||
"completely_free": "Completament gratuït",
|
"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.",
|
"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.",
|
"no_tracking_content": "Altres aplicacions registren el que feu i envien aquesta informació a terceres parts. ClassQuiz no ho fa.",
|
||||||
"multilingual_content": "",
|
"multilingual_content": "",
|
||||||
|
|||||||
@@ -160,7 +160,7 @@
|
|||||||
"backup_code": "Código de la copia de seguridad",
|
"backup_code": "Código de la copia de seguridad",
|
||||||
"totp": "contraseña de un solo uso (Totp)",
|
"totp": "contraseña de un solo uso (Totp)",
|
||||||
"text": "Texto",
|
"text": "Texto",
|
||||||
"order": "Ordenar",
|
"order": "Pedido",
|
||||||
"results": "Resultados",
|
"results": "Resultados",
|
||||||
"note": "Nota",
|
"note": "Nota",
|
||||||
"player_plural": "Jugadores",
|
"player_plural": "Jugadores",
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
"finish": "Finalizar",
|
"finish": "Finalizar",
|
||||||
"normal": "Normal",
|
"normal": "Normal",
|
||||||
"selected": "Seleccionado",
|
"selected": "Seleccionado",
|
||||||
"select": "Seleccionar",
|
"select": "Selección",
|
||||||
"quiz": "Cuestionario",
|
"quiz": "Cuestionario",
|
||||||
"quiztivity": "Quiztivity",
|
"quiztivity": "Quiztivity",
|
||||||
"next": "Siguiente",
|
"next": "Siguiente",
|
||||||
@@ -288,7 +288,7 @@
|
|||||||
"order_description": "Las respuestas se pueden poner en el orden correcto",
|
"order_description": "Las respuestas se pueden poner en el orden correcto",
|
||||||
"text_description": "Los jugadores pueden introducir texto",
|
"text_description": "Los jugadores pueden introducir texto",
|
||||||
"range_description": "Se puede seleccionar un intervalo de números con un control deslizante",
|
"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?",
|
"need_more_help": "¿Necesitas más ayuda?",
|
||||||
"enter_answer": "Escribe una respuesta",
|
"enter_answer": "Escribe una respuesta",
|
||||||
"visit_docs": "Visita los documentos.",
|
"visit_docs": "Visita los documentos.",
|
||||||
@@ -362,7 +362,7 @@
|
|||||||
"player_count": "Número de jugadores",
|
"player_count": "Número de jugadores",
|
||||||
"no_results_so_far": "No hay resultados guardados hasta ahora...",
|
"no_results_so_far": "No hay resultados guardados hasta ahora...",
|
||||||
"general_overview": {
|
"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": {
|
"result_page": {
|
||||||
@@ -406,7 +406,7 @@
|
|||||||
"enable_totp": "Activar TOTP"
|
"enable_totp": "Activar TOTP"
|
||||||
},
|
},
|
||||||
"view_quiz_page": {
|
"view_quiz_page": {
|
||||||
"made_by": "Hecho por",
|
"made_by": "Realizado por Anonimous",
|
||||||
"view_on_kahoot": "Ver en el original"
|
"view_on_kahoot": "Ver en el original"
|
||||||
},
|
},
|
||||||
"start_game": {
|
"start_game": {
|
||||||
|
|||||||
@@ -1,36 +1,192 @@
|
|||||||
{
|
{
|
||||||
"index_page": {
|
"index_page": {
|
||||||
"get_ranking_and_winners": "צפו בניקוד וראו מי זכה",
|
"get_ranking_and_winners": "צפייה בניקוד והצגת הזוכה",
|
||||||
"completely_free": "לגמרי ללא עלות",
|
"completely_free": "לגמרי בחינם",
|
||||||
"create_a_quiz_from_scratch": "צור חידון מאפס בעזרת העורך וכלול תמונות ועוד",
|
"create_a_quiz_from_scratch": "אפשר ליצור חידון מאפס בעזרת העורך ולכלול תמונות ועוד",
|
||||||
"see_how_many_true_and_false": "צפו בכמה צדקו או טעו",
|
"see_how_many_true_and_false": "לראות כמה צדקו או טעו",
|
||||||
"see_all_quizzes": "צפו בכל החידונים שלכם",
|
"see_all_quizzes": "צפייה בכל החידונים שיצרת",
|
||||||
"meta": {
|
"meta": {
|
||||||
"description": "ClassQuiz היא אפליקציית חידון ללמידה אינטראקטיבית עבור תלמידים, בקוד פתוח וחינמית לשימוש",
|
"description": "ClassQuiz היא יישום ליצירת חידונים ללמידה אינטראקטיבית לתלמידים, הזמין בקוד פתוח ובאופן חופשי",
|
||||||
"title": "בית"
|
"title": "בית"
|
||||||
},
|
},
|
||||||
"stats": "כבר יש {{user_count}} משתמשים ו-{{quiz_count}} חידונים ב-ClassQuiz.",
|
"stats": "כבר יש {{user_count}} משתמשים ו־{{quiz_count}} חידונים ב־ClassQuiz.",
|
||||||
"see_what_true_and_false": "צפו במה היה נכון או לא נכון",
|
"see_what_true_and_false": "לראות היכן צדקנו או טעינו",
|
||||||
"teachers_site": "אתר המורה",
|
"teachers_site": "אתר למורים",
|
||||||
"students_site": "אתר התלמיד",
|
"students_site": "אתר לתלמידים",
|
||||||
"no_tracking": "ללא מעקב",
|
"no_tracking": "ללא מעקב",
|
||||||
"self_hostable": "ניתן לאחסון מקומי",
|
"self_hostable": "ניתן לאירוח מקומי",
|
||||||
"german_server": "שרת גרמני",
|
"german_server": "שרת גרמני",
|
||||||
"user_friendly": "ידידותי למשתמש",
|
"user_friendly": "ידידותי למשתמש",
|
||||||
"quiz_results_downloadable": "תוצאות החידון ניתנות להורדה",
|
"quiz_results_downloadable": "תוצאות החידון ניתנות להורדה",
|
||||||
"multilingual": "רב לשוני",
|
"multilingual": "רב לשוני",
|
||||||
"dark_mode": "מצב כהה",
|
"dark_mode": "מצב כהה",
|
||||||
"get_a_quiz": "1. קבל חידון",
|
"get_a_quiz": "1. השגת חידון",
|
||||||
"play_quiz": "2. שחקו את החידון",
|
"play_quiz": "2. הפעלת החידון",
|
||||||
"select_answer": "בחר את התשובה",
|
"select_answer": "בחירת התשובה",
|
||||||
"choose_answer_wisely": "בחר את התשובה בחכמה",
|
"choose_answer_wisely": "יש לבחור את התשובה בתבונה",
|
||||||
"view_results": "צפייה בתוצאות",
|
"view_results": "צפייה בתוצאות",
|
||||||
"check_if_chosen_wisely": "בדקו, אם בחרת בחוכמה",
|
"check_if_chosen_wisely": "אפשר לבדוק אם בחרת תשובה בתבונה",
|
||||||
"list_winners": "רשימת הזוכים",
|
"list_winners": "רשימת הזוכים",
|
||||||
"why_classquiz": "למה ClassQuiz?",
|
"why_classquiz": "למה ClassQuiz?",
|
||||||
"no_tracking_content": "אחרים עוקבים אחריך ושולחים את המידע הזה לצד שלישי, אבל ClassQuiz לא.",
|
"no_tracking_content": "אתרים אחרים עוקבים אחריך ושולחים את המידע הזה לצד שלישי, אבל ClassQuiz לא.",
|
||||||
"slogan": "פלטפורמת חידון בקוד פתוח!",
|
"slogan": "פלטפורמת הקוד הפתוח ליצירת חידונים!",
|
||||||
"create_or_import": "צור או יבא",
|
"create_or_import": "יצירה או ייבוא",
|
||||||
"find_or_explore": "מצא (או גלה) חידונים שנוצרו או יובאו על ידי אנשים אחרים"
|
"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": "கிளாச் க்விச் கூட எவ்வாறு செயல்படுகிறது?",
|
"how_does_classquiz_work": "கிளாச் க்விச் கூட எவ்வாறு செயல்படுகிறது?",
|
||||||
"no_player_limit": "வீரர் வரம்பு இல்லை",
|
"no_player_limit": "வீரர் வரம்பு இல்லை",
|
||||||
"no_player_limit_content": "ClassQuiz க்கு செயற்கை வீரர் வரம்பு இல்லை. கோட்பாட்டில், 1000+ வீரர்கள் ஒரே நொடி வினாவை விளையாட முடியும், ஆனால் இது ஒரு நொடி வினா வரை 300 வீரர்களுடன் சோதிக்கப்படுகிறது.",
|
"no_player_limit_content": "ClassQuiz க்கு செயற்கை வீரர் வரம்பு இல்லை. கோட்பாட்டில், 1000+ வீரர்கள் ஒரே நொடி வினாவை விளையாட முடியும், ஆனால் இது ஒரு நொடி வினா வரை 300 வீரர்களுடன் சோதிக்கப்படுகிறது.",
|
||||||
"import_quiz_from_kahoot_and_edit": "ஏற்றுமதி"
|
"import_quiz_from_kahoot_and_edit": "கஊட்டிலிருந்து நொடி வினாவை இறக்குமதி செய்! வகுப்பு வினாடிவினாவில் அவற்றைத் திருத்தவும்"
|
||||||
},
|
},
|
||||||
"play_page": {
|
"play_page": {
|
||||||
"join_description": "{{pin} at இல் சேரவும், {{url}}} ஐ உள்ளிடவும்.",
|
"join_description": "{{pin} at இல் சேரவும், {{url}}} ஐ உள்ளிடவும்.",
|
||||||
@@ -247,7 +247,9 @@
|
|||||||
"visit_docs": "டாக்சைப் பார்வையிடவும்.",
|
"visit_docs": "டாக்சைப் பார்வையிடவும்.",
|
||||||
"enter_answer": "ஒரு பதிலை உள்ளிடவும்",
|
"enter_answer": "ஒரு பதிலை உள்ளிடவும்",
|
||||||
"enable_reorder": "மறுதொடக்கம் பயன்முறையை இயக்கவும்",
|
"enable_reorder": "மறுதொடக்கம் பயன்முறையை இயக்கவும்",
|
||||||
"disable_reorder": "மறுதொடக்கம் பயன்முறையை முடக்கு"
|
"disable_reorder": "மறுதொடக்கம் பயன்முறையை முடக்கு",
|
||||||
|
"advanced_settings": "மேம்பட்ட அமைப்புகள்",
|
||||||
|
"hide_question_results": "கேள்வி முடிவுகளை மறைக்கவா?"
|
||||||
},
|
},
|
||||||
"import_page": {
|
"import_page": {
|
||||||
"need_help": "உதவி தேவையா?",
|
"need_help": "உதவி தேவையா?",
|
||||||
@@ -314,7 +316,8 @@
|
|||||||
"search_for_own_quizzes": "உங்கள் சொந்த நொடி வினாக்களைத் தேடுங்கள்",
|
"search_for_own_quizzes": "உங்கள் சொந்த நொடி வினாக்களைத் தேடுங்கள்",
|
||||||
"views_n_plays": "காட்சிகள் மற்றும் நாடகங்கள்",
|
"views_n_plays": "காட்சிகள் மற்றும் நாடகங்கள்",
|
||||||
"info_analytics": "\"நாடகங்கள்\" நொடி வினா எவ்வளவு அடிக்கடி தொடங்கப்பட்டது என்பதை மட்டுமே காட்டுகிறது (நீங்கள் சேர்த்துள்ளீர்கள்), அதேசமயம் \"காட்சிகள்\" \"பார்வை\"-பக்கம் எவ்வளவு அடிக்கடி பார்வையிடப்பட்டது என்பதை கணக்கிடுகிறது, எனவே இது போட்களையும் கணக்கிடுகிறது (அதற்காக மன்னிக்கவும்).",
|
"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": {
|
"footer": {
|
||||||
"self_ads": "{{mawoka_link} by மற்றும் {{others_link} of உதவியுடன் தயாரிக்கப்பட்டது.",
|
"self_ads": "{{mawoka_link} by மற்றும் {{others_link} of உதவியுடன் தயாரிக்கப்பட்டது.",
|
||||||
@@ -370,7 +373,8 @@
|
|||||||
"correct_answer": "{{count}} சரியான பதில்",
|
"correct_answer": "{{count}} சரியான பதில்",
|
||||||
"correct_answer_plural": "{{count}} சரியான பதில்கள்",
|
"correct_answer_plural": "{{count}} சரியான பதில்கள்",
|
||||||
"time_taken": "எடுக்கப்பட்ட நேரம்",
|
"time_taken": "எடுக்கப்பட்ட நேரம்",
|
||||||
"player_score": "பிளேயர் ச்கோர்"
|
"player_score": "பிளேயர் ச்கோர்",
|
||||||
|
"player_correct_questions": "சரியான பதில்கள்"
|
||||||
},
|
},
|
||||||
"controllers": {
|
"controllers": {
|
||||||
"add_new_controller": "புதிய கட்டுப்படுத்தியைச் சேர்க்கவும்",
|
"add_new_controller": "புதிய கட்டுப்படுத்தியைச் சேர்க்கவும்",
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
"multilingual_content": "",
|
"multilingual_content": "",
|
||||||
"dark_mode": "暗黑模式",
|
"dark_mode": "暗黑模式",
|
||||||
"why_classquiz": "为什么选择ClassQuiz?",
|
"why_classquiz": "为什么选择ClassQuiz?",
|
||||||
"community_driven": "",
|
"community_driven": "社区驱动",
|
||||||
"play_quiz": "2. 开始测验",
|
"play_quiz": "2. 开始测验",
|
||||||
"see_all_quizzes": "查看您的所有测验",
|
"see_all_quizzes": "查看您的所有测验",
|
||||||
"download_quizzes_content": ""
|
"download_quizzes_content": ""
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
},
|
},
|
||||||
"overview_page": {
|
"overview_page": {
|
||||||
"question_count": "",
|
"question_count": "",
|
||||||
"created_at": "",
|
"created_at": "创建于",
|
||||||
"no_quizzes": ""
|
"no_quizzes": ""
|
||||||
},
|
},
|
||||||
"settings_page": {
|
"settings_page": {
|
||||||
@@ -382,15 +382,15 @@
|
|||||||
"login_page": {
|
"login_page": {
|
||||||
"use_backup_code": "",
|
"use_backup_code": "",
|
||||||
"email_or_username": "",
|
"email_or_username": "",
|
||||||
"already_have_account": "",
|
"already_have_account": "没有账号吗?",
|
||||||
"modal": {
|
"modal": {
|
||||||
"error": {
|
"error": {
|
||||||
"description": {
|
"description": {
|
||||||
"unexpected": "",
|
"unexpected": "",
|
||||||
"wrong_creds": ""
|
"wrong_creds": "请确保你的密码和电子邮件地址是正确的。"
|
||||||
},
|
},
|
||||||
"wrong_creds": "",
|
"wrong_creds": "电子邮件地址或密码错误。",
|
||||||
"unexpected": ""
|
"unexpected": "发生了未预料的错误!"
|
||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
"success": "",
|
"success": "",
|
||||||
@@ -401,8 +401,8 @@
|
|||||||
"success_check_mail": ""
|
"success_check_mail": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"login_or_create_account": "",
|
"login_or_create_account": "登录或创建账户",
|
||||||
"welcome_back": ""
|
"welcome_back": "欢迎回来。"
|
||||||
},
|
},
|
||||||
"password_reset_page": {
|
"password_reset_page": {
|
||||||
"reset_password": ""
|
"reset_password": ""
|
||||||
|
|||||||
@@ -65,12 +65,7 @@ export interface Question {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Answers =
|
export type Answers =
|
||||||
| Answer[]
|
Answer[] | RangeQuizAnswer | VotingAnswer[] | string | TextQuizAnswer[] | OrderQuizAnswer[];
|
||||||
| RangeQuizAnswer
|
|
||||||
| VotingAnswer[]
|
|
||||||
| string
|
|
||||||
| TextQuizAnswer[]
|
|
||||||
| OrderQuizAnswer[];
|
|
||||||
|
|
||||||
export interface Answer {
|
export interface Answer {
|
||||||
right: boolean;
|
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