Les administrateurs peuvent désormais configurer l'identité visuelle de leur établissement : upload d'un logo (PNG/JPG, redimensionné automatiquement via Imagick) et choix d'une couleur principale appliquée aux boutons et à la navigation. La couleur est validée côté client et serveur pour garantir la conformité WCAG AA (contraste ≥ 4.5:1 sur fond blanc). Les personnalisations sont injectées dynamiquement via CSS variables et visibles immédiatement après sauvegarde.
314 lines
12 KiB
TypeScript
314 lines
12 KiB
TypeScript
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');
|
|
|
|
// 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' }
|
|
);
|
|
});
|
|
|
|
// 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: 30000 }),
|
|
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);
|
|
|
|
const colorInput = page.locator('#primaryColor');
|
|
|
|
// --- Dark blue: passes AA (ratio ~10.3) → "Lisible" ---
|
|
await colorInput.fill('#1E3A5F');
|
|
await expect(page.locator('.contrast-indicator.pass')).toBeVisible();
|
|
await expect(page.locator('.contrast-badge')).toContainText('Lisible');
|
|
await expect(page.locator('.preview-swatch').first()).toBeVisible();
|
|
await expect(page.locator('.preview-swatch').first()).toHaveCSS(
|
|
'background-color',
|
|
'rgb(30, 58, 95)'
|
|
);
|
|
|
|
// --- Yellow: fails AA completely (ratio ~1.07) → "Illisible" ---
|
|
await colorInput.fill('#FFFF00');
|
|
await expect(page.locator('.contrast-indicator.fail')).toBeVisible();
|
|
await expect(page.locator('.contrast-badge')).toContainText('Illisible');
|
|
|
|
// --- Dark yellow: passes AA Large only (ratio ~3.7) → "Attention" ---
|
|
await colorInput.fill('#8B8000');
|
|
await expect(page.locator('.contrast-indicator.warning')).toBeVisible();
|
|
await expect(page.locator('.contrast-badge')).toContainText('Attention');
|
|
});
|
|
|
|
// ============================================================================
|
|
// [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
|
|
await page.locator('#primaryColor').fill('#1E3A5F');
|
|
|
|
// 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: 10000 });
|
|
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();
|
|
});
|
|
});
|