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 — unique to this spec to avoid cross-spec collisions const ADMIN_EMAIL = 'e2e-branding-admin@example.com'; const ADMIN_PASSWORD = 'BrandingAdmin123'; const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; // Minimal valid 1x1 transparent PNG for logo upload tests const TEST_LOGO_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', 'base64' ); test.describe('Branding Visual Customization', () => { test.describe.configure({ mode: 'serial' }); test.beforeAll(async () => { const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); // Clear rate limiter to prevent login throttling across serial tests try { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter --env=dev 2>&1`, { encoding: 'utf-8' } ); } catch { // Cache pool may not exist } // Create admin user 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' } ); // Clean up branding data from previous test runs execSync( `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_branding WHERE tenant_id = '${TENANT_ID}'" 2>&1`, { encoding: 'utf-8' } ); // Clean up logo files from previous test runs execSync( `docker compose -f "${composeFile}" exec -T php sh -c "rm -rf /app/public/uploads/logos/${TENANT_ID}" 2>&1`, { encoding: 'utf-8' } ); 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 in all environments } }); test.beforeEach(async () => { const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); try { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter --env=dev 2>&1`, { encoding: 'utf-8' } ); } catch { // Cache pool may not exist } }); // Helper to login as admin 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: 60000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } /** * Waits for the branding page to finish loading. * * After hydration, the page shows the card sections (logo + colors). * Waiting for the heading and the first .card ensures the component * is interactive and API data has been fetched. */ async function waitForPageLoaded(page: import('@playwright/test').Page) { await expect( page.getByRole('heading', { name: /identité visuelle/i }) ).toBeVisible({ timeout: 15000 }); // Wait for at least one card section to appear (loading finished) await expect( page.locator('.card').first() ).toBeVisible({ timeout: 15000 }); } // ============================================================================ // [P2] Page displays logo and color sections (AC1) // ============================================================================ test('[P2] page affiche les sections logo et couleurs', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/branding`); await waitForPageLoaded(page); // Title await expect( page.getByRole('heading', { name: /identité visuelle/i }) ).toBeVisible(); // Subtitle await expect( page.getByText(/personnalisez le logo et les couleurs/i) ).toBeVisible(); // Logo section heading await expect( page.getByRole('heading', { name: /logo de l'établissement/i }) ).toBeVisible(); // Format info await expect( page.getByText(/formats acceptés/i) ).toBeVisible(); // Logo placeholder (no logo initially) await expect( page.getByText(/aucun logo configuré/i) ).toBeVisible(); // Upload button await expect( page.getByText('Importer un logo') ).toBeVisible(); // Color section heading await expect( page.getByRole('heading', { name: /couleur principale/i }) ).toBeVisible(); // Color picker and text input await expect(page.locator('#primaryColorPicker')).toBeVisible(); await expect(page.locator('#primaryColor')).toBeVisible(); // Reset and save buttons await expect( page.getByRole('button', { name: /réinitialiser/i }) ).toBeVisible(); await expect( page.getByRole('button', { name: /enregistrer/i }) ).toBeVisible(); }); // ============================================================================ // [P1] Changing color updates contrast indicator and preview (AC3) // ============================================================================ test('[P1] modifier la couleur met à jour l\'indicateur de contraste et l\'aperçu', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/branding`); await waitForPageLoaded(page); // Use color picker input for reliable cross-browser reactive updates // Wrap in toPass() to handle Firefox timing where fill() may not immediately trigger change event const colorPicker = page.locator('#primaryColorPicker'); // --- Dark blue: passes AA (ratio ~10.3) → "Lisible" --- await expect(async () => { await colorPicker.fill('#1e3a5f'); await expect(page.locator('.contrast-badge')).toContainText('Lisible', { timeout: 2000 }); }).toPass({ timeout: 10000 }); await expect(page.locator('.contrast-indicator.pass')).toBeVisible(); await expect(page.locator('.preview-swatch').first()).toBeVisible(); await expect(page.locator('.preview-swatch').first()).toHaveCSS( 'background-color', 'rgb(30, 58, 95)', { timeout: 5000 } ); // --- Yellow: fails AA completely (ratio ~1.07) → "Illisible" --- await expect(async () => { await colorPicker.fill('#ffff00'); await expect(page.locator('.contrast-badge')).toContainText('Illisible', { timeout: 2000 }); }).toPass({ timeout: 10000 }); await expect(page.locator('.contrast-indicator.fail')).toBeVisible(); // --- Dark yellow: passes AA Large only (ratio ~3.7) → "Attention" --- await expect(async () => { await colorPicker.fill('#8b8000'); await expect(page.locator('.contrast-badge')).toContainText('Attention', { timeout: 2000 }); }).toPass({ timeout: 10000 }); await expect(page.locator('.contrast-indicator.warning')).toBeVisible(); }); // ============================================================================ // [P1] Saving colors applies CSS variables immediately (AC3, AC5) // ============================================================================ test('[P1] enregistrer les couleurs applique les CSS variables immédiatement', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/branding`); await waitForPageLoaded(page); // Set a dark blue color via color picker (more reliable than text input across browsers) // Wrap in toPass() to handle Firefox timing where fill() may not immediately trigger change event await expect(async () => { await page.locator('#primaryColorPicker').fill('#1e3a5f'); await expect(page.getByRole('button', { name: /enregistrer/i })).toBeEnabled({ timeout: 2000 }); }).toPass({ timeout: 10000 }); // Click save and wait for API response const responsePromise = page.waitForResponse( (resp) => resp.url().includes('/school/branding') && resp.request().method() === 'PUT' ); await page.getByRole('button', { name: /enregistrer/i }).click(); await responsePromise; // Success message await expect(page.locator('.alert-success')).toBeVisible({ timeout: 15000 }); await expect(page.locator('.alert-success')).toContainText(/couleurs mises à jour/i); // CSS variables applied to document root const accentPrimary = await page.evaluate(() => document.documentElement.style.getPropertyValue('--accent-primary') ); expect(accentPrimary).toBe('#1E3A5F'); const btnPrimaryBg = await page.evaluate(() => document.documentElement.style.getPropertyValue('--btn-primary-bg') ); expect(btnPrimaryBg).toBe('#1E3A5F'); // Save button should be disabled (no pending changes) await expect( page.getByRole('button', { name: /enregistrer/i }) ).toBeDisabled(); }); // ============================================================================ // [P2] Upload logo displays preview (AC2) // ============================================================================ test('[P2] upload logo affiche l\'aperçu', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/branding`); await waitForPageLoaded(page); // Initially no logo await expect(page.getByText(/aucun logo configuré/i)).toBeVisible(); // Trigger file chooser and upload the test PNG const fileChooserPromise = page.waitForEvent('filechooser'); await page.getByText('Importer un logo').click(); const fileChooser = await fileChooserPromise; const responsePromise = page.waitForResponse( (resp) => resp.url().includes('/school/branding/logo') && resp.request().method() === 'POST' ); await fileChooser.setFiles({ name: 'logo.png', mimeType: 'image/png', buffer: TEST_LOGO_PNG }); await responsePromise; // Success message await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.alert-success')).toContainText(/logo mis à jour/i); // Logo image is now visible await expect(page.locator('.logo-image')).toBeVisible(); // "Changer le logo" and "Supprimer" buttons visible await expect(page.getByText('Changer le logo')).toBeVisible(); await expect( page.getByRole('button', { name: /supprimer/i }) ).toBeVisible(); // Placeholder text is gone await expect(page.getByText(/aucun logo configuré/i)).not.toBeVisible(); }); // ============================================================================ // [P2] Delete logo returns to no-logo state (AC2) // ============================================================================ test('[P2] supprimer logo revient à l\'état sans logo', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/branding`); await waitForPageLoaded(page); // Logo should be visible from previous test await expect(page.locator('.logo-image')).toBeVisible(); // Accept the confirmation dialog, wait for DELETE response, then click page.once('dialog', (dialog) => dialog.accept()); const responsePromise = page.waitForResponse( (resp) => resp.url().includes('/school/branding/logo') && resp.request().method() === 'DELETE' ); await page.getByRole('button', { name: /supprimer/i }).click(); await responsePromise; // Success message await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.alert-success')).toContainText(/logo supprimé/i); // Back to placeholder state await expect(page.getByText(/aucun logo configuré/i)).toBeVisible(); await expect(page.getByText('Importer un logo')).toBeVisible(); }); // ============================================================================ // [P2] Reset restores default theme (AC4) // ============================================================================ test('[P2] réinitialiser restaure le thème par défaut', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/branding`); await waitForPageLoaded(page); // Color should be set from test 3 await expect(page.locator('#primaryColor')).toHaveValue('#1E3A5F'); // Accept the confirmation dialog, wait for PUT response, then click page.once('dialog', (dialog) => dialog.accept()); const responsePromise = page.waitForResponse( (resp) => resp.url().includes('/school/branding') && resp.request().method() === 'PUT' ); await page.getByRole('button', { name: /réinitialiser/i }).click(); await responsePromise; // Success message await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.alert-success')).toContainText(/couleurs mises à jour/i); // Color input is now empty await expect(page.locator('#primaryColor')).toHaveValue(''); // CSS variables removed const accentPrimary = await page.evaluate(() => document.documentElement.style.getPropertyValue('--accent-primary') ); expect(accentPrimary).toBe(''); // Preview swatch should not be visible (no primary color set) await expect(page.locator('.preview-swatch')).not.toBeVisible(); }); });