import { test, expect } from '@playwright/test'; import { execSync } from 'child_process'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts) const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; const urlMatch = baseUrl.match(/:(\d+)$/); const PORT = urlMatch ? urlMatch[1] : '4173'; const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; // Test credentials for authenticated tests const ADMIN_EMAIL = 'e2e-dashboard-admin@example.com'; const ADMIN_PASSWORD = 'DashboardTest123'; test.describe('Dashboard', () => { /** * Navigate to the dashboard and wait for SvelteKit hydration. * SSR renders the HTML immediately, but event handlers are only * attached after client-side hydration completes. */ async function goToDashboard(page: import('@playwright/test').Page) { await page.goto('/dashboard', { waitUntil: 'networkidle' }); await expect(page.locator('.demo-controls')).toBeVisible({ timeout: 5000 }); } /** * Switch to a demo role with retry logic to handle hydration timing. * Retries the click until the button's active class confirms the switch. */ async function switchToDemoRole( page: import('@playwright/test').Page, roleName: string | RegExp ) { const button = page.locator('.demo-controls button', { hasText: roleName }); await expect(async () => { await button.click(); await expect(button).toHaveClass(/active/, { timeout: 1000 }); }).toPass({ timeout: 10000 }); } // ============================================================================ // Demo Mode (unauthenticated) - Role Switcher // ============================================================================ test.describe('Demo Mode', () => { test('shows demo role switcher when not authenticated', async ({ page }) => { await goToDashboard(page); await expect(page).toHaveURL(/\/dashboard/); await expect(page.getByText(/Démo - Changer de rôle/i)).toBeVisible(); }); test('page title is set correctly', async ({ page }) => { await goToDashboard(page); await expect(page).toHaveTitle(/tableau de bord/i); }); test('demo role switcher has all 4 role buttons', async ({ page }) => { await goToDashboard(page); const demoControls = page.locator('.demo-controls'); await expect(demoControls).toBeVisible(); await expect(demoControls.getByRole('button', { name: 'Parent' })).toBeVisible(); await expect(demoControls.getByRole('button', { name: 'Enseignant' })).toBeVisible(); await expect(demoControls.getByRole('button', { name: /Élève/i })).toBeVisible(); await expect(demoControls.getByRole('button', { name: 'Admin' })).toBeVisible(); }); test('Parent role is selected by default', async ({ page }) => { await goToDashboard(page); const parentButton = page.locator('.demo-controls button', { hasText: 'Parent' }); await expect(parentButton).toHaveClass(/active/); }); }); // ============================================================================ // Parent Dashboard View // ============================================================================ test.describe('Parent Dashboard', () => { test('shows Score Serenite card', async ({ page }) => { await goToDashboard(page); // Parent is the default demo role await expect(page.getByText(/score sérénité/i).first()).toBeVisible(); }); test('shows serenity score with numeric value', async ({ page }) => { await goToDashboard(page); // The score card should display a number value const scoreCard = page.locator('.serenity-card'); await expect(scoreCard).toBeVisible(); // Should have a numeric value followed by /100 await expect(scoreCard.locator('.value')).toBeVisible(); await expect(scoreCard.getByText('/100')).toBeVisible(); }); test('serenity score shows demo badge', async ({ page }) => { await goToDashboard(page); await expect(page.getByText(/données de démonstration/i)).toBeVisible(); }); test('shows placeholder sections for schedule, notes, and homework', async ({ page }) => { await goToDashboard(page); // These sections show as placeholders since hasRealData is false await expect(page.getByRole('heading', { name: /emploi du temps/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /notes récentes/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /devoirs à venir/i })).toBeVisible(); }); test('placeholder sections show informative messages', async ({ page }) => { await goToDashboard(page); await expect(page.getByText(/l'emploi du temps sera disponible/i)).toBeVisible(); await expect(page.getByText(/les notes apparaîtront ici/i)).toBeVisible(); await expect(page.getByText(/les devoirs seront affichés ici/i)).toBeVisible(); }); test('onboarding banner is visible on first login', async ({ page }) => { await goToDashboard(page); // The onboarding banner should be visible (isFirstLogin=true initially) await expect(page.getByText(/bienvenue sur classeo/i)).toBeVisible(); await expect(page.getByText(/score sérénité/i).first()).toBeVisible(); }); test('clicking serenity score opens explainer', async ({ page }) => { await goToDashboard(page); // Click the serenity score card const scoreCard = page.locator('.serenity-card'); await expect(scoreCard).toBeVisible(); await scoreCard.click(); // The explainer modal/overlay should appear // SerenityScoreExplainer should be visible after click await expect(page.getByText(/cliquez pour en savoir plus/i)).toBeVisible(); }); }); // ============================================================================ // Teacher Dashboard View // ============================================================================ test.describe('Teacher Dashboard', () => { test('shows teacher dashboard header', async ({ page }) => { await goToDashboard(page); // Switch to teacher await switchToDemoRole(page, 'Enseignant'); await expect(page.getByRole('heading', { name: /tableau de bord enseignant/i })).toBeVisible(); await expect(page.getByText(/bienvenue.*voici vos outils du jour/i)).toBeVisible(); }); test('shows quick action cards', async ({ page }) => { await goToDashboard(page); await switchToDemoRole(page, 'Enseignant'); await expect(page.getByText(/faire l'appel/i)).toBeVisible(); await expect(page.getByText(/saisir des notes/i)).toBeVisible(); await expect(page.getByText(/créer un devoir/i)).toBeVisible(); }); test('quick action cards are disabled in demo mode', async ({ page }) => { await goToDashboard(page); await switchToDemoRole(page, 'Enseignant'); // Action cards should be disabled since hasRealData=false const actionCards = page.locator('.action-card'); const count = await actionCards.count(); expect(count).toBeGreaterThanOrEqual(3); for (let i = 0; i < count; i++) { await expect(actionCards.nth(i)).toBeDisabled(); } }); test('shows placeholder sections for teacher data', async ({ page }) => { await goToDashboard(page); await switchToDemoRole(page, 'Enseignant'); await expect(page.getByRole('heading', { name: /mes classes aujourd'hui/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /notes à saisir/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /appels du jour/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /statistiques rapides/i })).toBeVisible(); }); test('placeholder sections have informative messages', async ({ page }) => { await goToDashboard(page); await switchToDemoRole(page, 'Enseignant'); await expect(page.getByText(/vos classes apparaîtront ici/i)).toBeVisible(); await expect(page.getByText(/évaluations en attente de notation/i)).toBeVisible(); await expect(page.getByText(/les appels à effectuer/i)).toBeVisible(); await expect(page.getByText(/les statistiques de vos classes/i)).toBeVisible(); }); }); // ============================================================================ // Student Dashboard View // ============================================================================ test.describe('Student Dashboard', () => { test('shows student dashboard header', async ({ page }) => { await goToDashboard(page); // Switch to student await switchToDemoRole(page, /Élève/i); await expect(page.getByRole('heading', { name: /mon espace/i })).toBeVisible(); // Student is minor by default, so "ton" instead of "votre" await expect(page.getByText(/bienvenue.*voici ton tableau de bord/i)).toBeVisible(); }); test('shows info banner for student in demo mode', async ({ page }) => { await goToDashboard(page); await switchToDemoRole(page, /Élève/i); await expect(page.getByText(/ton emploi du temps, tes notes et tes devoirs/i)).toBeVisible(); }); test('shows placeholder sections for student data', async ({ page }) => { await goToDashboard(page); await switchToDemoRole(page, /Élève/i); await expect(page.getByRole('heading', { name: /mon emploi du temps/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /mes notes/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible(); }); test('placeholder sections show minor-appropriate messages', async ({ page }) => { await goToDashboard(page); await switchToDemoRole(page, /Élève/i); // Uses "ton/tes" for minors await expect(page.getByText(/ton emploi du temps sera bientôt disponible/i)).toBeVisible(); await expect(page.getByText(/tes notes apparaîtront ici/i)).toBeVisible(); await expect(page.getByText(/tes devoirs s'afficheront ici/i)).toBeVisible(); }); }); // ============================================================================ // Admin Dashboard View // ============================================================================ test.describe('Admin Dashboard', () => { test('shows admin dashboard header', async ({ page }) => { await goToDashboard(page); // Switch to admin await switchToDemoRole(page, 'Admin'); await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible(); }); test('shows establishment name', async ({ page }) => { await goToDashboard(page); await switchToDemoRole(page, 'Admin'); // Demo data uses "École Alpha" as establishment name await expect(page.getByText(/école alpha/i)).toBeVisible(); }); test('shows quick action links for admin', async ({ page }) => { await goToDashboard(page); await switchToDemoRole(page, 'Admin'); await expect(page.getByText(/gérer les utilisateurs/i)).toBeVisible(); await expect(page.getByText(/configurer les classes/i)).toBeVisible(); await expect(page.getByText(/gérer les matières/i)).toBeVisible(); await expect(page.getByText(/périodes scolaires/i)).toBeVisible(); await expect(page.getByText(/pédagogie/i)).toBeVisible(); }); test('admin quick action links have correct hrefs', async ({ page }) => { await goToDashboard(page); await switchToDemoRole(page, 'Admin'); // Verify action cards link to correct pages const usersLink = page.locator('.action-card[href="/admin/users"]'); await expect(usersLink).toBeVisible(); const classesLink = page.locator('.action-card[href="/admin/classes"]'); await expect(classesLink).toBeVisible(); const subjectsLink = page.locator('.action-card[href="/admin/subjects"]'); await expect(subjectsLink).toBeVisible(); const periodsLink = page.locator('.action-card[href="/admin/academic-year/periods"]'); await expect(periodsLink).toBeVisible(); const pedagogyLink = page.locator('.action-card[href="/admin/pedagogy"]'); await expect(pedagogyLink).toBeVisible(); }); test('shows import action cards for students and teachers', async ({ page }) => { await goToDashboard(page); await switchToDemoRole(page, 'Admin'); const studentImport = page.getByRole('link', { name: /importer des élèves/i }); await expect(studentImport).toBeVisible(); await expect(studentImport).toHaveAttribute('href', '/admin/import/students'); const teacherImport = page.getByRole('link', { name: /importer des enseignants/i }); await expect(teacherImport).toBeVisible(); await expect(teacherImport).toHaveAttribute('href', '/admin/import/teachers'); }); test('shows placeholder sections for admin stats', async ({ page }) => { await goToDashboard(page); await switchToDemoRole(page, 'Admin'); await expect(page.getByRole('heading', { name: /utilisateurs/i })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Configuration', exact: true })).toBeVisible(); await expect(page.getByRole('heading', { name: /activité récente/i })).toBeVisible(); }); }); // ============================================================================ // Role Switching // ============================================================================ test.describe('Role Switching', () => { test('switching from parent to teacher changes dashboard content', async ({ page }) => { await goToDashboard(page); // Verify parent view await expect(page.getByText(/score sérénité/i).first()).toBeVisible(); // Switch to teacher await switchToDemoRole(page, 'Enseignant'); // Parent content should be gone await expect(page.locator('.serenity-card')).not.toBeVisible(); // Teacher content should appear await expect(page.getByRole('heading', { name: /tableau de bord enseignant/i })).toBeVisible(); }); test('switching from teacher to student changes dashboard content', async ({ page }) => { await goToDashboard(page); // Switch to teacher first await switchToDemoRole(page, 'Enseignant'); await expect(page.getByRole('heading', { name: /tableau de bord enseignant/i })).toBeVisible(); // Switch to student await switchToDemoRole(page, /Élève/i); // Teacher content should be gone await expect(page.getByRole('heading', { name: /tableau de bord enseignant/i })).not.toBeVisible(); // Student content should appear await expect(page.getByRole('heading', { name: /mon espace/i })).toBeVisible(); }); test('switching from student to admin changes dashboard content', async ({ page }) => { await goToDashboard(page); // Switch to student first await switchToDemoRole(page, /Élève/i); await expect(page.getByRole('heading', { name: /mon espace/i })).toBeVisible(); // Switch to admin await switchToDemoRole(page, 'Admin'); // Student content should be gone await expect(page.getByRole('heading', { name: /mon espace/i })).not.toBeVisible(); // Admin content should appear await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible(); }); test('active role button changes visual state', async ({ page }) => { await goToDashboard(page); // Parent should be active initially const parentBtn = page.locator('.demo-controls button', { hasText: 'Parent' }); await expect(parentBtn).toHaveClass(/active/); // Switch to teacher await switchToDemoRole(page, 'Enseignant'); // Teacher should now be active, parent should not const teacherBtn = page.locator('.demo-controls button', { hasText: 'Enseignant' }); await expect(teacherBtn).toHaveClass(/active/); await expect(parentBtn).not.toHaveClass(/active/); }); test('onboarding banner disappears after switching roles', async ({ page }) => { await goToDashboard(page); // Onboarding banner is visible initially (isFirstLogin=true) await expect(page.getByText(/bienvenue sur classeo/i)).toBeVisible(); // Switch role - this calls switchDemoRole which sets isFirstLogin=false await switchToDemoRole(page, 'Enseignant'); // Switch back to parent await switchToDemoRole(page, 'Parent'); // Onboarding banner should no longer be visible await expect(page.getByText(/bienvenue sur classeo/i)).not.toBeVisible(); }); }); // ============================================================================ // Admin Dashboard - Navigation from Quick Actions // ============================================================================ test.describe('Admin Quick Action Navigation', () => { test.beforeAll(async () => { const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); execSync( `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, { encoding: 'utf-8' } ); }); async function loginAsAdmin(page: import('@playwright/test').Page) { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); await Promise.all([ page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } test('clicking "Gerer les utilisateurs" navigates to users page', async ({ page }) => { await loginAsAdmin(page); // Admin dashboard should show after login (ROLE_ADMIN maps to admin view) await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({ timeout: 10000 }); // Click users link await page.locator('.action-card[href="/admin/users"]').click(); await expect(page).toHaveURL(/\/admin\/users/); }); test('clicking "Configurer les classes" navigates to classes page', async ({ page }) => { await loginAsAdmin(page); await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({ timeout: 10000 }); await page.locator('.action-card[href="/admin/classes"]').click(); await expect(page).toHaveURL(/\/admin\/classes/); }); test('clicking "Gerer les matieres" navigates to subjects page', async ({ page }) => { await loginAsAdmin(page); await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({ timeout: 10000 }); await page.locator('.action-card[href="/admin/subjects"]').click(); await expect(page).toHaveURL(/\/admin\/subjects/); }); test('clicking "Periodes scolaires" navigates to periods page', async ({ page }) => { await loginAsAdmin(page); await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({ timeout: 10000 }); await page.locator('.action-card[href="/admin/academic-year/periods"]').click(); await expect(page).toHaveURL(/\/admin\/academic-year\/periods/); }); test('clicking "Pedagogie" navigates to pedagogy page', async ({ page }) => { await loginAsAdmin(page); await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({ timeout: 10000 }); await page.locator('.action-card[href="/admin/pedagogy"]').click(); await expect(page).toHaveURL(/\/admin\/pedagogy/); }); }); // ============================================================================ // Accessibility // ============================================================================ test.describe('Accessibility', () => { test('serenity score card has accessible label', async ({ page }) => { await goToDashboard(page); const scoreCard = page.locator('[aria-label*="Score Sérénité"]'); await expect(scoreCard).toBeVisible(); }); test('teacher quick actions have a visually hidden heading', async ({ page }) => { await goToDashboard(page); await switchToDemoRole(page, 'Enseignant'); // The "Actions rapides" heading exists but is sr-only const actionsHeading = page.getByRole('heading', { name: /actions rapides/i }); await expect(actionsHeading).toBeAttached(); }); test('admin configuration actions have a visually hidden heading', async ({ page }) => { await goToDashboard(page); await switchToDemoRole(page, 'Admin'); const configHeading = page.getByRole('heading', { name: /actions de configuration/i }); await expect(configHeading).toBeAttached(); }); }); }); test.describe('Dashboard Components', () => { test('demo data JSON is valid and accessible', async ({ page }) => { // This tests that the demo data file is bundled correctly await page.goto('/'); // The app should load without errors await expect(page.locator('body')).toBeVisible(); }); });