""" 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 backend URL in the web UI Environment variables: VELXIO_HOST — Velxio backend URL for Arduino compilation (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 logging import os import random import time import requests 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', []) # Velxio backend URL for Arduino compilation VELXIO_HOST = os.environ.get('VELXIO_HOST', 'http://localhost:8001') # 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) # --------------------------------------------------------------------------- # 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 '' resp = self.client.post( '/login', json={'token': self.token}, name='/login' ) data = resp.json() if not data.get('success'): logging.warning(f"Login failed: {data.get('message')}") # ── Task 1: Browse Lessons (weight=3) ────────────────────────────── @task(3) def browse_lessons(self): """Fetch lesson list — simulates landing on home page.""" with self.client.get('/lessons', name='/lessons', catch_response=True) as resp: data = resp.json() 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'/lesson/{slug}.json', name='/lesson/[slug].json', catch_response=True ) as resp: data = resp.json() if 'error' in 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( '/compile', json={'code': code, 'language': 'c'}, name='/compile [C]', catch_response=True ) as resp: data = resp.json() 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 and not check_key_text(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( '/compile', json={'code': code, 'language': 'python'}, name='/compile [Python]', catch_response=True ) as resp: data = resp.json() 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'/lesson/{slug}.json', name='/lesson/[arduino].json', catch_response=True ) as resp: data = resp.json() 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') # 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: 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 /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 try: resp = requests.post( f'{VELXIO_HOST}/api/compile', json={'code': code, 'board_fqbn': 'arduino:avr:uno'}, timeout=30 ) data = resp.json() if not data.get('success'): # Report as Locust failure for stats tracking 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=Exception( f"{lesson['slug']}: compile failed — " f"{data.get('error', data.get('stderr', 'unknown'))}" ), ) return hex_content = data.get('hex_content', '') if not hex_content: 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=Exception(f"{lesson['slug']}: no hex_content"), ) 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(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'/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( '/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( '/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'): try: requests.post( f'{VELXIO_HOST}/api/compile', json={'code': lesson['initial_code_arduino'], 'board_fqbn': 'arduino:avr:uno'}, timeout=30 ) except requests.RequestException: pass # Non-critical in flow time.sleep(0.5) # 3. Track progress with self.client.post( '/track-progress', json={ 'token': self.token, 'lesson_name': slug, 'status': 'completed' }, name='/track-progress', catch_response=True ) as resp: data = resp.json() if not data.get('success'): resp.failure(f"Track progress failed: {data.get('message')}") # ── 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( '/login', json={'token': TEACHER_TOKEN}, name='/login [teacher]' ) with self.client.get( '/progress-report.json', name='/progress-report.json', catch_response=True ) as resp: data = resp.json() 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( '/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" Velxio: {VELXIO_HOST}") logging.info("=" * 60)