import { test, expect } from '@playwright/test'; import { execSync } from 'child_process'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { writeFileSync, mkdirSync, unlinkSync } from 'fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); 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}`; // Réutilise le même enseignant que homework.spec.ts pour partager le setup const TEACHER_EMAIL = 'e2e-homework-teacher@example.com'; const TEACHER_PASSWORD = 'HomeworkTest123'; const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); function runSql(sql: string) { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, { encoding: 'utf-8' } ); } function clearCache() { try { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`, { encoding: 'utf-8' } ); } catch { // Cache pool may not exist } } function resolveDeterministicIds(): { schoolId: string; academicYearId: string } { const output = execSync( `docker compose -f "${composeFile}" exec -T php php -r '` + `require "/app/vendor/autoload.php"; ` + `$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` + `$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` + `' 2>&1`, { encoding: 'utf-8' } ).trim(); const [schoolId, academicYearId] = output.split('\n'); return { schoolId: schoolId!, academicYearId: academicYearId! }; } function getNextWeekday(daysFromNow: number): string { const date = new Date(); date.setDate(date.getDate() + daysFromNow); const day = date.getDay(); if (day === 0) date.setDate(date.getDate() + 1); if (day === 6) date.setDate(date.getDate() + 2); const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } function seedTeacherAssignments() { const { academicYearId } = resolveDeterministicIds(); try { runSql( `INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` + `FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` + `WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + `AND c.tenant_id = '${TENANT_ID}' ` + `AND s.tenant_id = '${TENANT_ID}' ` + `ON CONFLICT DO NOTHING` ); } catch { // May already exist } } async function loginAsTeacher(page: import('@playwright/test').Page) { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(TEACHER_EMAIL); await page.locator('#password').fill(TEACHER_PASSWORD); await Promise.all([ page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } async function navigateToHomework(page: import('@playwright/test').Page) { await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`); await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 }); } async function createHomework(page: import('@playwright/test').Page, title: string) { await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); await page.locator('#hw-class').selectOption({ index: 1 }); await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-title').fill(title); // Type in WYSIWYG editor const editorContent = page.locator('.modal .rich-text-content'); await editorContent.click(); await page.keyboard.type('Consignes du devoir'); await page.locator('#hw-due-date').fill(getNextWeekday(5)); await page.getByRole('button', { name: /créer le devoir/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); await expect(page.getByText(title)).toBeVisible({ timeout: 10000 }); } function createTempPdf(): string { const tmpDir = join(__dirname, '..', 'tmp-test-files'); mkdirSync(tmpDir, { recursive: true }); const filePath = join(tmpDir, 'test-attachment.pdf'); const pdfContent = `%PDF-1.4 1 0 obj<>endobj 2 0 obj<>endobj 3 0 obj<>endobj xref 0 4 0000000000 65535 f 0000000009 00000 n 0000000058 00000 n 0000000115 00000 n trailer<> startxref 190 %%EOF`; writeFileSync(filePath, pdfContent); return filePath; } function createTempTxt(): string { const tmpDir = join(__dirname, '..', 'tmp-test-files'); mkdirSync(tmpDir, { recursive: true }); const filePath = join(tmpDir, 'test-invalid.txt'); writeFileSync(filePath, 'This is a plain text file that should be rejected.'); return filePath; } function cleanupTempFiles() { const tmpDir = join(__dirname, '..', 'tmp-test-files'); for (const name of ['test-attachment.pdf', 'test-invalid.txt']) { try { unlinkSync(join(tmpDir, name)); } catch { // May not exist } } } test.describe('Rich Text & Attachments (Story 5.9)', () => { test.beforeAll(async () => { // Ensure teacher user exists (same as homework.spec.ts) execSync( `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`, { encoding: 'utf-8' } ); const { schoolId, academicYearId } = resolveDeterministicIds(); try { runSql( `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); runSql( `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); } catch { // May already exist } seedTeacherAssignments(); clearCache(); }); test.afterAll(() => { cleanupTempFiles(); }); test.beforeEach(async () => { try { runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`); } catch { // Table may not exist } clearCache(); }); // ============================================================================ // T4.1 : WYSIWYG Editor // ============================================================================ test.describe('WYSIWYG Editor', () => { test('create form shows rich text editor with toolbar', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); // Rich text editor with toolbar should be visible const editor = page.locator('.rich-text-editor'); await expect(editor).toBeVisible({ timeout: 5000 }); await expect(page.locator('.toolbar')).toBeVisible(); // Toolbar buttons await expect(page.getByRole('button', { name: 'Gras' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Italique' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Liste à puces' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Liste numérotée' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Lien' })).toBeVisible(); }); test('can create homework with rich text description', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); await page.locator('#hw-class').selectOption({ index: 1 }); await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-title').fill('Devoir texte riche'); // Type in rich text editor const editorContent = page.locator('.modal .rich-text-content'); await editorContent.click(); await page.keyboard.type('Consignes importantes'); await page.locator('#hw-due-date').fill(getNextWeekday(5)); await page.getByRole('button', { name: /créer le devoir/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); await expect(page.getByText('Devoir texte riche')).toBeVisible({ timeout: 10000 }); }); test('bold formatting works in editor', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); await page.locator('#hw-class').selectOption({ index: 1 }); await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-title').fill('Devoir gras test'); const editorContent = page.locator('.modal .rich-text-content'); await editorContent.click(); await page.keyboard.type('Normal '); // Apply bold via keyboard shortcut (more reliable than toolbar click) await page.keyboard.press('Control+b'); await page.keyboard.type('en gras'); await page.locator('#hw-due-date').fill(getNextWeekday(5)); await page.getByRole('button', { name: /créer le devoir/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); await expect(page.getByText('Devoir gras test')).toBeVisible({ timeout: 10000 }); // Verify bold is rendered in the description const description = page.locator('.homework-description'); await expect(description.locator('strong')).toContainText('en gras'); }); }); // ============================================================================ // T4.2 : Upload attachment // ============================================================================ test.describe('Attachments', () => { test('can upload a PDF attachment to homework via edit modal', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); // Create homework await createHomework(page, 'Devoir avec PJ'); // Open edit modal const hwCard = page.locator('.homework-card', { hasText: 'Devoir avec PJ' }); await hwCard.getByRole('button', { name: /modifier/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); // Upload file const pdfPath = createTempPdf(); const fileInput = page.locator('.file-input-hidden'); await fileInput.setInputFiles(pdfPath); // File appears in list await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 }); }); // T4.3 : Delete attachment test('can delete an uploaded attachment', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await createHomework(page, 'Devoir suppr PJ'); // Open edit modal and upload const hwCard = page.locator('.homework-card', { hasText: 'Devoir suppr PJ' }); await hwCard.getByRole('button', { name: /modifier/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); const pdfPath = createTempPdf(); await page.locator('.file-input-hidden').setInputFiles(pdfPath); await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 }); // Delete the attachment await page.getByRole('button', { name: /supprimer test-attachment.pdf/i }).click(); await expect(page.getByText('test-attachment.pdf')).not.toBeVisible({ timeout: 5000 }); }); }); // ============================================================================ // T5.9.1 : Invalid file type rejection (P1) // ============================================================================ test.describe('Invalid File Type Rejection', () => { test('rejects a .txt file with an error message', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await createHomework(page, 'Devoir rejet fichier'); // Open edit modal const hwCard = page.locator('.homework-card', { hasText: 'Devoir rejet fichier' }); await hwCard.getByRole('button', { name: /modifier/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); // Try to upload a .txt file const txtPath = createTempTxt(); const fileInput = page.locator('.file-input-hidden'); await fileInput.setInputFiles(txtPath); // Error message should appear const errorAlert = page.locator('[role="alert"]'); await expect(errorAlert).toBeVisible({ timeout: 5000 }); await expect(errorAlert).toContainText('Type de fichier non accepté'); // The .txt file should NOT appear in the file list await expect(page.getByText('test-invalid.txt')).not.toBeVisible(); }); }); // ============================================================================ // T5.9.2 : Attachment persistence after save (P1) // ============================================================================ test.describe('Attachment Persistence', () => { test('uploaded attachment persists after saving and reopening edit modal', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await createHomework(page, 'Devoir persistance PJ'); // Open edit modal const hwCard = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' }); await hwCard.getByRole('button', { name: /modifier/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); // Upload a PDF const pdfPath = createTempPdf(); const fileInput = page.locator('.file-input-hidden'); await fileInput.setInputFiles(pdfPath); await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 }); // Save the changes await page.getByRole('button', { name: /enregistrer/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); // Reopen the edit modal const hwCardAfterSave = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' }); await hwCardAfterSave.getByRole('button', { name: /modifier/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); // The attachment should still be there await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 }); }); }); // ============================================================================ // T5.9.3 : File size display after upload (P2) // ============================================================================ test.describe('File Size Display', () => { test('shows formatted file size after uploading a PDF', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await createHomework(page, 'Devoir taille fichier'); // Open edit modal const hwCard = page.locator('.homework-card', { hasText: 'Devoir taille fichier' }); await hwCard.getByRole('button', { name: /modifier/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); // Upload a PDF const pdfPath = createTempPdf(); const fileInput = page.locator('.file-input-hidden'); await fileInput.setInputFiles(pdfPath); await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 }); // The file size element should be visible and show a formatted size (e.g., "xxx o" or "xxx Ko") const fileSize = page.locator('.file-size'); await expect(fileSize).toBeVisible({ timeout: 5000 }); await expect(fileSize).toHaveText(/\d+(\.\d+)?\s*(o|Ko|Mo)/); }); }); // ============================================================================ // T4.4 : Backward compatibility // ============================================================================ test.describe('Backward Compatibility', () => { test('existing plain text homework displays correctly', async ({ page }) => { // Create homework with plain text description via SQL runSql( `INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir texte brut E2E', 'Description simple sans balise HTML', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` + `FROM users u, school_classes c, subjects s ` + `WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + `AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` + `AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` + `LIMIT 1` ); clearCache(); await loginAsTeacher(page); await navigateToHomework(page); // Plain text description displays correctly await expect(page.getByText('Devoir texte brut E2E')).toBeVisible({ timeout: 10000 }); await expect(page.getByText('Description simple sans balise HTML')).toBeVisible(); }); test('edit modal loads plain text in WYSIWYG editor', async ({ page }) => { runSql( `INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir edit brut E2E', 'Ancienne description', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` + `FROM users u, school_classes c, subjects s ` + `WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + `AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` + `AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` + `LIMIT 1` ); clearCache(); await loginAsTeacher(page); await navigateToHomework(page); // Open edit modal const hwCard = page.locator('.homework-card', { hasText: 'Devoir edit brut E2E' }); await hwCard.getByRole('button', { name: /modifier/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); // WYSIWYG editor contains the old text const editorContent = page.locator('.modal .rich-text-content'); await expect(editorContent).toContainText('Ancienne description', { timeout: 5000 }); }); }); });