""" Locust E2E Test Suite for Elemes LMS. Auto-generated test scenarios from content/ folder via content_parser.py. Reads test_data.json to know which lessons exist and their types. Usage (from elemes/load-test/): 1. python content_parser.py # generate test_data.json 2. locust -f locustfile.py # open web UI at http://localhost:8089 → Set host to your Elemes URL in the web UI 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 (default: http://localhost:8001) """ import json import logging import os import random import time import requests from requests.exceptions import JSONDecodeError from locust import HttpUser, task, between, events # --------------------------------------------------------------------------- # Load test data # --------------------------------------------------------------------------- TEST_DATA_FILE = os.path.join(os.path.dirname(__file__), 'test_data.json') def load_test_data() -> dict: """Load test_data.json generated by content_parser.py.""" if not os.path.exists(TEST_DATA_FILE): raise FileNotFoundError( f"{TEST_DATA_FILE} not found. " f"Run 'python content_parser.py' first." ) with open(TEST_DATA_FILE, 'r', encoding='utf-8') as f: return json.load(f) try: TEST_DATA = load_test_data() except FileNotFoundError as e: logging.error(str(e)) TEST_DATA = {'token': '', 'teacher_token': '', 'lessons': []} TOKENS = TEST_DATA.get('tokens', []) TEACHER_TOKEN = TEST_DATA.get('teacher_token', '') ALL_LESSONS = TEST_DATA.get('lessons', []) # API prefix: '/api' for SvelteKit (default), '' for direct Flask 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 C_LESSONS = [l for l in ALL_LESSONS if l['type'] == 'c' and l.get('solution_code')] PYTHON_LESSONS = [l for l in ALL_LESSONS if l['type'] == 'python' and l.get('solution_python')] HYBRID_LESSONS = [l for l in ALL_LESSONS if l['type'] == 'hybrid'] ARDUINO_LESSONS = [l for l in ALL_LESSONS if l['type'] == 'arduino'] COMPILABLE_LESSONS = C_LESSONS + PYTHON_LESSONS + [ l for l in HYBRID_LESSONS if l.get('solution_code') or l.get('solution_python') ] # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def check_key_text(code: str, key_text: str) -> bool: """Client-side key_text check (mirrors exercise.ts logic).""" if not key_text.strip(): return True keys = [k.strip() for k in key_text.split('\n') if k.strip()] 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 # --------------------------------------------------------------------------- class ElemesStudent(HttpUser): """ Simulates a student interacting with the Elemes LMS. Each user logs in once (on_start), then performs weighted tasks that mirror real student behavior. """ wait_time = between(1, 5) def on_start(self): """Login once when user spawns.""" self.token = random.choice(TOKENS) if TOKENS else '' with self.client.post( f'{API}/login', json={'token': self.token}, name='/login', catch_response=True ) as resp: data = safe_json(resp) if not data.get('success'): resp.failure(f"Login failed: {data.get('message', f'HTTP {resp.status_code}')}") # ── Task 1: Browse Lessons (weight=3) ────────────────────────────── @task(3) def browse_lessons(self): """Fetch lesson list — simulates landing on home page.""" with self.client.get(f'{API}/lessons', name='/lessons', catch_response=True) as resp: data = safe_json(resp) lessons = data.get('lessons', []) if len(lessons) == 0: resp.failure("No lessons returned") elif len(lessons) != len(ALL_LESSONS): resp.failure( f"Expected {len(ALL_LESSONS)} lessons, got {len(lessons)}" ) # ── Task 2: View Lesson Detail (weight=5) ────────────────────────── @task(5) def view_lesson_detail(self): """Fetch a random lesson's detail JSON.""" if not ALL_LESSONS: return lesson = random.choice(ALL_LESSONS) slug = lesson['slug'] with self.client.get( f'{API}/lesson/{slug}.json', name='/lesson/[slug].json', catch_response=True ) as resp: data = safe_json(resp) if 'error' in data or not data: resp.failure(f"Lesson {slug} not found") return # Validate fields based on type if lesson['type'] == 'arduino': if not data.get('initial_code_arduino'): resp.failure(f"{slug}: missing initial_code_arduino") if not data.get('velxio_circuit'): resp.failure(f"{slug}: missing velxio_circuit") # Validate velxio_circuit is valid JSON try: json.loads(data['velxio_circuit']) except (json.JSONDecodeError, TypeError): resp.failure(f"{slug}: velxio_circuit is not valid JSON") elif lesson['type'] in ('c', 'hybrid'): if not data.get('initial_code'): resp.failure(f"{slug}: missing initial_code") elif lesson['type'] == 'python': if not data.get('initial_python') and not data.get('initial_code'): resp.failure(f"{slug}: missing initial code") # ── Task 3: Compile C Lesson (weight=4) ──────────────────────────── @task(4) def compile_c_lesson(self): """Compile a C lesson's solution and validate output.""" if not C_LESSONS: return lesson = random.choice(C_LESSONS) code = lesson.get('solution_code', lesson.get('initial_code_c', '')) expected = lesson.get('expected_output', '') if not code: return with self.client.post( f'{API}/compile', json={'code': code, 'language': 'c'}, name='/compile [C]', catch_response=True ) as resp: data = safe_json(resp) if not data.get('success'): resp.failure(f"Compile failed: {data.get('error', 'unknown')}") return output = data.get('output', '') # Validate expected output if expected and expected.strip() not in output: resp.failure( f"{lesson['slug']}: expected '{expected.strip()[:50]}' " f"not in output '{output[:50]}'" ) return # Validate key_text key_text = lesson.get('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") # ── Task 4: Compile Python Lesson (weight=3) ─────────────────────── @task(3) def compile_python_lesson(self): """Compile a Python lesson's solution and validate output.""" if not PYTHON_LESSONS: return lesson = random.choice(PYTHON_LESSONS) code = lesson.get('solution_python', lesson.get('initial_python', '')) expected = lesson.get('expected_output_python', lesson.get('expected_output', '')) if not code: return with self.client.post( f'{API}/compile', json={'code': code, 'language': 'python'}, name='/compile [Python]', catch_response=True ) as resp: data = safe_json(resp) if not data.get('success'): resp.failure(f"Compile failed: {data.get('error', 'unknown')}") return output = data.get('output', '') if expected and expected.strip() not in output: resp.failure( f"{lesson['slug']}: expected '{expected.strip()[:50]}' " f"not in output '{output[:50]}'" ) # ── Task 5: Verify Arduino Lesson Structure (weight=2) ───────────── @task(2) def verify_arduino_lesson(self): """ Verify an Arduino lesson's data structure. Validates JSON integrity of velxio_circuit and expected_wiring. """ if not ARDUINO_LESSONS: return lesson = random.choice(ARDUINO_LESSONS) slug = lesson['slug'] with self.client.get( f'{API}/lesson/{slug}.json', name='/lesson/[arduino].json', catch_response=True ) as resp: data = safe_json(resp) errors = [] # Check required fields if not data.get('initial_code_arduino'): errors.append('missing initial_code_arduino') vc = data.get('velxio_circuit', '') if vc: try: circuit = json.loads(vc) if 'board' not in circuit: errors.append('velxio_circuit missing board field') except (json.JSONDecodeError, TypeError): errors.append('velxio_circuit invalid JSON') else: errors.append('missing velxio_circuit') # Check expected_wiring JSON validity if present ew = data.get('expected_wiring', '') if ew: try: wiring = json.loads(ew) if 'wires' not in wiring: errors.append('expected_wiring missing wires array') except (json.JSONDecodeError, TypeError): errors.append('expected_wiring invalid JSON') if errors: resp.failure(f"{slug}: {'; '.join(errors)}") # ── Task 8: Compile Arduino via Velxio (weight=3) ────────────────── @task(3) def compile_arduino_lesson(self): """ Compile an Arduino lesson's code via Velxio backend. Hits Velxio's POST /velxio/api/compile/ endpoint. Validates: compile success + hex_content returned. Note: serial output can only be validated in-browser (avr8js sim). """ if not ARDUINO_LESSONS: return lesson = random.choice(ARDUINO_LESSONS) code = lesson.get('initial_code_arduino', '') if not code: return with self.client.post( '/velxio/api/compile/', json={ 'files': [{'name': 'sketch.ino', 'content': code}], 'board_fqbn': 'arduino:avr:uno' }, name='/velxio/api/compile [Arduino]', timeout=30, catch_response=True ) as resp: data = safe_json(resp) if not data.get('success'): # Report as Locust failure for stats tracking # Fallback to checking 'detail' for HTTP 422 validations err_msg = data.get('error', data.get('stderr')) if not err_msg and data.get('detail'): err_msg = str(data.get('detail')) elif not err_msg: err_msg = f"HTTP {resp.status_code} - unknown" resp.failure(f"{lesson['slug']}: compile failed — {err_msg}") return hex_content = data.get('hex_content', '') if not hex_content: resp.failure(f"{lesson['slug']}: no hex_content") return # ── Task 6: Complete Lesson Flow (weight=2) ──────────────────────── @task(2) def complete_lesson_flow(self): """ Full flow: fetch lesson → compile (if possible) → track progress. Simulates a student completing a lesson. """ if not ALL_LESSONS: return lesson = random.choice(ALL_LESSONS) slug = lesson['slug'] # 1. Fetch lesson detail resp = self.client.get( f'{API}/lesson/{slug}.json', name='/lesson/[slug].json (flow)' ) if resp.status_code != 200: return # 2. Compile if it's a C or Python lesson with solution if lesson['type'] in ('c', 'hybrid') and lesson.get('solution_code'): self.client.post( f'{API}/compile', json={'code': lesson['solution_code'], 'language': 'c'}, name='/compile (flow)' ) time.sleep(0.5) # Simulate student reading output elif lesson['type'] == 'python' and lesson.get('solution_python'): self.client.post( f'{API}/compile', json={'code': lesson['solution_python'], 'language': 'python'}, name='/compile (flow)' ) time.sleep(0.5) elif lesson['type'] == 'arduino' and lesson.get('initial_code_arduino'): self.client.post( '/velxio/api/compile/', json={ 'files': [{'name': 'sketch.ino', 'content': lesson['initial_code_arduino']}], 'board_fqbn': 'arduino:avr:uno' }, name='/velxio/api/compile (flow)', timeout=30 ) time.sleep(0.5) # 3. Track progress with self.client.post( f'{API}/track-progress', json={ 'token': self.token, 'lesson_name': slug, 'status': 'completed' }, name='/track-progress', catch_response=True ) as resp: data = safe_json(resp) if not data.get('success'): resp.failure(f"Track progress failed: {data.get('message', f'HTTP {resp.status_code}')}") # ── Task 7: Progress Report (weight=1) ───────────────────────────── @task(1) def view_progress_report(self): """Fetch progress report (teacher perspective).""" if not TEACHER_TOKEN: return # Login as teacher self.client.post( f'{API}/login', json={'token': TEACHER_TOKEN}, name='/login [teacher]' ) with self.client.get( f'{API}/progress-report.json', name='/progress-report.json', catch_response=True ) as resp: data = safe_json(resp) students = data.get('students', []) lessons = data.get('lessons', []) if not lessons: resp.failure("No lessons in progress report") # Re-login as student for subsequent tasks self.client.post( f'{API}/login', json={'token': self.token}, name='/login [re-auth]' ) # --------------------------------------------------------------------------- # Event hooks # --------------------------------------------------------------------------- @events.test_start.add_listener def on_test_start(environment, **kwargs): """Log test configuration at start.""" stats = TEST_DATA.get('stats', {}) logging.info("=" * 60) logging.info(" Elemes LMS — Locust E2E Test") logging.info("=" * 60) logging.info(f" Lessons: {stats.get('total', 0)} total") logging.info(f" C: {stats.get('c', 0)}, Python: {stats.get('python', 0)}, " f"Hybrid: {stats.get('hybrid', 0)}, Arduino: {stats.get('arduino', 0)}") logging.info(f" Compilable: {stats.get('compilable', 0)}") logging.info(f" Tokens: {len(TOKENS)} loaded") logging.info(f" API prefix: '{API}'") logging.info("=" * 60)