|
| 1 | +import { test, expect } from '@playwright/test' |
| 2 | + |
| 3 | +const BASE_URL = 'http://code-academy.lvh.me:3000' |
| 4 | +const STUDENT_EMAIL = 'alice@student.com' |
| 5 | +const STUDENT_PASSWORD = 'password123' |
| 6 | +const COURSE_ID = 2001 |
| 7 | +const EXAM_ID = 2001 |
| 8 | + |
| 9 | +test.describe('AI Exam Grading E2E', () => { |
| 10 | + test('student takes exam and gets AI-graded results', async ({ page }) => { |
| 11 | + test.setTimeout(120000) // 2 minutes for AI grading |
| 12 | + // Step 1: Login as student |
| 13 | + await page.goto(`${BASE_URL}/en/auth/login`) |
| 14 | + await page.waitForLoadState('networkidle') |
| 15 | + |
| 16 | + await page.fill('input[type="email"]', STUDENT_EMAIL) |
| 17 | + await page.fill('input[type="password"]', STUDENT_PASSWORD) |
| 18 | + await page.click('button[type="submit"]') |
| 19 | + await page.waitForURL(/dashboard/, { timeout: 15000 }) |
| 20 | + console.log('✅ Logged in as student') |
| 21 | + |
| 22 | + // Step 2: Navigate to exam |
| 23 | + await page.goto(`${BASE_URL}/en/dashboard/student/courses/${COURSE_ID}/exams/${EXAM_ID}`) |
| 24 | + await page.waitForLoadState('networkidle') |
| 25 | + console.log('📋 Navigated to exam page:', page.url()) |
| 26 | + |
| 27 | + // Check we're on the exam taker (not redirected to result) |
| 28 | + const pageContent = await page.textContent('body') |
| 29 | + if (pageContent?.includes('No Questions Found')) { |
| 30 | + console.log('❌ No questions found for this exam') |
| 31 | + return |
| 32 | + } |
| 33 | + |
| 34 | + // Check if redirected to result (already submitted) |
| 35 | + if (page.url().includes('/result')) { |
| 36 | + console.log('ℹ️ Already submitted this exam, checking result page...') |
| 37 | + await checkResultPage(page) |
| 38 | + return |
| 39 | + } |
| 40 | + |
| 41 | + // Step 3: Answer all questions |
| 42 | + console.log('📝 Starting to answer questions...') |
| 43 | + const totalQuestions = 10 |
| 44 | + |
| 45 | + for (let i = 0; i < totalQuestions; i++) { |
| 46 | + // Wait for question to load |
| 47 | + await page.waitForSelector('h2', { timeout: 5000 }) |
| 48 | + const questionText = await page.textContent('h2') |
| 49 | + console.log(` Q${i + 1}: ${questionText?.substring(0, 60)}...`) |
| 50 | + |
| 51 | + // Determine question type and answer |
| 52 | + const hasRadioGroup = await page.locator('[role="radiogroup"]').count() |
| 53 | + const hasTextarea = await page.locator('textarea').count() |
| 54 | + |
| 55 | + if (hasTextarea > 0) { |
| 56 | + // Free text question - provide a substantive answer |
| 57 | + const freeTextAnswers: Record<number, string> = { |
| 58 | + 6: 'Lists are mutable ordered collections using square brackets [], while tuples are immutable ordered collections using parentheses (). Lists support append, remove, and other modification methods. Tuples cannot be modified after creation. Use lists when you need a dynamic collection that changes, like a shopping cart. Use tuples for fixed data like coordinates (x, y) or database records that should not change.', |
| 59 | + 7: 'def count_words(text: str) -> dict[str, int]:\n words = text.lower().split()\n result = {}\n for word in words:\n result[word] = result.get(word, 0) + 1\n return result\n\nThis function splits the text into words after converting to lowercase for case-insensitive counting. It uses dict.get() with a default of 0 to handle new words. An alternative would be using collections.Counter.', |
| 60 | + 8: 'EAFP stands for Easier to Ask Forgiveness than Permission. Instead of checking conditions before an operation (LBYL - Look Before You Leap), you try the operation and handle exceptions if they occur. Benefits: 1) Cleaner code without nested if checks, 2) Avoids race conditions (checking then acting is not atomic), 3) More Pythonic. Example: try: value = my_dict["key"] except KeyError: value = "default" vs if "key" in my_dict: value = my_dict["key"] else: value = "default"', |
| 61 | + 9: 'A Python project should be structured with a root directory containing a src/ or package directory with __init__.py files. __init__.py marks a directory as a Python package, allowing imports. It can be empty or contain package-level initialization code. The if __name__ == "__main__" guard allows a module to be both imported and run as a script. When run directly, __name__ is set to "__main__", so guarded code executes. When imported, __name__ is the module name, so the guarded code is skipped. Example structure: myproject/ -> src/ -> mypackage/ -> __init__.py, module1.py, module2.py, tests/, setup.py', |
| 62 | + } |
| 63 | + const answerIndex = i - (totalQuestions - Object.keys(freeTextAnswers).length) |
| 64 | + const answer = Object.values(freeTextAnswers)[answerIndex] || 'This is a test answer for the free text question.' |
| 65 | + await page.fill('textarea', answer) |
| 66 | + } else if (hasRadioGroup > 0) { |
| 67 | + // Multiple choice or true/false - click first available option |
| 68 | + const labels = page.locator('[role="radiogroup"] label') |
| 69 | + const count = await labels.count() |
| 70 | + if (count > 0) { |
| 71 | + // Pick a reasonable answer (second option for variety) |
| 72 | + const pickIndex = Math.min(1, count - 1) |
| 73 | + await labels.nth(pickIndex).click() |
| 74 | + } |
| 75 | + } |
| 76 | + |
| 77 | + // Navigate to next question or submit |
| 78 | + if (i < totalQuestions - 1) { |
| 79 | + const nextBtn = page.locator('button:has-text("Next Question")') |
| 80 | + await nextBtn.click() |
| 81 | + await page.waitForTimeout(500) // Brief pause for animation |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + console.log('✅ All questions answered') |
| 86 | + |
| 87 | + // Step 4: Submit the exam |
| 88 | + const submitBtn = page.locator('[data-testid="exam-finish-submit"]') |
| 89 | + await expect(submitBtn).toBeVisible() |
| 90 | + |
| 91 | + // Listen for console errors during submission |
| 92 | + const consoleErrors: string[] = [] |
| 93 | + page.on('console', (msg) => { |
| 94 | + if (msg.type() === 'error') { |
| 95 | + consoleErrors.push(msg.text()) |
| 96 | + } |
| 97 | + }) |
| 98 | + |
| 99 | + console.log('🚀 Submitting exam...') |
| 100 | + // Use evaluate to click - base-ui Button has issues with Playwright's .click() |
| 101 | + await page.evaluate(() => { |
| 102 | + const btn = document.querySelector('[data-testid="exam-finish-submit"]') as HTMLButtonElement |
| 103 | + if (btn) btn.click() |
| 104 | + }) |
| 105 | + |
| 106 | + // Wait for redirect to result page (AI grading may take up to 60s) |
| 107 | + await page.waitForURL(/result/, { timeout: 90000 }) |
| 108 | + console.log('✅ Redirected to result page:', page.url()) |
| 109 | + |
| 110 | + if (consoleErrors.length > 0) { |
| 111 | + console.log('⚠️ Console errors during submission:', consoleErrors) |
| 112 | + } |
| 113 | + |
| 114 | + // Step 5: Check result page |
| 115 | + await checkResultPage(page) |
| 116 | + }) |
| 117 | +}) |
| 118 | + |
| 119 | +async function checkResultPage(page: any) { |
| 120 | + await page.waitForLoadState('networkidle') |
| 121 | + await page.waitForTimeout(3000) // Give time for data to load |
| 122 | + |
| 123 | + const resultContent = await page.textContent('body') |
| 124 | + console.log('\n📊 Result page content (first 500 chars):') |
| 125 | + console.log(resultContent?.substring(0, 500)) |
| 126 | + |
| 127 | + // Check for score display |
| 128 | + if (resultContent?.includes('Score') || resultContent?.includes('score') || resultContent?.includes('%')) { |
| 129 | + console.log('✅ Score is displayed on result page') |
| 130 | + } else { |
| 131 | + console.log('⚠️ No score visible on result page') |
| 132 | + } |
| 133 | + |
| 134 | + // Check for feedback |
| 135 | + if (resultContent?.includes('feedback') || resultContent?.includes('Feedback') || resultContent?.includes('Correct') || resultContent?.includes('Incorrect')) { |
| 136 | + console.log('✅ Feedback is displayed on result page') |
| 137 | + } else { |
| 138 | + console.log('⚠️ No feedback visible on result page') |
| 139 | + } |
| 140 | +} |
0 commit comments