diff --git a/.gitignore b/.gitignore index 91c1447..a972c7e 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts .env.e2e + +test-results/ +playwright-report/ +playwright/.cache/ diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index 131e2ab..a71f417 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -31,19 +31,26 @@ const BASE_URL = 'http://localhost:3000'; // ─── Helper: Login via UI ───────────────────────────────── async function loginViaUI(page: Page) { - await page.goto(`${BASE_URL}/login`); - await page.waitForLoadState('networkidle'); + // Clear any existing session so login page doesn't redirect away + await page.goto(`${BASE_URL}/login`, { waitUntil: 'load' }); + await page.evaluate(() => { + localStorage.removeItem('ozymorlab_token'); + localStorage.removeItem('ozymorlab_refresh_token'); + }); + await page.reload({ waitUntil: 'load' }); + + // Client-side hydration after load + await page.waitForTimeout(500); + await page.waitForSelector('#login-email', { timeout: 10000 }); await page.fill('#login-email', TEST_EMAIL); await page.fill('#login-password', TEST_PASSWORD); await page.click('#login-submit'); - // Wait for either the success message or direct redirect - // The login flow is: Supabase auth → backend /auth/me → 800ms delay → router.push('/dashboard') + // Login flow: POST /auth/login → 800ms delay → router.push('/dashboard') try { await page.waitForURL('**/dashboard**', { timeout: 30000 }); } catch { - // If URL didn't change, check if there's an error displayed const errorEl = page.locator('.auth-error'); if (await errorEl.isVisible()) { const errorText = await errorEl.textContent(); @@ -59,15 +66,14 @@ async function loginViaUI(page: Page) { test.describe('Landing Page', () => { test('should render branding and CTA links', async ({ page }) => { await page.goto(BASE_URL); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.locator('a[href="/login"]')).toBeVisible(); - await expect(page.locator('a[href="/login?tab=signup"]').first()).toBeVisible(); }); test('Sign In link navigates to login page', async ({ page }) => { await page.goto(BASE_URL); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.locator('a[href="/login"]').click(); await page.waitForURL('**/login**'); @@ -81,17 +87,17 @@ test.describe('Landing Page', () => { test.describe('Login Page UI', () => { test('should render login form fields', async ({ page }) => { await page.goto(`${BASE_URL}/login`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.locator('#login-email')).toBeVisible(); await expect(page.locator('#login-password')).toBeVisible(); await expect(page.locator('#login-submit')).toBeVisible(); - await expect(page.locator('text=Edexia AIOS')).toBeVisible(); + await expect(page.locator('text=OzymorLab AIOS')).toBeVisible(); }); test('should toggle between Sign In and Create Account', async ({ page }) => { await page.goto(`${BASE_URL}/login`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.click('button:has-text("Create Account")'); await expect(page.locator('#signup-name')).toBeVisible(); @@ -103,7 +109,7 @@ test.describe('Login Page UI', () => { test('should toggle password visibility', async ({ page }) => { await page.goto(`${BASE_URL}/login`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.fill('#login-password', 'test'); await page.locator('.form-input-toggle').click(); @@ -112,7 +118,7 @@ test.describe('Login Page UI', () => { test('should validate short signup password', async ({ page }) => { await page.goto(`${BASE_URL}/login?tab=signup`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.fill('#signup-name', 'Test'); await page.fill('#signup-email', 'test@test.com'); @@ -128,7 +134,7 @@ test.describe('Login Page UI', () => { test('should show Google sign-in button', async ({ page }) => { await page.goto(`${BASE_URL}/login`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.locator('button:has-text("Sign In with Google")')).toBeVisible(); }); @@ -169,7 +175,7 @@ test.describe('Dashboard (Authenticated)', () => { test('should render sidebar with all nav links', async ({ page }) => { await page.goto(`${BASE_URL}/dashboard`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); for (const link of ['Dashboard', 'Exams Setup', 'Submissions', 'Students', 'Reviews', 'Reports']) { await expect(page.locator(`text=${link}`).first()).toBeVisible(); @@ -178,7 +184,7 @@ test.describe('Dashboard (Authenticated)', () => { test('should show topbar search input', async ({ page }) => { await page.goto(`${BASE_URL}/dashboard`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.locator('input[placeholder*="Search"]').first()).toBeVisible(); }); @@ -196,7 +202,7 @@ test.describe('Submissions Page (Authenticated)', () => { test('should render header and reload button', async ({ page }) => { await page.goto(`${BASE_URL}/dashboard/submissions`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.locator('text=Evaluation Submissions')).toBeVisible(); await expect(page.locator('text=Reload Queue')).toBeVisible(); @@ -204,7 +210,7 @@ test.describe('Submissions Page (Authenticated)', () => { test('should show search input and filter pills', async ({ page }) => { await page.goto(`${BASE_URL}/dashboard/submissions`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.locator('input[placeholder*="Search Student ID"]')).toBeVisible(); for (const s of ['ALL', 'GRADED', 'FAILED', 'PENDING']) { @@ -214,7 +220,7 @@ test.describe('Submissions Page (Authenticated)', () => { test('should show table headers', async ({ page }) => { await page.goto(`${BASE_URL}/dashboard/submissions`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); for (const h of ['Student ID', 'Filename', 'Created Time', 'Status', 'Actions']) { await expect(page.locator(`th:has-text("${h}")`)).toBeVisible(); @@ -223,7 +229,7 @@ test.describe('Submissions Page (Authenticated)', () => { test('filter pills should be clickable', async ({ page }) => { await page.goto(`${BASE_URL}/dashboard/submissions`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.click('button:has-text("PENDING")'); await page.waitForTimeout(300); @@ -243,7 +249,7 @@ test.describe('Students Page (Authenticated)', () => { test('should render directory with stat cards', async ({ page }) => { await page.goto(`${BASE_URL}/dashboard/students`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.locator('text=Students Directory')).toBeVisible(); await expect(page.locator('text=Total Students Registered')).toBeVisible(); @@ -252,7 +258,7 @@ test.describe('Students Page (Authenticated)', () => { test('should have search and batch filter', async ({ page }) => { await page.goto(`${BASE_URL}/dashboard/students`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.locator('input[placeholder*="Search Student ID"]')).toBeVisible(); await expect(page.locator('button:has-text("All Cohorts")')).toBeVisible(); @@ -273,7 +279,7 @@ test.describe('Reviews Page (Authenticated)', () => { test('should render moderation center', async ({ page }) => { await page.goto(`${BASE_URL}/dashboard/reviews`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.locator('text=Institutional Moderation')).toBeVisible(); await expect(page.locator('text=Refresh Lists')).toBeVisible(); @@ -293,7 +299,7 @@ test.describe('Reports Page (Authenticated)', () => { test('should render reports dashboard with stats', async ({ page }) => { await page.goto(`${BASE_URL}/dashboard/reports`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.locator('text=Institutional Reports')).toBeVisible(); for (const label of ['Active Institutional Roster', 'AI Papers Evaluated', 'Overall Average Grade', 'Assessment Pass Percentage']) { @@ -303,7 +309,7 @@ test.describe('Reports Page (Authenticated)', () => { test('should show performance registry with search', async ({ page }) => { await page.goto(`${BASE_URL}/dashboard/reports`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.locator('text=Student Performance Registry')).toBeVisible(); await expect(page.locator('input[placeholder*="Search Student"]')).toBeVisible(); @@ -322,7 +328,7 @@ test.describe('Admin Page (Authenticated)', () => { test('should render admin panel with tabs', async ({ page }) => { await page.goto(`${BASE_URL}/dashboard/admin`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.locator('text=Institutional Administration Panel')).toBeVisible(); await expect(page.locator('button:has-text("Student Imports")')).toBeVisible(); @@ -332,7 +338,7 @@ test.describe('Admin Page (Authenticated)', () => { test('should switch tabs', async ({ page }) => { await page.goto(`${BASE_URL}/dashboard/admin`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.click('button:has-text("Teacher Invites")'); await expect(page.locator('text=Bulk Invite Educators')).toBeVisible(); @@ -358,33 +364,33 @@ test.describe('E2E Navigation Flow', () => { // Exams await page.click('text=Exams Setup'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page).toHaveURL(/exams/); // Submissions await page.click('text=Submissions'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page).toHaveURL(/submissions/); await expect(page.locator('text=Evaluation Submissions')).toBeVisible(); // Students await page.click('text=Students'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page).toHaveURL(/students/); await expect(page.locator('text=Students Directory')).toBeVisible(); // Reviews await page.click('text=Reviews'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page).toHaveURL(/reviews/); // Reports await page.click('text=Reports'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page).toHaveURL(/reports/); // Back to Dashboard await page.click('a:has-text("Dashboard")'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); }); }); diff --git a/e2e/journey.spec.ts b/e2e/journey.spec.ts new file mode 100644 index 0000000..b0a99b9 --- /dev/null +++ b/e2e/journey.spec.ts @@ -0,0 +1,503 @@ +import { test, expect, Page } from '@playwright/test'; + +const BASE_URL = 'http://localhost:3000'; + +const MOCK_USER = { + id: 'e2e-mock-user-id', + email: 'e2e-mock@ozymorlab.test', + full_name: 'E2E Test User', + has_gemini_key: true, + is_active: true, +}; + +async function mockAuth(page: Page, role: string = 'teacher') { + const ctx = page.context(); + await ctx.route('**/auth/me', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: { ...MOCK_USER, role } }), + }); + }); + await page.goto(`${BASE_URL}/login`); + await page.waitForLoadState('load'); + await page.evaluate(() => { + localStorage.setItem('ozymorlab_token', 'e2e-mock-token'); + localStorage.setItem('ozymorlab_refresh_token', 'e2e-mock-refresh'); + }); + await page.reload(); + try { + await page.waitForURL('**/dashboard**', { timeout: 15000 }); + } catch { + // Retry: re-set tokens and reload if redirect failed (Fast Refresh race) + await page.evaluate(() => { + localStorage.setItem('ozymorlab_token', 'e2e-mock-token'); + localStorage.setItem('ozymorlab_refresh_token', 'e2e-mock-refresh'); + }); + await page.reload(); + await page.waitForURL('**/dashboard**', { timeout: 15000 }); + } +} + +// ═══════════════════════════════════════════════════════════ +// PHASE 1: AUTHENTICATION TESTING +// ═══════════════════════════════════════════════════════════ +test.describe('Phase 1: Authentication Testing', () => { + test.describe('Sign Up', () => { + test('should render signup form with all fields', async ({ page }) => { + await page.goto(`${BASE_URL}/login?tab=signup`); + await page.waitForLoadState('load'); + await expect(page.locator('#signup-name')).toBeVisible(); + await expect(page.locator('#signup-email')).toBeVisible(); + await expect(page.locator('#signup-password')).toBeVisible(); + await expect(page.locator('#signup-role')).toBeVisible(); + await expect(page.locator('#signup-submit')).toBeVisible(); + }); + + test('should validate short password on signup', async ({ page }) => { + await page.goto(`${BASE_URL}/login?tab=signup`); + await page.waitForLoadState('load'); + await page.fill('#signup-name', 'Test User'); + await page.fill('#signup-email', 'newuser@test.com'); + await page.fill('#signup-password', 'short'); + const valid = await page.$eval('#signup-password', (el: HTMLInputElement) => el.validity.valid); + expect(valid).toBe(false); + }); + }); + + test.describe('Sign In', () => { + test('should render login form with all fields', async ({ page }) => { + await page.goto(`${BASE_URL}/login`); + await page.waitForLoadState('load'); + await expect(page.locator('#login-email')).toBeVisible(); + await expect(page.locator('#login-password')).toBeVisible(); + await expect(page.locator('#login-submit')).toBeVisible(); + }); + + test('should show error on invalid credentials', async ({ page }) => { + await page.goto(`${BASE_URL}/login`); + await page.waitForLoadState('load'); + await page.fill('#login-email', 'invalid@test.com'); + await page.fill('#login-password', 'wrongpassword'); + await page.click('#login-submit'); + await expect(page.locator('.auth-error')).toBeVisible({ timeout: 10000 }); + }); + + test('should show Google sign-in button', async ({ page }) => { + await page.goto(`${BASE_URL}/login`); + await page.waitForLoadState('load'); + await expect(page.locator('.btn-google')).toBeVisible(); + }); + }); + + test.describe('Logout', () => { + test.beforeEach(async ({ page }) => { + await mockAuth(page); + }); + + test('should sign out from user menu and redirect to login', async ({ page }) => { + const userMenuBtn = page.locator('header button').filter({ has: page.locator('svg.lucide-chevron-down') }); + await userMenuBtn.click(); + await page.locator('button:has-text("Sign out")').click(); + await page.waitForURL('**/login**', { timeout: 10000 }); + await expect(page).toHaveURL(/login/); + }); + }); + + test.describe('Tab Switching', () => { + test('should toggle between Sign In and Create Account tabs', async ({ page }) => { + await page.goto(`${BASE_URL}/login`); + await page.waitForLoadState('load'); + await page.click('button:has-text("Create Account")'); + await expect(page.locator('#signup-name')).toBeVisible(); + await page.click('button:has-text("Sign In")'); + await expect(page.locator('#login-email')).toBeVisible(); + }); + }); + + test.describe('Password Visibility Toggle', () => { + test('should toggle password visibility on login form', async ({ page }) => { + await page.goto(`${BASE_URL}/login`); + await page.waitForLoadState('load'); + await page.fill('#login-password', 'visibletest'); + await page.locator('.form-input-toggle').click(); + await expect(page.locator('#login-password')).toHaveAttribute('type', 'text'); + await page.locator('.form-input-toggle').click(); + await expect(page.locator('#login-password')).toHaveAttribute('type', 'password'); + }); + }); + + test.describe('Protected Routes Redirect', () => { + test('unauthenticated access to /dashboard redirects to /login', async ({ page }) => { + await page.goto(`${BASE_URL}/dashboard`); + await page.waitForURL('**/login**', { timeout: 10000 }); + await expect(page).toHaveURL(/login/); + }); + + test('unauthenticated access to /dashboard/admin redirects to /login', async ({ page }) => { + await page.goto(`${BASE_URL}/dashboard/admin`); + await page.waitForURL('**/login**', { timeout: 10000 }); + await expect(page).toHaveURL(/login/); + }); + }); + + test.describe('Session Persistence', () => { + test.beforeEach(async ({ page, context }) => { + await context.route('**/auth/me', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: { ...MOCK_USER, role: 'teacher' } }), + }); + }); + await mockAuth(page); + }); + + test('should persist session across page reload', async ({ page }) => { + const tokenBefore = await page.evaluate(() => localStorage.getItem('ozymorlab_token')); + expect(tokenBefore).toBe('e2e-mock-token'); + + await page.goto(`${BASE_URL}/dashboard?t=${Date.now()}`); + await page.waitForLoadState('load'); + + const tokenAfter = await page.evaluate(() => localStorage.getItem('ozymorlab_token')); + expect(tokenAfter).toBe('e2e-mock-token'); + await expect(page.locator('text=Recent Submissions')).toBeVisible({ timeout: 15000 }); + }); + }); + + test.describe('Multiple Tab Login', () => { + test.beforeEach(async ({ page, context }) => { + await context.route('**/auth/me', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: { ...MOCK_USER, role: 'teacher' } }), + }); + }); + await mockAuth(page); + }); + + test('should maintain session across tabs', async ({ context }) => { + const tab2 = await context.newPage(); + await tab2.goto(`${BASE_URL}/dashboard`); + await tab2.waitForLoadState('load'); + await expect(tab2).toHaveURL(/dashboard/); + await tab2.close(); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════ +// PHASE 2: STUDENT JOURNEY TESTING +// ═══════════════════════════════════════════════════════════ +test.describe('Phase 2: Student Journey Testing', () => { + test.beforeEach(async ({ page }) => { + await mockAuth(page); + }); + + test.describe('Exam Page (Student View)', () => { + test('should show student submission form with upload fields', async ({ page }) => { + await page.goto(`${BASE_URL}/dashboard/exams`); + await page.waitForLoadState('load'); + + const subjectSelect = page.locator('select').filter({ has: page.locator('option[value="Physics"]') }); + const qPaperUpload = page.locator('text=Upload Question Paper'); + const answerUpload = page.locator('text=Upload Answer Sheet'); + + const isStudentView = await subjectSelect.count() > 0; + if (isStudentView) { + await expect(subjectSelect.first()).toBeVisible(); + await expect(qPaperUpload.first()).toBeVisible(); + await expect(answerUpload.first()).toBeVisible(); + } + }); + }); + + test.describe('Submissions Dashboard', () => { + test('should show submissions list with filter pills', async ({ page }) => { + await page.locator('a[href="/dashboard/submissions"]').first().click(); + await page.waitForLoadState('load'); + await expect(page.locator('input[placeholder*="Search"]').first()).toBeVisible({ timeout: 10000 }); + for (const s of ['ALL', 'GRADED', 'FAILED', 'PENDING']) { + const pill = page.locator(`button:has-text("${s}")`); + if (await pill.count() > 0) await expect(pill.first()).toBeVisible(); + } + }); + + test('should have search input and table columns', async ({ page }) => { + await page.locator('a[href="/dashboard/submissions"]').first().click(); + await page.waitForLoadState('load'); + await expect(page.locator('input[placeholder*="Search"]').first()).toBeVisible(); + for (const h of ['Student Name', 'Filename', 'Status', 'Actions']) { + const header = page.locator(`th:has-text("${h}")`); + if (await header.count() > 0) await expect(header.first()).toBeVisible(); + } + }); + }); +}); + +// ═══════════════════════════════════════════════════════════ +// PHASE 3: SUBMISSION DASHBOARD +// ═══════════════════════════════════════════════════════════ +test.describe('Phase 3: Submission Dashboard', () => { + test.beforeEach(async ({ page }) => { + await mockAuth(page); + }); + + test.describe('Dashboard Home', () => { + test('should display Recent Submissions and stat cards', async ({ page }) => { + await expect(page.locator('text=Recent Submissions')).toBeVisible(); + expect(await page.locator('.card-lp').count()).toBeGreaterThanOrEqual(3); + }); + + test('should show throughput chart and live activity', async ({ page }) => { + await expect(page.locator('text=Throughput')).toBeVisible(); + await expect(page.locator('text=Live Activity')).toBeVisible(); + }); + }); + + test.describe('Submissions Features', () => { + test('filter pills should be clickable', async ({ page }) => { + await page.goto(`${BASE_URL}/dashboard/submissions`); + await page.waitForLoadState('load'); + const pending = page.locator('button:has-text("PENDING")'); + if (await pending.count() > 0) { await pending.first().click(); await page.waitForTimeout(300); } + const all = page.locator('button:has-text("ALL")'); + if (await all.count() > 0) { await all.first().click(); } + }); + + test('should have Reload Queue button', async ({ page }) => { + await page.goto(`${BASE_URL}/dashboard/submissions`); + await page.waitForLoadState('load'); + const reloadBtn = page.locator('button:has-text("Reload Queue")'); + if (await reloadBtn.count() > 0) { + await expect(reloadBtn.first()).toBeVisible(); + } + }); + }); + + test.describe('Reports Page', () => { + test('should render reports dashboard with stats', async ({ page }) => { + await page.locator('a[href="/dashboard/reports"]').first().click(); + await page.waitForLoadState('load'); + await expect(page.locator('input[placeholder*="Search"]').first()).toBeVisible({ timeout: 10000 }); + }); + }); + + test.describe('Responsive Layout', () => { + test('mobile viewport', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto(`${BASE_URL}/dashboard`); + await page.waitForLoadState('load'); + await expect(page.locator('text=OzymorLab').first()).toBeVisible({ timeout: 5000 }); + }); + + test('tablet viewport', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }); + await page.goto(`${BASE_URL}/dashboard`); + await page.waitForLoadState('load'); + await expect(page.locator('text=OzymorLab').first()).toBeVisible({ timeout: 5000 }); + }); + + test('desktop viewport', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await expect(page.locator('text=Recent Submissions')).toBeVisible({ timeout: 5000 }); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════ +// PHASE 4: CREDITS SYSTEM +// ═══════════════════════════════════════════════════════════ +test.describe('Phase 4: Credits System', () => { + test.beforeEach(async ({ page }) => { + await mockAuth(page); + }); + + test('should navigate to settings page', async ({ page }) => { + await page.goto(`${BASE_URL}/dashboard/settings`); + await page.waitForLoadState('load'); + }); + + test('should show credit balance if credits section exists', async ({ page }) => { + await page.goto(`${BASE_URL}/dashboard/settings`); + await page.waitForLoadState('load'); + const creditSection = page.locator('text=Credits, text=Credit, text=Purchase, text=Billing'); + if (await creditSection.count() > 0) { + await expect(creditSection.first()).toBeVisible(); + } + }); +}); + +// ═══════════════════════════════════════════════════════════ +// PHASE 5: ADMIN PANEL +// ═══════════════════════════════════════════════════════════ +test.describe('Phase 5: Admin Panel', () => { + test.describe('Admin Access and Tabs', () => { + test.beforeEach(async ({ page }) => { + await mockAuth(page, 'admin'); + }); + + test('should render admin panel with all tabs', async ({ page }) => { + await page.goto(`${BASE_URL}/dashboard/admin`); + await page.waitForLoadState('load'); + await expect(page.locator('text=Administration').first()).toBeVisible({ timeout: 10000 }); + for (const tab of ['Manage Teachers', 'Manage Students', 'School Classrooms', 'Exams & Assignments', 'Classes & Roster']) { + const el = page.locator(`button:has-text("${tab}")`); + if (await el.count() > 0) await expect(el.first()).toBeVisible(); + } + }); + + test('should switch between admin tabs', async ({ page }) => { + await page.goto(`${BASE_URL}/dashboard/admin`); + await page.waitForLoadState('load'); + + const studentsTab = page.locator('button:has-text("Manage Students")'); + if (await studentsTab.count() > 0) { + await studentsTab.first().click(); + await page.waitForTimeout(500); + await expect(page.locator('text=Student Directory').first()).toBeVisible(); + } + + const teachersTab = page.locator('button:has-text("Manage Teachers")'); + if (await teachersTab.count() > 0) { + await teachersTab.first().click(); + await page.waitForTimeout(500); + await expect(page.locator('h2:has-text("Teacher")').first()).toBeVisible(); + } + + const classesTab = page.locator('button:has-text("Classes & Roster")'); + if (await classesTab.count() > 0) { + await classesTab.first().click(); + await page.waitForTimeout(500); + await expect(page.locator('text=Class Standard').first()).toBeVisible(); + } + }); + }); + + test.describe('Analytics Dashboard', () => { + test.beforeEach(async ({ page }) => { + await mockAuth(page, 'admin'); + }); + + test('should display stat cards on analytics tab', async ({ page }) => { + await page.goto(`${BASE_URL}/dashboard/admin`); + await page.waitForLoadState('load'); + for (const label of ['Students', 'Teachers', 'Classrooms']) { + const el = page.locator(`text=${label}`); + if (await el.count() > 0) await expect(el.first()).toBeVisible({ timeout: 5000 }); + } + }); + + test('should show evaluation pipeline section', async ({ page }) => { + await page.goto(`${BASE_URL}/dashboard/admin`); + await page.waitForLoadState('load'); + const pipeline = page.locator('text=Evaluation Pipeline'); + if (await pipeline.count() > 0) await expect(pipeline.first()).toBeVisible(); + }); + }); + + test.describe('Student Directory', () => { + test.beforeEach(async ({ page }) => { + await mockAuth(page, 'admin'); + }); + + test('should show student directory with CSV import option', async ({ page }) => { + await page.goto(`${BASE_URL}/dashboard/admin`); + await page.waitForLoadState('load'); + const tab = page.locator('button:has-text("Manage Students")'); + if (await tab.count() > 0) { + await tab.first().click(); + await page.waitForTimeout(500); + await expect(page.locator('text=Student Directory').first()).toBeVisible(); + await expect(page.locator('text=Roster CSV Import').first()).toBeVisible(); + } + }); + }); + + test.describe('Teacher Management', () => { + test.beforeEach(async ({ page }) => { + await mockAuth(page, 'admin'); + }); + + test('should show teacher directory with invite section', async ({ page }) => { + await page.goto(`${BASE_URL}/dashboard/admin`); + await page.waitForLoadState('load'); + const tab = page.locator('button:has-text("Manage Teachers")'); + if (await tab.count() > 0) { + await tab.first().click(); + await page.waitForTimeout(500); + await expect(page.locator('h2:has-text("Teacher")').first()).toBeVisible(); + } + }); + }); + + test.describe('Classrooms and Assignments', () => { + test.beforeEach(async ({ page }) => { + await mockAuth(page, 'admin'); + }); + + test('should navigate to classrooms and assignments tabs', async ({ page }) => { + await page.goto(`${BASE_URL}/dashboard/admin`); + await page.waitForLoadState('load'); + + let tab = page.locator('button:has-text("School Classrooms")'); + if (await tab.count() > 0) { + await tab.first().click(); + await page.waitForTimeout(500); + await expect(page.locator('text=All School Classrooms')).toBeVisible(); + } + + tab = page.locator('button:has-text("Exams & Assignments")'); + if (await tab.count() > 0) { + await tab.first().click(); + await page.waitForTimeout(500); + await expect(page.locator('text=Active Exams & Assignments')).toBeVisible(); + } + }); + }); + + test.describe('Authorization - Student Cannot Access Admin', () => { + test.beforeEach(async ({ page }) => { + await mockAuth(page, 'teacher'); + }); + + test('admin link should only be visible for admin/principal users', async ({ page }) => { + await page.goto(`${BASE_URL}/dashboard`); + await page.waitForLoadState('load'); + const adminLink = page.locator('a:has-text("Admin")').first().or(page.locator('nav a[href="/dashboard/admin"]')); + await adminLink.isVisible().catch(() => false); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════ +// FULL NAVIGATION FLOW +// ═══════════════════════════════════════════════════════════ +test.describe('Full Navigation Flow', () => { + test.beforeEach(async ({ page }) => { + await mockAuth(page); + }); + + test('should navigate through all dashboard pages', async ({ page }) => { + const pages = [ + { href: '/dashboard/exams', url: /exams/ }, + { href: '/dashboard/submissions', url: /submissions/ }, + { href: '/dashboard/students', url: /students/ }, + { href: '/dashboard/reviews', url: /reviews/ }, + { href: '/dashboard/reports', url: /reports/ }, + ]; + + for (const { href, url } of pages) { + const navLink = page.locator(`a[href="${href}"]`); + if (await navLink.count() > 0) { + await navLink.first().click(); + await page.waitForURL(url, { timeout: 8000 }); + } else { + await page.goto(`${BASE_URL}${href}`); + await page.waitForLoadState('load'); + } + } + }); +}); diff --git a/package.json b/package.json index 8886f5c..97f0fd4 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev -p 3000", - "build": "next build", + "dev": "next dev --webpack -p 3000", + "build": "next build --webpack", "start": "next start", "lint": "eslint" }, diff --git a/playwright.config.ts b/playwright.config.ts index b5b2322..d4e5e79 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,4 @@ import { defineConfig } from '@playwright/test'; -import path from 'path'; export default defineConfig({ testDir: './e2e', @@ -20,9 +19,12 @@ export default defineConfig({ }, ], webServer: { - command: 'npm run dev -- -p 3000', + command: 'npm run dev', port: 3000, timeout: 120000, - reuseExistingServer: true, + reuseExistingServer: false, + env: { + NEXT_PUBLIC_SUPABASE_URL: '', + }, }, }); diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index f9e2df0..c1a1a77 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -1,5 +1,15 @@ +import type { Metadata } from "next"; import LandingPage from "../landing/LandingPage"; +export const metadata: Metadata = { + title: "About OzymorLab | AI Essay Grading for Indian Education Boards", + description: "Learn how OzymorLab uses AI to grade essays for CBSE, ICSE & state boards. Supporting 22 Indian languages with per-criterion scoring.", + openGraph: { + title: "About OzymorLab | AI Essay Grader for Indian Schools", + description: "AI-powered essay grading built for Indian education boards. Multilingual, rubric-based, evidence-backed scores.", + }, +}; + export default function AboutPage() { return ; } diff --git a/src/app/analysis/page.tsx b/src/app/analysis/page.tsx index 9ca3885..fe26deb 100644 --- a/src/app/analysis/page.tsx +++ b/src/app/analysis/page.tsx @@ -10,6 +10,7 @@ import { Sun, Moon, ChevronDown, Shield } from "lucide-react"; import Link from "next/link"; +import { usePathname } from "next/navigation"; import { useAuth, AuthProvider } from "../context/AuthContext"; const navItems = [ @@ -90,6 +91,7 @@ interface ChatMessage { function AnalysisHUDPageContent() { const { user, fetchWithAuth, logout } = useAuth(); + const pathname = usePathname(); // Mode & Core Data const [viewMode, setViewMode] = useState<"teacher" | "student" | "self-eval">("teacher"); @@ -98,6 +100,13 @@ function AnalysisHUDPageContent() { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const userMenuRef = useRef(null); + useEffect(() => { + const meta = document.createElement("meta"); + meta.name = "robots"; + meta.content = "noindex"; + document.head.appendChild(meta); + }, []); + const isAdmin = user?.role === "admin" || user?.role === "principal"; const [roster, setRoster] = useState([]); // Array of all worksheets in the class @@ -261,6 +270,8 @@ function AnalysisHUDPageContent() { useEffect(() => { if (selectedStudentId) { setIsLoadingDetail(true); + setSubmissionDetail(null); + setSelectedQuestionIndex(0); setChatMessages([]); setGradeDetail(null); @@ -419,6 +430,13 @@ function AnalysisHUDPageContent() { points: 0 }; + // Derived grade for current step — match by stepNum, not array index + const currentStepGrade = (() => { + if (!gradeDetail?.step_grades || !submissionDetail?.steps?.[selectedQuestionIndex]) return null; + const stepNum = submissionDetail.steps[selectedQuestionIndex].stepNum; + return gradeDetail.step_grades.find((g: any) => g.step_num === stepNum) || null; + })(); + // We are removing `activeSteps` since we render the student's actual answers via HTML, not fixed steps array. // ========================================== @@ -660,7 +678,7 @@ function AnalysisHUDPageContent() { {/* Desktop Nav Links */}