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/dashboard/exams/page.tsx b/src/app/dashboard/exams/page.tsx index c83cffb..2b572d1 100644 --- a/src/app/dashboard/exams/page.tsx +++ b/src/app/dashboard/exams/page.tsx @@ -51,6 +51,7 @@ export default function ExamsPage() { setIsStudentSubmitting(true); try { + const today = new Date(); // 1. Upload Question Paper setStudentStatusMsg("Decomposing question paper & drafting marking criteria..."); const paperFormData = new FormData(); @@ -89,8 +90,8 @@ export default function ExamsPage() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Self Submissions Cycle", - start_date: new Date().toISOString().split('T')[0], - end_date: new Date(Date.now() + 30 * 86400000).toISOString().split('T')[0], + start_date: today.toISOString().split('T')[0], + end_date: new Date(today.getTime() + 30 * 86400000).toISOString().split('T')[0], }), }); const cycJson = await cycRes.json(); diff --git a/src/app/dashboard/students/page.tsx b/src/app/dashboard/students/page.tsx index 449f88b..3b7f8bf 100644 --- a/src/app/dashboard/students/page.tsx +++ b/src/app/dashboard/students/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { Users, Search, Award, TrendingUp, BookOpen, User, Star, ArrowRight, Sparkles, Plus, Trash2, Check, X, ShieldAlert, MoreVertical, ArrowLeft, UploadCloud, Loader2, FileText, CheckCircle2 } from "lucide-react"; import { useAuth } from "../../context/AuthContext"; import Link from "next/link"; @@ -36,13 +36,13 @@ interface StudentSummary { lastActive: string; } -// Client-side cache for high-performance instant loading -const classroomExamsCache: Record = {}; -let globalWorksheetsCache: any = null; - export default function StudentsPage() { const { user, fetchWithAuth } = useAuth(); const router = useRouter(); + + // Client-side cache for high-performance instant loading + const globalWorksheetsCache = useRef(null); + const classroomExamsCache = useRef>({}); // Navigation back states const [selectedClassroom, setSelectedClassroom] = useState(null); @@ -122,8 +122,8 @@ export default function StudentsPage() { // Fetch all databases const fetchClassroomData = async () => { - if (globalWorksheetsCache) { - setClassWorksheets(globalWorksheetsCache); + if (globalWorksheetsCache.current) { + setClassWorksheets(globalWorksheetsCache.current); } try { const resClassrooms = await fetchWithAuth(`${API_BASE}/classroom`); @@ -146,7 +146,7 @@ export default function StudentsPage() { const resWs = await fetchWithAuth(`${API_BASE}/classroom/worksheets`); const jsonWs = await resWs.json(); if (jsonWs.data) { - globalWorksheetsCache = jsonWs.data; + globalWorksheetsCache.current = jsonWs.data; setClassWorksheets(jsonWs.data); } @@ -159,14 +159,14 @@ export default function StudentsPage() { }; const fetchClassroomExams = async (classId: string) => { - if (classroomExamsCache[classId]) { - setExamWorksheetsList(classroomExamsCache[classId]); + if (classroomExamsCache.current[classId]) { + setExamWorksheetsList(classroomExamsCache.current[classId]); } try { const res = await fetchWithAuth(`${API_BASE}/classroom/${classId}/exams`); const json = await res.json(); if (json.data) { - classroomExamsCache[classId] = json.data; + classroomExamsCache.current[classId] = json.data; setExamWorksheetsList(json.data); } } catch (e) { diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 4d95972..87835fb 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,19 +1,23 @@ "use client"; -import { useState, useEffect, Suspense } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; import Link from "next/link"; import { Eye, EyeOff, ArrowRight, AlertCircle, CheckCircle2, Sparkles } from "lucide-react"; import { AuthProvider, useAuth } from "../context/AuthContext"; function LoginPageContent() { const router = useRouter(); - const searchParams = useSearchParams(); const { login, signup, loginWithGoogle, user, isLoading } = useAuth(); - const [activeTab, setActiveTab] = useState<"login" | "signup">( - searchParams.get("tab") === "signup" ? "signup" : "login" - ); + const [activeTab, setActiveTab] = useState<"login" | "signup">("login"); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get("tab") === "signup") { + setActiveTab("signup"); + } + }, []); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [fullName, setFullName] = useState(""); @@ -74,18 +78,14 @@ function LoginPageContent() { setLoading(false); }; - if (isLoading) { - return ( -
+ return ( +
+ {isLoading ? (
-
- ); - } - - return ( -
+ ) : ( + <> {/* Left: Branding Panel */}
@@ -356,6 +356,8 @@ function LoginPageContent() {

+ + )}
); } @@ -363,9 +365,7 @@ function LoginPageContent() { export default function LoginPage() { return ( -
}> - -
+
); } diff --git a/src/app/osm-evaluator/layout.tsx b/src/app/osm-evaluator/layout.tsx new file mode 100644 index 0000000..84801c9 --- /dev/null +++ b/src/app/osm-evaluator/layout.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "OSM (On-Screen Marking) & AI Answer Sheet Evaluator | CBSE ICSE | OzymorLab", + description: "Learn about OSM (On-Screen Marking) for CBSE, ICSE & State Boards. Understand how AI answer sheet evaluators help practice OSM exams. 83% accuracy. 22 languages.", + keywords: [ + "OSM CBSE exam", + "on-screen marking evaluator", + "OSM answer sheet evaluator", + "AI OSM evaluator India", + "on-screen marking checker", + "OSM grading system", + "OSM vs traditional marking", + "on-screen marking vs paper marking", + "how does OSM marking work", + "CBSE on-screen marking", + "OSM marking scheme", + "AI answer sheet evaluation", + "OSM marking reliability", + "digital answer sheet evaluation", + "computer-based marking India", + ], + openGraph: { + title: "OSM (On-Screen Marking) Explained | AI Answer Sheet Evaluator | OzymorLab", + description: "Complete guide to OSM marking for Indian board exams. Learn how AI evaluation helps you practice for CBSE, ICSE, and state board exams.", + type: "article", + locale: "en_IN", + }, + twitter: { + card: "summary_large_image", + title: "OSM Evaluator - Practice with AI for Board Exams", + description: "Understand On-Screen Marking and practice with AI. Get exam-ready for CBSE, ICSE, and state boards.", + }, +}; + +export default function OSMLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/src/app/osm-evaluator/page.tsx b/src/app/osm-evaluator/page.tsx index e234244..4d7ecf1 100644 --- a/src/app/osm-evaluator/page.tsx +++ b/src/app/osm-evaluator/page.tsx @@ -1,41 +1,7 @@ 'use client'; -import type { Metadata } from "next"; import { useState } from 'react'; -export const metadata: Metadata = { - title: "OSM (On-Screen Marking) & AI Answer Sheet Evaluator | CBSE ICSE | OzymorLab", - description: "Learn about OSM (On-Screen Marking) for CBSE, ICSE & State Boards. Understand how AI answer sheet evaluators help practice OSM exams. 83% accuracy. 22 languages.", - keywords: [ - "OSM CBSE exam", - "on-screen marking evaluator", - "OSM answer sheet evaluator", - "AI OSM evaluator India", - "on-screen marking checker", - "OSM grading system", - "OSM vs traditional marking", - "on-screen marking vs paper marking", - "how does OSM marking work", - "CBSE on-screen marking", - "OSM marking scheme", - "AI answer sheet evaluation", - "OSM marking reliability", - "digital answer sheet evaluation", - "computer-based marking India", - ], - openGraph: { - title: "OSM (On-Screen Marking) Explained | AI Answer Sheet Evaluator | OzymorLab", - description: "Complete guide to OSM marking for Indian board exams. Learn how AI evaluation helps you practice for CBSE, ICSE, and state board exams.", - type: "article", - locale: "en_IN", - }, - twitter: { - card: "summary_large_image", - title: "OSM Evaluator - Practice with AI for Board Exams", - description: "Understand On-Screen Marking and practice with AI. Get exam-ready for CBSE, ICSE, and state boards.", - }, -}; - export default function OSMEvaluatorPage() { const [expandedSection, setExpandedSection] = useState(null); diff --git a/test-results/.last-run.json b/test-results/.last-run.json index 957284b..cbcc1fb 100644 --- a/test-results/.last-run.json +++ b/test-results/.last-run.json @@ -1,6 +1,4 @@ { - "status": "failed", - "failedTests": [ - "70b872a5e72a7b2c7282-d4de8269ab84cb7dd8a5" - ] + "status": "passed", + "failedTests": [] } \ No newline at end of file