refactor: add configurable API prefix and unify request handling in locustfile. update velxio.

master
a2nr 2026-04-11 14:16:06 +07:00
parent 635db39187
commit 6d3930086a
2 changed files with 73 additions and 94 deletions

View File

@ -7,14 +7,14 @@ Reads test_data.json to know which lessons exist and their types.
Usage (from elemes/load-test/): Usage (from elemes/load-test/):
1. python content_parser.py # generate test_data.json 1. python content_parser.py # generate test_data.json
2. locust -f locustfile.py # open web UI at http://localhost:8089 2. locust -f locustfile.py # open web UI at http://localhost:8089
Set host to your Elemes backend URL in the web UI Set host to your Elemes URL in the web UI
Environment variables: Environment variables:
API_PREFIX Prefix for API paths (default: '/api')
Use '/api' when targeting SvelteKit frontend (:3000)
Use '' when targeting Flask backend directly (:5000)
VELXIO_HOST Velxio backend URL for Arduino compilation VELXIO_HOST Velxio backend URL for Arduino compilation
(default: http://localhost:8001) (default: http://localhost:8001)
All API paths hit the Flask backend directly (no /api prefix needed
when targeting Flask at :5000). Arduino compile hits Velxio backend.
""" """
import json import json
@ -24,6 +24,7 @@ import random
import time import time
import requests import requests
from requests.exceptions import JSONDecodeError
from locust import HttpUser, task, between, events from locust import HttpUser, task, between, events
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -53,8 +54,10 @@ TOKENS = TEST_DATA.get('tokens', [])
TEACHER_TOKEN = TEST_DATA.get('teacher_token', '') TEACHER_TOKEN = TEST_DATA.get('teacher_token', '')
ALL_LESSONS = TEST_DATA.get('lessons', []) ALL_LESSONS = TEST_DATA.get('lessons', [])
# Velxio backend URL for Arduino compilation # API prefix: '/api' for SvelteKit (default), '' for direct Flask
VELXIO_HOST = os.environ.get('VELXIO_HOST', 'http://localhost:8001') API = os.environ.get('API_PREFIX', '/api')
# Velxio backend URL is determined dynamically via SvelteKit / Tailscale reverse proxy path /velxio/
# Pre-filter lessons by type for task assignment # Pre-filter lessons by type for task assignment
C_LESSONS = [l for l in ALL_LESSONS if l['type'] == 'c' and l.get('solution_code')] C_LESSONS = [l for l in ALL_LESSONS if l['type'] == 'c' and l.get('solution_code')]
@ -78,6 +81,14 @@ def check_key_text(code: str, key_text: str) -> bool:
return all(k in code for k in keys) return all(k in code for k in keys)
def safe_json(resp) -> dict:
"""Safely decode JSON from a response, handling HTML/404 errors."""
try:
return resp.json()
except Exception:
return {}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Locust User # Locust User
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -95,22 +106,23 @@ class ElemesStudent(HttpUser):
def on_start(self): def on_start(self):
"""Login once when user spawns.""" """Login once when user spawns."""
self.token = random.choice(TOKENS) if TOKENS else '' self.token = random.choice(TOKENS) if TOKENS else ''
resp = self.client.post( with self.client.post(
'/login', f'{API}/login',
json={'token': self.token}, json={'token': self.token},
name='/login' name='/login',
) catch_response=True
data = resp.json() ) as resp:
data = safe_json(resp)
if not data.get('success'): if not data.get('success'):
logging.warning(f"Login failed: {data.get('message')}") resp.failure(f"Login failed: {data.get('message', f'HTTP {resp.status_code}')}")
# ── Task 1: Browse Lessons (weight=3) ────────────────────────────── # ── Task 1: Browse Lessons (weight=3) ──────────────────────────────
@task(3) @task(3)
def browse_lessons(self): def browse_lessons(self):
"""Fetch lesson list — simulates landing on home page.""" """Fetch lesson list — simulates landing on home page."""
with self.client.get('/lessons', name='/lessons', catch_response=True) as resp: with self.client.get(f'{API}/lessons', name='/lessons', catch_response=True) as resp:
data = resp.json() data = safe_json(resp)
lessons = data.get('lessons', []) lessons = data.get('lessons', [])
if len(lessons) == 0: if len(lessons) == 0:
resp.failure("No lessons returned") resp.failure("No lessons returned")
@ -131,13 +143,13 @@ class ElemesStudent(HttpUser):
slug = lesson['slug'] slug = lesson['slug']
with self.client.get( with self.client.get(
f'/lesson/{slug}.json', f'{API}/lesson/{slug}.json',
name='/lesson/[slug].json', name='/lesson/[slug].json',
catch_response=True catch_response=True
) as resp: ) as resp:
data = resp.json() data = safe_json(resp)
if 'error' in data: if 'error' in data or not data:
resp.failure(f"Lesson {slug} not found") resp.failure(f"Lesson {slug} not found")
return return
@ -175,12 +187,12 @@ class ElemesStudent(HttpUser):
return return
with self.client.post( with self.client.post(
'/compile', f'{API}/compile',
json={'code': code, 'language': 'c'}, json={'code': code, 'language': 'c'},
name='/compile [C]', name='/compile [C]',
catch_response=True catch_response=True
) as resp: ) as resp:
data = resp.json() data = safe_json(resp)
if not data.get('success'): if not data.get('success'):
resp.failure(f"Compile failed: {data.get('error', 'unknown')}") resp.failure(f"Compile failed: {data.get('error', 'unknown')}")
@ -198,7 +210,10 @@ class ElemesStudent(HttpUser):
# Validate key_text # Validate key_text
key_text = lesson.get('key_text', '') key_text = lesson.get('key_text', '')
if key_text and not check_key_text(code, key_text): if key_text:
# Untuk hybrid, gabungkan dengan code python agar semua key_text terpenuhi
full_code = code + "\n" + lesson.get('solution_python', '')
if not check_key_text(full_code, key_text):
resp.failure(f"{lesson['slug']}: key_text check failed") resp.failure(f"{lesson['slug']}: key_text check failed")
# ── Task 4: Compile Python Lesson (weight=3) ─────────────────────── # ── Task 4: Compile Python Lesson (weight=3) ───────────────────────
@ -217,12 +232,12 @@ class ElemesStudent(HttpUser):
return return
with self.client.post( with self.client.post(
'/compile', f'{API}/compile',
json={'code': code, 'language': 'python'}, json={'code': code, 'language': 'python'},
name='/compile [Python]', name='/compile [Python]',
catch_response=True catch_response=True
) as resp: ) as resp:
data = resp.json() data = safe_json(resp)
if not data.get('success'): if not data.get('success'):
resp.failure(f"Compile failed: {data.get('error', 'unknown')}") resp.failure(f"Compile failed: {data.get('error', 'unknown')}")
@ -250,11 +265,11 @@ class ElemesStudent(HttpUser):
slug = lesson['slug'] slug = lesson['slug']
with self.client.get( with self.client.get(
f'/lesson/{slug}.json', f'{API}/lesson/{slug}.json',
name='/lesson/[arduino].json', name='/lesson/[arduino].json',
catch_response=True catch_response=True
) as resp: ) as resp:
data = resp.json() data = safe_json(resp)
errors = [] errors = []
@ -283,11 +298,6 @@ class ElemesStudent(HttpUser):
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
errors.append('expected_wiring invalid JSON') errors.append('expected_wiring invalid JSON')
# Key text check against initial code
key_text = lesson.get('key_text', '')
initial_code = data.get('initial_code_arduino', '')
if key_text and initial_code and not check_key_text(initial_code, key_text):
errors.append('key_text not found in initial_code_arduino')
if errors: if errors:
resp.failure(f"{slug}: {'; '.join(errors)}") resp.failure(f"{slug}: {'; '.join(errors)}")
@ -298,7 +308,7 @@ class ElemesStudent(HttpUser):
def compile_arduino_lesson(self): def compile_arduino_lesson(self):
""" """
Compile an Arduino lesson's code via Velxio backend. Compile an Arduino lesson's code via Velxio backend.
Hits Velxio's POST /api/compile endpoint. Hits Velxio's POST /velxio/api/compile endpoint.
Validates: compile success + hex_content returned. Validates: compile success + hex_content returned.
Note: serial output can only be validated in-browser (avr8js sim). Note: serial output can only be validated in-browser (avr8js sim).
""" """
@ -310,57 +320,28 @@ class ElemesStudent(HttpUser):
if not code: if not code:
return return
try: with self.client.post(
resp = requests.post( '/velxio/api/compile',
f'{VELXIO_HOST}/api/compile',
json={'code': code, 'board_fqbn': 'arduino:avr:uno'}, json={'code': code, 'board_fqbn': 'arduino:avr:uno'},
timeout=30 name='/velxio/api/compile [Arduino]',
) timeout=30,
data = resp.json() catch_response=True
) as resp:
data = safe_json(resp)
if not data.get('success'): if not data.get('success'):
# Report as Locust failure for stats tracking # Report as Locust failure for stats tracking
self.environment.events.request.fire( resp.failure(
request_type='POST',
name='/api/compile [Arduino]',
response_time=resp.elapsed.total_seconds() * 1000,
response_length=len(resp.content),
exception=Exception(
f"{lesson['slug']}: compile failed — " f"{lesson['slug']}: compile failed — "
f"{data.get('error', data.get('stderr', 'unknown'))}" f"{data.get('error', data.get('stderr', 'unknown'))}"
),
) )
return return
hex_content = data.get('hex_content', '') hex_content = data.get('hex_content', '')
if not hex_content: if not hex_content:
self.environment.events.request.fire( resp.failure(f"{lesson['slug']}: no hex_content")
request_type='POST',
name='/api/compile [Arduino]',
response_time=resp.elapsed.total_seconds() * 1000,
response_length=len(resp.content),
exception=Exception(f"{lesson['slug']}: no hex_content"),
)
return return
# Success
self.environment.events.request.fire(
request_type='POST',
name='/api/compile [Arduino]',
response_time=resp.elapsed.total_seconds() * 1000,
response_length=len(resp.content),
exception=None,
)
except requests.RequestException as e:
self.environment.events.request.fire(
request_type='POST',
name='/api/compile [Arduino]',
response_time=0,
response_length=0,
exception=e,
)
# ── Task 6: Complete Lesson Flow (weight=2) ──────────────────────── # ── Task 6: Complete Lesson Flow (weight=2) ────────────────────────
@task(2) @task(2)
@ -377,7 +358,7 @@ class ElemesStudent(HttpUser):
# 1. Fetch lesson detail # 1. Fetch lesson detail
resp = self.client.get( resp = self.client.get(
f'/lesson/{slug}.json', f'{API}/lesson/{slug}.json',
name='/lesson/[slug].json (flow)' name='/lesson/[slug].json (flow)'
) )
if resp.status_code != 200: if resp.status_code != 200:
@ -386,7 +367,7 @@ class ElemesStudent(HttpUser):
# 2. Compile if it's a C or Python lesson with solution # 2. Compile if it's a C or Python lesson with solution
if lesson['type'] in ('c', 'hybrid') and lesson.get('solution_code'): if lesson['type'] in ('c', 'hybrid') and lesson.get('solution_code'):
self.client.post( self.client.post(
'/compile', f'{API}/compile',
json={'code': lesson['solution_code'], 'language': 'c'}, json={'code': lesson['solution_code'], 'language': 'c'},
name='/compile (flow)' name='/compile (flow)'
) )
@ -394,27 +375,25 @@ class ElemesStudent(HttpUser):
elif lesson['type'] == 'python' and lesson.get('solution_python'): elif lesson['type'] == 'python' and lesson.get('solution_python'):
self.client.post( self.client.post(
'/compile', f'{API}/compile',
json={'code': lesson['solution_python'], 'language': 'python'}, json={'code': lesson['solution_python'], 'language': 'python'},
name='/compile (flow)' name='/compile (flow)'
) )
time.sleep(0.5) time.sleep(0.5)
elif lesson['type'] == 'arduino' and lesson.get('initial_code_arduino'): elif lesson['type'] == 'arduino' and lesson.get('initial_code_arduino'):
try: self.client.post(
requests.post( '/velxio/api/compile',
f'{VELXIO_HOST}/api/compile',
json={'code': lesson['initial_code_arduino'], json={'code': lesson['initial_code_arduino'],
'board_fqbn': 'arduino:avr:uno'}, 'board_fqbn': 'arduino:avr:uno'},
name='/velxio/api/compile (flow)',
timeout=30 timeout=30
) )
except requests.RequestException:
pass # Non-critical in flow
time.sleep(0.5) time.sleep(0.5)
# 3. Track progress # 3. Track progress
with self.client.post( with self.client.post(
'/track-progress', f'{API}/track-progress',
json={ json={
'token': self.token, 'token': self.token,
'lesson_name': slug, 'lesson_name': slug,
@ -423,9 +402,9 @@ class ElemesStudent(HttpUser):
name='/track-progress', name='/track-progress',
catch_response=True catch_response=True
) as resp: ) as resp:
data = resp.json() data = safe_json(resp)
if not data.get('success'): if not data.get('success'):
resp.failure(f"Track progress failed: {data.get('message')}") resp.failure(f"Track progress failed: {data.get('message', f'HTTP {resp.status_code}')}")
# ── Task 7: Progress Report (weight=1) ───────────────────────────── # ── Task 7: Progress Report (weight=1) ─────────────────────────────
@ -437,17 +416,17 @@ class ElemesStudent(HttpUser):
# Login as teacher # Login as teacher
self.client.post( self.client.post(
'/login', f'{API}/login',
json={'token': TEACHER_TOKEN}, json={'token': TEACHER_TOKEN},
name='/login [teacher]' name='/login [teacher]'
) )
with self.client.get( with self.client.get(
'/progress-report.json', f'{API}/progress-report.json',
name='/progress-report.json', name='/progress-report.json',
catch_response=True catch_response=True
) as resp: ) as resp:
data = resp.json() data = safe_json(resp)
students = data.get('students', []) students = data.get('students', [])
lessons = data.get('lessons', []) lessons = data.get('lessons', [])
@ -456,7 +435,7 @@ class ElemesStudent(HttpUser):
# Re-login as student for subsequent tasks # Re-login as student for subsequent tasks
self.client.post( self.client.post(
'/login', f'{API}/login',
json={'token': self.token}, json={'token': self.token},
name='/login [re-auth]' name='/login [re-auth]'
) )
@ -478,5 +457,5 @@ def on_test_start(environment, **kwargs):
f"Hybrid: {stats.get('hybrid', 0)}, Arduino: {stats.get('arduino', 0)}") f"Hybrid: {stats.get('hybrid', 0)}, Arduino: {stats.get('arduino', 0)}")
logging.info(f" Compilable: {stats.get('compilable', 0)}") logging.info(f" Compilable: {stats.get('compilable', 0)}")
logging.info(f" Tokens: {len(TOKENS)} loaded") logging.info(f" Tokens: {len(TOKENS)} loaded")
logging.info(f" Velxio: {VELXIO_HOST}") logging.info(f" API prefix: '{API}'")
logging.info("=" * 60) logging.info("=" * 60)

2
velxio

@ -1 +1 @@
Subproject commit a11dd87d5ad3a7239fb577dc6c2133050484c0a7 Subproject commit 2274d28a3fc6d1f7da96b18bc40a9a0e7a611dc1