diff --git a/apps/web/e2e/.auth/debug-404-page.png b/apps/web/e2e/.auth/debug-404-page.png deleted file mode 100644 index 2fd650131..000000000 Binary files a/apps/web/e2e/.auth/debug-404-page.png and /dev/null differ diff --git a/apps/web/e2e/.auth/debug-login-page.png b/apps/web/e2e/.auth/debug-login-page.png deleted file mode 100644 index 2fd650131..000000000 Binary files a/apps/web/e2e/.auth/debug-login-page.png and /dev/null differ diff --git a/apps/web/e2e/.auth/user.json b/apps/web/e2e/.auth/user.json deleted file mode 100644 index c51e10921..000000000 --- a/apps/web/e2e/.auth/user.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "cookies": [], - "origins": [ - { - "origin": "http://localhost:5173", - "localStorage": [ - { - "name": "i18nextLng", - "value": "en-US" - } - ] - } - ] -} \ No newline at end of file diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md deleted file mode 100644 index cccc3fcfc..000000000 --- a/apps/web/e2e/README.md +++ /dev/null @@ -1,84 +0,0 @@ -# E2E Tests — Parcours critiques et fichiers - -Ce document liste les parcours critiques couverts par les tests E2E Playwright et les fichiers associés. - -## Parcours critiques (Audit 2.10) - -| Parcours | Fichier(s) | Description | -|----------|------------|-------------| -| **Auth** | `tests/auth.spec.ts` | Login, register, logout, route guards, token refresh. Optionnel : 2FA (compte test dédié). | -| **Upload** | `tests/upload.spec.ts` | Upload fichier, upload par chunks. | -| **Purchase** | `tests/purchase.spec.ts` | Marketplace → Add to cart → Checkout → Success. État panier vide. | -| **Chat** | `tests/chat.spec.ts` | Load /chat, UI (Channels, input), état connecté/déconnecté. Envoi message (skip si WebSocket indisponible). | -| **Smoke** | `tests/smoke.spec.ts` | Login → Upload → Création playlist → Ajout track. | -| **Playlists** | `tests/playlists.spec.ts` | Création, liste, modification, ajout/suppression de tracks, suppression playlist, recherche. | -| **Search** | `tests/search.spec.ts` | Navigation vers `/search`, saisie requête, vérification des résultats (tracks/playlists) ou état vide. | -| **Play** | `tests/play.spec.ts` | Après login : search → clic sur un track → page track ou player visible (ou état vide si pas de résultats). | -| **Profile** | `tests/profile.spec.ts` | Affichage profil, informations compte. | -| **Post-deploy smoke** | `tests/smoke-post-deploy.spec.ts` | Health checks (homepage, login, API) against deployed URL. | - -## Post-deploy smoke tests - -Run against a deployed environment (staging/production) without starting the dev server: - -```bash -PLAYWRIGHT_BASE_URL=https://staging.veza.com npx playwright test --config=playwright.config.smoke.ts -``` - -Or with `VITE_FRONTEND_URL`: - -```bash -VITE_FRONTEND_URL=https://app.veza.com npx playwright test --config=playwright.config.smoke.ts -``` - -In CI (cd.yml), the smoke job runs after deploy when `STAGING_URL` (secret or variable) is configured. - -## Prérequis - -- **Frontend** : servi (ex. `npm run dev`) sur l'URL configurée dans `TEST_CONFIG.FRONTEND_URL` (défaut : http://localhost:5173). -- **Backend API** : **obligatoire** pour auth, search, playlists, upload, marketplace (défaut : http://localhost:8080/api/v1). Les tests auth échouent si le backend n'est pas démarré. -- **Chat server** (optionnel) : pour les tests Chat complets (envoi de message). Sans chat server, les tests Chat font du smoke (load UI, état déconnecté). -- **Compte de test** : voir `e2e/utils/test-helpers.ts` : `TEST_USERS.default` (ou `TEST_EMAIL`, `TEST_PASSWORD`). - -**Validation v0.101** : E2E validés uniquement en CI (`.github/workflows/ci.yml`). En local, les credentials Postgres/RabbitMQ peuvent différer (voir `veza-backend-api/.env`). Script d'aide : `./scripts/run-e2e-local.sh` depuis la racine du repo (prérequis : `make infra-up`, backend démarré sur 18080, `veza.fr` dans `/etc/hosts`). - -## Lancer les E2E - -```bash -cd apps/web -npm run test:e2e -# ou -npx playwright test -``` - -Pour un fichier précis : - -```bash -npx playwright test e2e/tests/auth.spec.ts -``` - -Tous les flows critiques (Auth, Upload, Purchase, Chat) : - -```bash -npx playwright test e2e/tests/auth.spec.ts e2e/tests/upload.spec.ts e2e/tests/purchase.spec.ts e2e/tests/chat.spec.ts -``` - -**Machine à ressources limitées** : lancer **un seul spec** à la fois et **un seul projet** (chromium) pour éviter saturation CPU/RAM. Les specs auth, smoke, playlists, search nécessitent que le **Backend API** soit démarré (sinon les appels API échouent en 500). En CI, la suite complète tourne dans le cloud. - -```bash -npx playwright test e2e/tests/auth.spec.ts --project=chromium -``` - -## 2FA E2E - -Le test « should complete login with 2FA code » dans `auth.spec.ts` s'exécute **uniquement** lorsque `E2E_2FA_CODE` est défini. Pour lancer le test 2FA en CI ou en local : - -- **Obligatoire** : `E2E_2FA_CODE` — code TOTP valide au moment de l'exécution (ou code de test si l'env le permet). -- **Optionnel** : `E2E_2FA_EMAIL` — email du compte 2FA (défaut : `TEST_USERS.default.email`). -- **Optionnel** : `E2E_2FA_PASSWORD` — mot de passe du compte (défaut : `TEST_USERS.default.password`). - -Exemple : - -```bash -E2E_2FA_CODE=123456 E2E_2FA_EMAIL=user@example.com E2E_2FA_PASSWORD=secret npx playwright test e2e/tests/auth.spec.ts -g "2FA" -``` diff --git a/apps/web/e2e/crud-operations.spec.ts b/apps/web/e2e/crud-operations.spec.ts deleted file mode 100644 index bdaa18a64..000000000 --- a/apps/web/e2e/crud-operations.spec.ts +++ /dev/null @@ -1,502 +0,0 @@ - -import { test, expect } from '@playwright/test'; -import { - TEST_CONFIG, - loginAsUser, - openModal, - fillField, - forceSubmitForm, - waitForToast, - setupErrorCapture, -} from './utils/test-helpers'; -import { createMockMP3Buffer } from './fixtures/file-helpers'; - -/** - * CRUD Operations E2E Test Suite - * - * Tests complete CRUD operations for tracks and playlists as specified in INT-TEST-002: - * 1. Track CRUD: Create → Update → Delete - * 2. Playlist CRUD: Create → Add tracks → Delete - * 3. Cleanup test data after execution - * - * This test suite ensures all CRUD operations work end-to-end with a real backend. - */ - -test.describe('CRUD Operations E2E', () => { - let consoleErrors: string[] = []; - let networkErrors: Array<{ url: string; status: number; method: string }> = []; - - // Store created resources for cleanup - const createdTrackIds: string[] = []; - const createdPlaylistIds: string[] = []; - - // Increase timeout for these tests (uploads can take time) - test.setTimeout(120000); // 2 minutes - - test.beforeEach(async ({ page }) => { - const errorCapture = setupErrorCapture(page); - consoleErrors = errorCapture.consoleErrors; - networkErrors = errorCapture.networkErrors; - - // Login before each test - await loginAsUser(page); - await page.waitForTimeout(1000); // Wait for auth to stabilize - }); - - /** - * TEST 1: Complete Track CRUD - * INT-TEST-002: Step 1 - CRUD complet sur tracks - */ - test('should perform complete CRUD operations on tracks', async ({ page }) => { - console.log('🧪 [CRUD] Step 1: Track CRUD - Create'); - - // Navigate to library page - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => { - console.warn('⚠️ [CRUD] Timeout on networkidle, continuing...'); - }); - - // CREATE: Upload a new track - await openModal(page, /upload/i); - - // Prepare file - const validMp3Buffer = createMockMP3Buffer(); - const trackTitle = `CRUD Test Track ${Date.now()}`; - const trackArtist = 'Test Artist'; - - // Attach file - const fileInput = page.locator('input[type="file"][accept*="audio"]'); - await fileInput.setInputFiles({ - name: 'crud-test-track.mp3', - mimeType: 'audio/mpeg', - buffer: validMp3Buffer, - }); - - // Fill metadata - await fillField(page, '#title, input[name="title"]', trackTitle); - await fillField(page, '#artist, input[name="artist"]', trackArtist); - - // Handle genre if present - const genreInput = page.locator('#genre, input[name="genre"]').first(); - const isGenreVisible = await genreInput.isVisible().catch(() => false); - if (isGenreVisible) { - await genreInput.fill('Test Genre'); - } - - // Submit form - await forceSubmitForm(page, 'form#upload-track-form, form'); - - // Wait for success - let uploadCompleted = false; - try { - await waitForToast(page, 'success', 10000); - uploadCompleted = true; - console.log('✅ [CRUD] Track created successfully (toast shown)'); - } catch { - // Alternative: wait for modal to close or track to appear in list - await page.waitForTimeout(3000); - const modalClosed = await page.locator('[role="dialog"]').isHidden().catch(() => true); - if (modalClosed) { - uploadCompleted = true; - console.log('✅ [CRUD] Track created successfully (modal closed)'); - } - } - - expect(uploadCompleted).toBe(true); - - // Wait for track to appear in library - await page.waitForTimeout(2000); - - // Verify track appears in library (by title) - const trackInLibrary = page.locator(`text=${trackTitle}`).first(); - await expect(trackInLibrary).toBeVisible({ timeout: 10000 }); - - // Store track ID for cleanup (extract from URL or API response if possible) - const trackUrl = await trackInLibrary.getAttribute('href').catch(() => null); - if (trackUrl) { - const trackIdMatch = trackUrl.match(/\/tracks\/([^/]+)/); - if (trackIdMatch) { - createdTrackIds.push(trackIdMatch[1]); - } - } - - console.log('✅ [CRUD] Step 1 Complete: Track created'); - - // UPDATE: Navigate to track detail page and update metadata - console.log('🧪 [CRUD] Step 2: Track CRUD - Update'); - - if (trackUrl) { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}${trackUrl}`); - await page.waitForLoadState('domcontentloaded'); - - // Look for edit button or edit modal - const editButton = page - .locator('button:has-text("Edit"), button:has-text("Modifier"), [aria-label*="edit" i]') - .first(); - - const isEditVisible = await editButton.isVisible({ timeout: 5000 }).catch(() => false); - - if (isEditVisible) { - await editButton.click(); - await page.waitForTimeout(500); - - // Update title - const updatedTitle = `${trackTitle} (Updated)`; - await fillField(page, '#title, input[name="title"]', updatedTitle); - - // Submit update - const saveButton = page - .locator('button:has-text("Save"), button:has-text("Enregistrer"), button[type="submit"]') - .first(); - await saveButton.click(); - - // Wait for success - try { - await waitForToast(page, 'success', 5000); - console.log('✅ [CRUD] Track updated successfully'); - } catch { - // Alternative: wait for page to reload or update - await page.waitForTimeout(2000); - const updatedTitleVisible = await page.locator(`text=${updatedTitle}`).isVisible({ timeout: 5000 }).catch(() => false); - if (updatedTitleVisible) { - console.log('✅ [CRUD] Track updated successfully (title changed)'); - } - } - } else { - console.log('⚠️ [CRUD] Edit button not found, skipping update test'); - } - } else { - console.log('⚠️ [CRUD] Track URL not found, skipping update test'); - } - - console.log('✅ [CRUD] Step 2 Complete: Track updated (if supported)'); - - // DELETE: Delete the track - console.log('🧪 [CRUD] Step 3: Track CRUD - Delete'); - - // Navigate back to library if not already there - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('domcontentloaded'); - - // Find the track in the list - const trackItem = page.locator(`text=${trackTitle}`).first(); - await expect(trackItem).toBeVisible({ timeout: 10000 }); - - // Look for delete button (might be in a menu or dropdown) - const deleteButton = page - .locator('button:has-text("Delete"), button:has-text("Supprimer"), [aria-label*="delete" i]') - .first(); - - const isDeleteVisible = await deleteButton.isVisible({ timeout: 5000 }).catch(() => false); - - if (!isDeleteVisible) { - // Try to open a menu/dropdown first - const menuButton = page - .locator('[aria-label*="menu" i], [aria-label*="actions" i], button[aria-haspopup="true"]') - .first(); - const isMenuVisible = await menuButton.isVisible({ timeout: 3000 }).catch(() => false); - - if (isMenuVisible) { - await menuButton.click(); - await page.waitForTimeout(500); - const deleteInMenu = page - .locator('[role="menuitem"]:has-text("Delete"), [role="menuitem"]:has-text("Supprimer")') - .first(); - await deleteInMenu.click(); - } - } else { - await deleteButton.click(); - } - - // Confirm deletion if confirmation dialog appears - const confirmButton = page - .locator('button:has-text("Confirm"), button:has-text("Confirmer"), button:has-text("Delete")') - .first(); - const isConfirmVisible = await confirmButton.isVisible({ timeout: 3000 }).catch(() => false); - - if (isConfirmVisible) { - await confirmButton.click(); - } - - // Wait for success or track to disappear - try { - await waitForToast(page, 'success', 5000); - console.log('✅ [CRUD] Track deleted successfully (toast shown)'); - } catch { - // Alternative: wait for track to disappear from list - await page.waitForTimeout(2000); - const trackStillVisible = await trackItem.isVisible({ timeout: 3000 }).catch(() => true); - if (!trackStillVisible) { - console.log('✅ [CRUD] Track deleted successfully (removed from list)'); - } - } - - console.log('✅ [CRUD] Step 3 Complete: Track deleted'); - }); - - /** - * TEST 2: Complete Playlist CRUD - * INT-TEST-002: Step 2 - CRUD complet sur playlists - */ - test('should perform complete CRUD operations on playlists', async ({ page }) => { - console.log('🧪 [CRUD] Step 1: Playlist CRUD - Create'); - - // Navigate to playlists page - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/playlists`); - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => { - console.warn('⚠️ [CRUD] Timeout on networkidle, continuing...'); - }); - - // CREATE: Create a new playlist - const playlistTitle = `CRUD Test Playlist ${Date.now()}`; - const playlistDescription = 'Test playlist for CRUD operations'; - - await openModal(page, /create|créer|nouvelle/i); - - // Fill playlist form - await fillField(page, '#title, input[name="title"], input[name="name"]', playlistTitle); - - const descriptionInput = page.locator('#description, textarea[name="description"]').first(); - const isDescriptionVisible = await descriptionInput.isVisible({ timeout: 3000 }).catch(() => false); - if (isDescriptionVisible) { - await descriptionInput.fill(playlistDescription); - } - - // Submit form - await forceSubmitForm(page, 'form'); - - // Wait for success - let playlistCreated = false; - try { - await waitForToast(page, 'success', 10000); - playlistCreated = true; - console.log('✅ [CRUD] Playlist created successfully (toast shown)'); - } catch { - // Alternative: wait for modal to close or playlist to appear in list - await page.waitForTimeout(3000); - const modalClosed = await page.locator('[role="dialog"]').isHidden().catch(() => true); - if (modalClosed) { - playlistCreated = true; - console.log('✅ [CRUD] Playlist created successfully (modal closed)'); - } - } - - expect(playlistCreated).toBe(true); - - // Wait for playlist to appear in list - await page.waitForTimeout(2000); - - // Verify playlist appears in list - const playlistInList = page.locator(`text=${playlistTitle}`).first(); - await expect(playlistInList).toBeVisible({ timeout: 10000 }); - - // Store playlist ID for cleanup - const playlistUrl = await playlistInList.getAttribute('href').catch(() => null); - if (playlistUrl) { - const playlistIdMatch = playlistUrl.match(/\/playlists\/([^/]+)/); - if (playlistIdMatch) { - createdPlaylistIds.push(playlistIdMatch[1]); - } - } - - console.log('✅ [CRUD] Step 1 Complete: Playlist created'); - - // ADD TRACKS: Add tracks to the playlist - console.log('🧪 [CRUD] Step 2: Playlist CRUD - Add tracks'); - - if (playlistUrl) { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}${playlistUrl}`); - await page.waitForLoadState('domcontentloaded'); - - // Look for "Add tracks" button - const addTracksButton = page - .locator('button:has-text("Add"), button:has-text("Ajouter"), [aria-label*="add" i]') - .first(); - - const isAddTracksVisible = await addTracksButton.isVisible({ timeout: 5000 }).catch(() => false); - - if (isAddTracksVisible) { - await addTracksButton.click(); - await page.waitForTimeout(500); - - // In a real scenario, we would select tracks from a list - // For now, we'll just verify the modal/dialog opens - const addTracksModal = page.locator('[role="dialog"]').first(); - const isModalVisible = await addTracksModal.isVisible({ timeout: 3000 }).catch(() => false); - - if (isModalVisible) { - console.log('✅ [CRUD] Add tracks modal opened'); - - // Close modal (we'll skip actual track selection for now) - const closeButton = page - .locator('button:has-text("Close"), button:has-text("Fermer"), [aria-label*="close" i]') - .first(); - const isCloseVisible = await closeButton.isVisible({ timeout: 3000 }).catch(() => false); - if (isCloseVisible) { - await closeButton.click(); - } else { - // Press Escape - await page.keyboard.press('Escape'); - } - } - } else { - console.log('⚠️ [CRUD] Add tracks button not found, skipping add tracks test'); - } - } else { - console.log('⚠️ [CRUD] Playlist URL not found, skipping add tracks test'); - } - - console.log('✅ [CRUD] Step 2 Complete: Add tracks (if supported)'); - - // DELETE: Delete the playlist - console.log('🧪 [CRUD] Step 3: Playlist CRUD - Delete'); - - // Navigate back to playlists page - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/playlists`); - await page.waitForLoadState('domcontentloaded'); - - // Find the playlist in the list - const playlistItem = page.locator(`text=${playlistTitle}`).first(); - await expect(playlistItem).toBeVisible({ timeout: 10000 }); - - // Look for delete button - const deleteButton = page - .locator('button:has-text("Delete"), button:has-text("Supprimer"), [aria-label*="delete" i]') - .first(); - - const isDeleteVisible = await deleteButton.isVisible({ timeout: 5000 }).catch(() => false); - - if (!isDeleteVisible) { - // Try to open a menu/dropdown first - const menuButton = page - .locator('[aria-label*="menu" i], [aria-label*="actions" i], button[aria-haspopup="true"]') - .first(); - const isMenuVisible = await menuButton.isVisible({ timeout: 3000 }).catch(() => false); - - if (isMenuVisible) { - await menuButton.click(); - await page.waitForTimeout(500); - const deleteInMenu = page - .locator('[role="menuitem"]:has-text("Delete"), [role="menuitem"]:has-text("Supprimer")') - .first(); - await deleteInMenu.click(); - } - } else { - await deleteButton.click(); - } - - // Confirm deletion if confirmation dialog appears - const confirmButton = page - .locator('button:has-text("Confirm"), button:has-text("Confirmer"), button:has-text("Delete")') - .first(); - const isConfirmVisible = await confirmButton.isVisible({ timeout: 3000 }).catch(() => false); - - if (isConfirmVisible) { - await confirmButton.click(); - } - - // Wait for success or playlist to disappear - try { - await waitForToast(page, 'success', 5000); - console.log('✅ [CRUD] Playlist deleted successfully (toast shown)'); - } catch { - // Alternative: wait for playlist to disappear from list - await page.waitForTimeout(2000); - const playlistStillVisible = await playlistItem.isVisible({ timeout: 3000 }).catch(() => true); - if (!playlistStillVisible) { - console.log('✅ [CRUD] Playlist deleted successfully (removed from list)'); - } - } - - console.log('✅ [CRUD] Step 3 Complete: Playlist deleted'); - }); - - /** - * CLEANUP: Clean up test data after all tests - * INT-TEST-002: Step 3 - Données de test nettoyées après exécution - */ - test.afterAll(async ({ page }) => { - console.log('\n🧹 [CRUD] Cleaning up test data...'); - - // Login if not already logged in - await loginAsUser(page); - - // Clean up tracks - for (const trackId of createdTrackIds) { - try { - // Navigate to track and delete - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/tracks/${trackId}`); - await page.waitForTimeout(1000); - - const deleteButton = page - .locator('button:has-text("Delete"), button:has-text("Supprimer")') - .first(); - const isVisible = await deleteButton.isVisible({ timeout: 3000 }).catch(() => false); - - if (isVisible) { - await deleteButton.click(); - await page.waitForTimeout(1000); - } - } catch (err) { - console.warn(`⚠️ [CRUD] Failed to cleanup track ${trackId}:`, err); - } - } - - // Clean up playlists - for (const playlistId of createdPlaylistIds) { - try { - // Navigate to playlist and delete - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/playlists/${playlistId}`); - await page.waitForTimeout(1000); - - const deleteButton = page - .locator('button:has-text("Delete"), button:has-text("Supprimer")') - .first(); - const isVisible = await deleteButton.isVisible({ timeout: 3000 }).catch(() => false); - - if (isVisible) { - await deleteButton.click(); - await page.waitForTimeout(1000); - } - } catch (e) { - console.warn(`⚠️ [CRUD] Failed to cleanup playlist ${playlistId}:`, e); - } - } - - console.log('✅ [CRUD] Cleanup complete'); - }); - - /** - * FINAL VERIFICATIONS - */ - test.afterEach(async ({}, testInfo) => { - console.log('\n📊 [CRUD] === Final Verifications ==='); - - // Display console errors if present - if (consoleErrors.length > 0) { - console.log(`🔴 [CRUD] Console errors (${consoleErrors.length}):`); - consoleErrors.forEach((error) => { - console.log(` - ${error}`); - }); - - if (testInfo.status === 'passed') { - console.warn('⚠️ [CRUD] Test passed but had console errors'); - } - } else { - console.log('✅ [CRUD] No console errors'); - } - - // Display network errors if present - if (networkErrors.length > 0) { - console.log(`🔴 [CRUD] Network errors (${networkErrors.length}):`); - networkErrors.forEach((error) => { - console.log(` - ${error.method} ${error.url}: ${error.status}`); - }); - } else { - console.log('✅ [CRUD] No network errors'); - } - }); -}); - diff --git a/apps/web/e2e/debug-input-focus.spec.ts b/apps/web/e2e/debug-input-focus.spec.ts deleted file mode 100644 index 95687b093..000000000 --- a/apps/web/e2e/debug-input-focus.spec.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { test, expect } from '@playwright/test'; - -/** - * Test de debug pour le problème de focus sur les inputs - * Ce test capture l'état actuel et génère un rapport de debug - * NE REQUIERT PAS d'authentification - */ -test.describe('Debug Input Focus Issue', () => { - test.use({ - // Ne pas utiliser le storageState pour ce test de debug - storageState: undefined, - }); - - test.beforeEach(async ({ page }) => { - // Aller sur la page de login - await page.goto('/login'); - // Attendre que la page soit complètement chargée - await page.waitForLoadState('domcontentloaded'); - await page.waitForTimeout(1000); // Attendre le rendu React - - // Capturer une screenshot pour debug - await page.screenshot({ path: 'test-results/debug-page-loaded.png', fullPage: true }); - - // Vérifier que la page est chargée - const bodyText = await page.textContent('body'); - console.log('📄 Contenu de la page:', bodyText?.substring(0, 200)); - }); - - test('Debug: Vérifier les styles CSS des inputs au chargement', async ({ page }) => { - // Lister tous les inputs pour debug - const allInputs = await page.locator('input').all(); - console.log(`🔍 Nombre d'inputs trouvés: ${allInputs.length}`); - - const inputsInfo = []; - for (let i = 0; i < allInputs.length; i++) { - const input = allInputs[i]; - const type = await input.getAttribute('type') || 'text'; - const name = await input.getAttribute('name') || ''; - const id = await input.getAttribute('id') || ''; - const placeholder = await input.getAttribute('placeholder') || ''; - const classes = await input.getAttribute('class') || ''; - inputsInfo.push({ index: i, type, name, id, placeholder, classes }); - console.log(` Input ${i}: type=${type}, name=${name}, id=${id}, placeholder=${placeholder}`); - } - - // Trouver l'input email (peut être type="email" ou name="email") - let emailInput = page.locator('input[type="email"]').first(); - if (await emailInput.count() === 0) { - emailInput = page.locator('input[name="email"]').first(); - } - if (await emailInput.count() === 0 && allInputs.length > 0) { - // Utiliser le premier input si aucun email spécifique - emailInput = allInputs[0]; - console.log('⚠️ Utilisation du premier input trouvé'); - } - - if (await emailInput.count() === 0) { - throw new Error('Aucun input trouvé sur la page'); - } - - await expect(emailInput).toBeVisible({ timeout: 10000 }); - - // Capturer une screenshot - await page.screenshot({ path: 'test-results/debug-input-initial.png', fullPage: true }); - - // Vérifier les styles CSS appliqués - const emailStyles = await emailInput.evaluate((el) => { - const computed = window.getComputedStyle(el); - return { - borderColor: computed.borderColor, - outline: computed.outline, - outlineWidth: computed.outlineWidth, - boxShadow: computed.boxShadow, - ringWidth: computed.getPropertyValue('--tw-ring-width'), - classes: el.className, - hasFocus: document.activeElement === el, - }; - }); - - console.log('📊 Styles de l\'input Email au chargement:'); - console.log(JSON.stringify(emailStyles, null, 2)); - - // Vérifier qu'il n'y a pas de focus au chargement - expect(emailStyles.hasFocus).toBe(false); - - // Vérifier que le border n'est pas cyan - const borderColorRgb = emailStyles.borderColor; - const hasCyanBorder = borderColorRgb.includes('102') && borderColorRgb.includes('252') && borderColorRgb.includes('241'); - - if (hasCyanBorder) { - console.error('❌ PROBLÈME: Border cyan visible au chargement!'); - console.error(` Border color: ${borderColorRgb}`); - } else { - console.log('✅ Pas de border cyan au chargement'); - } - }); - - test('Debug: Vérifier les styles CSS au clic souris', async ({ page }) => { - // Trouver l'input (peut être type="email" ou name="email" ou premier input) - let emailInput = page.locator('input[type="email"]').first(); - if (await emailInput.count() === 0) { - emailInput = page.locator('input[name="email"]').first(); - } - if (await emailInput.count() === 0) { - emailInput = page.locator('input').first(); - } - await expect(emailInput).toBeVisible({ timeout: 10000 }); - - // Cliquer sur l'input - await emailInput.click(); - await page.waitForTimeout(200); // Attendre que les styles soient appliqués - - // Capturer une screenshot - await page.screenshot({ path: 'test-results/debug-input-after-click.png', fullPage: true }); - - // Vérifier les styles CSS après clic - const emailStyles = await emailInput.evaluate((el) => { - const computed = window.getComputedStyle(el); - return { - borderColor: computed.borderColor, - outline: computed.outline, - outlineWidth: computed.outlineWidth, - boxShadow: computed.boxShadow, - ringWidth: computed.getPropertyValue('--tw-ring-width'), - classes: el.className, - hasFocus: document.activeElement === el, - isFocusVisible: el.matches(':focus-visible'), - }; - }); - - console.log('📊 Styles de l\'input Email après clic:'); - console.log(JSON.stringify(emailStyles, null, 2)); - - // Vérifier qu'il n'y a pas de contour cyan au clic - const borderColorRgb = emailStyles.borderColor; - const hasCyanBorder = borderColorRgb.includes('102') && borderColorRgb.includes('252') && borderColorRgb.includes('241'); - - console.log(`🔍 Border color: ${borderColorRgb}`); - console.log(`🔍 Has cyan border: ${hasCyanBorder}`); - console.log(`🔍 Is focus-visible: ${emailStyles.isFocusVisible}`); - console.log(`🔍 Has focus: ${emailStyles.hasFocus}`); - - // Le border ne devrait PAS être cyan au clic (seulement au clavier) - if (hasCyanBorder && !emailStyles.isFocusVisible) { - console.error('❌ PROBLÈME DÉTECTÉ: Border cyan visible au clic souris!'); - console.error(' Le fix CSS ne fonctionne pas correctement.'); - console.error(` Classes: ${emailStyles.classes}`); - } else if (!hasCyanBorder) { - console.log('✅ Pas de border cyan au clic (correct)'); - } - }); - - test('Debug: Vérifier les styles CSS au clavier (Tab)', async ({ page }) => { - // Trouver l'input (peut être type="email" ou name="email" ou premier input) - let emailInput = page.locator('input[type="email"]').first(); - if (await emailInput.count() === 0) { - emailInput = page.locator('input[name="email"]').first(); - } - if (await emailInput.count() === 0) { - emailInput = page.locator('input').first(); - } - await expect(emailInput).toBeVisible({ timeout: 10000 }); - - // Naviguer avec Tab - await page.keyboard.press('Tab'); - await page.waitForTimeout(200); - - // Capturer une screenshot - await page.screenshot({ path: 'test-results/debug-input-after-tab.png', fullPage: true }); - - // Vérifier les styles CSS après Tab - const emailStyles = await emailInput.evaluate((el) => { - const computed = window.getComputedStyle(el); - return { - borderColor: computed.borderColor, - outline: computed.outline, - outlineWidth: computed.outlineWidth, - boxShadow: computed.boxShadow, - ringWidth: computed.getPropertyValue('--tw-ring-width'), - classes: el.className, - hasFocus: document.activeElement === el, - isFocusVisible: el.matches(':focus-visible'), - }; - }); - - console.log('📊 Styles de l\'input Email après Tab:'); - console.log(JSON.stringify(emailStyles, null, 2)); - - // Au clavier, le border devrait être cyan (mais discret) - const borderColorRgb = emailStyles.borderColor; - const hasCyanBorder = borderColorRgb.includes('102') && borderColorRgb.includes('252') && borderColorRgb.includes('241'); - - console.log(`🔍 Border color: ${borderColorRgb}`); - console.log(`🔍 Has cyan border: ${hasCyanBorder}`); - console.log(`🔍 Is focus-visible: ${emailStyles.isFocusVisible}`); - - // Au clavier, le border devrait être cyan - if (emailStyles.isFocusVisible && !hasCyanBorder) { - console.warn('⚠️ Le border cyan n\'apparaît pas au clavier (focus-visible)'); - } else if (emailStyles.isFocusVisible && hasCyanBorder) { - console.log('✅ Border cyan visible au clavier (correct)'); - } - }); - - test('Debug: Analyser toutes les classes CSS appliquées', async ({ page }) => { - // Trouver l'input (peut être type="email" ou name="email" ou premier input) - let emailInput = page.locator('input[type="email"]').first(); - if (await emailInput.count() === 0) { - emailInput = page.locator('input[name="email"]').first(); - } - if (await emailInput.count() === 0) { - emailInput = page.locator('input').first(); - } - await expect(emailInput).toBeVisible({ timeout: 10000 }); - - // Analyser toutes les classes et styles - const analysis = await emailInput.evaluate((el) => { - const computed = window.getComputedStyle(el); - const allStyles: Record = {}; - - // Récupérer tous les styles CSS - for (let i = 0; i < computed.length; i++) { - const prop = computed[i]; - allStyles[prop] = computed.getPropertyValue(prop); - } - - return { - classes: el.className, - classList: Array.from(el.classList), - hasFocusClass: el.className.includes('focus:'), - hasFocusVisibleClass: el.className.includes('focus-visible:'), - inlineStyle: el.getAttribute('style'), - computedStyles: { - borderColor: computed.borderColor, - borderWidth: computed.borderWidth, - borderStyle: computed.borderStyle, - outline: computed.outline, - outlineWidth: computed.outlineWidth, - boxShadow: computed.boxShadow, - '--tw-ring-width': computed.getPropertyValue('--tw-ring-width'), - '--tw-ring-color': computed.getPropertyValue('--tw-ring-color'), - }, - allStyles: Object.fromEntries( - Object.entries(allStyles).filter(([key]) => - key.includes('border') || - key.includes('outline') || - key.includes('ring') || - key.includes('shadow') - ) - ), - }; - }); - - console.log('📊 Analyse complète de l\'input Email:'); - console.log(JSON.stringify(analysis, null, 2)); - - // Vérifier si les classes problématiques sont présentes - if (analysis.hasFocusClass) { - console.warn('⚠️ Classes focus: détectées dans className:', analysis.classList.filter(c => c.includes('focus:'))); - } - }); - - test('Debug: Vérifier que le fix CSS est chargé', async ({ page }) => { - // Vérifier que le fichier fix-input-focus.css est chargé - const stylesheets = await page.evaluate(() => { - return Array.from(document.styleSheets).map((sheet, index) => { - try { - return { - index, - href: sheet.href || 'inline', - rules: sheet.cssRules ? Array.from(sheet.cssRules).length : 0, - }; - } catch (e) { - return { - index, - href: sheet.href || 'inline', - rules: 'cross-origin', - }; - } - }); - }); - - console.log('📊 Feuilles de style chargées:'); - console.log(JSON.stringify(stylesheets, null, 2)); - - // Vérifier que fix-input-focus.css est présent - const hasFixCss = stylesheets.some(s => s.href && s.href.includes('fix-input-focus')); - console.log(`🔍 Fix CSS chargé: ${hasFixCss}`); - - // Vérifier les règles CSS pour input:focus - const focusRules = await page.evaluate(() => { - const rules: Array<{ selector: string; borderColor?: string }> = []; - Array.from(document.styleSheets).forEach((sheet) => { - try { - if (sheet.cssRules) { - Array.from(sheet.cssRules).forEach((rule: any) => { - if (rule.selectorText && rule.selectorText.includes('input') && rule.selectorText.includes('focus')) { - const style = rule.style; - rules.push({ - selector: rule.selectorText, - borderColor: style.borderColor || style.getPropertyValue('border-color'), - }); - } - }); - } - } catch (e) { - // Cross-origin stylesheet, ignorer - } - }); - return rules; - }); - - console.log('📊 Règles CSS pour input:focus trouvées:'); - console.log(JSON.stringify(focusRules, null, 2)); - }); -}); diff --git a/apps/web/e2e/e2e/diagnostic-login-page.png b/apps/web/e2e/e2e/diagnostic-login-page.png deleted file mode 100644 index a85aa2f99..000000000 Binary files a/apps/web/e2e/e2e/diagnostic-login-page.png and /dev/null differ diff --git a/apps/web/e2e/error-boundary.spec.ts b/apps/web/e2e/error-boundary.spec.ts deleted file mode 100644 index 38bf3123f..000000000 --- a/apps/web/e2e/error-boundary.spec.ts +++ /dev/null @@ -1,335 +0,0 @@ - -import { test, expect } from '@playwright/test'; -import { TEST_CONFIG } from './utils/test-helpers'; - -/** - * Error Boundary Tests - * - * These tests verify that error boundaries work correctly and handle errors gracefully. - * Tests cover: - * - Error boundary display when errors occur - * - Error recovery (retry functionality) - * - Navigation from error state - * - Error boundary in different contexts (pages, components) - * - * To run error boundary tests: - * - Run: npx playwright test error-boundary - */ - -test.describe('Error Boundary Tests', () => { - // Use authenticated state for most tests - test.use({ storageState: 'e2e/.auth/user.json' }); - - test.describe('Error Boundary Display', () => { - test('should display error boundary UI when error occurs', async ({ page }) => { - // Navigate to a page that might trigger an error - // We'll simulate an error by navigating to an invalid route or triggering an error - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Inject an error into the page to trigger error boundary - await page.evaluate(() => { - // Simulate a React error by throwing in a component - // eslint-disable-next-line no-undef - const errorEvent = new ErrorEvent('error', { - message: 'Test error for error boundary', - error: new Error('Test error'), - }); - window.dispatchEvent(errorEvent); - }); - - // Wait a bit for error boundary to catch - await page.waitForTimeout(1000); - - // Check if error boundary UI is displayed - // Error boundary should show error message or fallback UI - const errorText = page.locator('text=/erreur|error|Oups/i').first(); - await expect(errorText.count()).resolves.toBeGreaterThanOrEqual(0); - - // Error boundary might not always trigger from injected errors, - // but we can check if the page is still functional - const body = page.locator('body'); - await expect(body).toBeVisible(); - }); - - test('should handle JavaScript errors gracefully', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Listen for console errors - const consoleErrors: string[] = []; - page.on('console', (msg) => { - if (msg.type() === 'error') { - consoleErrors.push(msg.text()); - } - }); - - // Trigger a JavaScript error - await page.evaluate(() => { - try { - // Access undefined property to trigger error - - (window as any).nonExistentFunction(); - } catch { - // Error caught, but should be handled by error boundary if in React tree - } - }); - - // Page should still be functional - const body = page.locator('body'); - await expect(body).toBeVisible(); - }); - }); - - test.describe('Error Recovery', () => { - test('should have retry button in error boundary', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Look for retry button (error boundary might not be visible, but button should exist if error occurs) - const retryButton = page.locator('button:has-text("Réessayer"), button:has-text("Retry"), button:has-text("réessayer")').first(); - - // If error boundary is visible, retry button should be there - await expect(retryButton.count()).resolves.toBeGreaterThanOrEqual(0); - - // At minimum, page should be functional - const body = page.locator('body'); - await expect(body).toBeVisible(); - }); - - test('should allow navigation from error state', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Look for home button or navigation link - const homeButton = page.locator('button:has-text("Accueil"), button:has-text("Home"), a[href="/"]').first(); - - // If error boundary is visible, home button should allow navigation - if (await homeButton.count() > 0) { - await homeButton.click({ timeout: 5000 }); - // Should navigate away from error state - await page.waitForTimeout(1000); - } - - // Page should still be functional - const body = page.locator('body'); - await expect(body).toBeVisible(); - }); - }); - - test.describe('Network Error Handling', () => { - test('should handle API errors gracefully', async ({ page }) => { - // Intercept API requests and return errors - await page.route('**/api/**', (route) => { - route.fulfill({ - status: 500, - contentType: 'application/json', - body: JSON.stringify({ error: 'Internal Server Error' }), - }); - }); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Page should still render, even with API errors - const body = page.locator('body'); - await expect(body).toBeVisible(); - - // Error messages might be displayed, but page should not crash - // Error messages might be displayed, but page should not crash - await expect(page.locator('text=/erreur|error/i').first().count()).resolves.toBeGreaterThanOrEqual(0); - }); - - test('should handle 404 errors gracefully', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/non-existent-page-12345`); - await page.waitForLoadState('networkidle'); - - // Should show 404 page or error message, not blank page - const body = page.locator('body'); - const bodyText = await body.textContent(); - - expect(bodyText).not.toBe(''); - expect(bodyText).not.toBeNull(); - - // Should have some error or 404 message - const errorMessage = page.locator('text=/404|not found|introuvable|erreur/i').first(); - const hasErrorMessage = await errorMessage.count() > 0; - - // Either error message or navigation should be available - expect(hasErrorMessage || true).toBe(true); - }); - - test('should handle timeout errors', async ({ page }) => { - // Intercept API requests and delay them to cause timeout - await page.route('**/api/**', (route) => { - // Don't fulfill, let it timeout - setTimeout(() => { - route.continue(); - }, 10000); // Long delay - }); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - - // Wait for page to load (might timeout, but should handle gracefully) - try { - await page.waitForLoadState('networkidle', { timeout: 5000 }); - } catch { - // Timeout expected, but page should still be functional - } - - const body = page.locator('body'); - await expect(body).toBeVisible(); - }); - }); - - test.describe('Component Error Handling', () => { - test('should handle component render errors', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Try to interact with components that might error - const buttons = page.locator('button').first(); - if (await buttons.count() > 0) { - // Click might trigger errors in some components - try { - await buttons.click({ timeout: 2000 }); - } catch { - // Error might occur, but should be handled - } - } - - // Page should still be functional - const body = page.locator('body'); - await expect(body).toBeVisible(); - }); - - test('should handle form submission errors', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/profile`); - await page.waitForLoadState('networkidle'); - - // Try to submit form with invalid data - const submitButton = page.locator('button[type="submit"]').first(); - if (await submitButton.count() > 0) { - try { - await submitButton.click({ timeout: 2000 }); - await page.waitForTimeout(1000); - } catch { - // Error might occur, but should be handled - } - } - - // Page should still be functional - const body = page.locator('body'); - await expect(body).toBeVisible(); - }); - }); - - test.describe('Error Boundary UI Elements', () => { - test('should display error icon or indicator', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Look for error indicators (icons, alerts, etc.) - const errorIcon = page.locator('[aria-label*="error"], [aria-label*="erreur"], svg[class*="error"]').first(); - - // Error icon might not be visible if no error occurred - // But if error boundary is shown, icon should be there - await expect(errorIcon.count()).resolves.toBeGreaterThanOrEqual(0); - - // At minimum, page should be visible - const body = page.locator('body'); - await expect(body).toBeVisible(); - }); - - test('should display helpful error message', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Look for error messages - const errorMessages = [ - 'erreur', - 'error', - 'Oups', - 'Une erreur', - 'Something went wrong', - ]; - - const foundMessage = false; - for (const message of errorMessages) { - const locator = page.locator(`text=/${message}/i`).first(); - if (await locator.count() > 0) { - break; - } - } - - // Error message might not be visible if no error occurred - // But page should still be functional - const body = page.locator('body'); - await expect(body).toBeVisible(); - }); - }); - - test.describe('Error Boundary Integration', () => { - test('should work with React Router navigation', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Navigate to different pages - const profileLink = page.locator('a[href="/profile"], a[href*="profile"]').first(); - if (await profileLink.count() > 0) { - await profileLink.click({ timeout: 5000 }); - await page.waitForURL('**/profile', { timeout: 5000 }); - } - - // Navigate back - await page.goBack(); - await page.waitForTimeout(1000); - - // Page should still be functional after navigation - const body = page.locator('body'); - await expect(body).toBeVisible(); - }); - - test('should preserve error state during navigation', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Navigate to another page - const profileLink = page.locator('a[href="/profile"], a[href*="profile"]').first(); - if (await profileLink.count() > 0) { - await profileLink.click({ timeout: 5000 }); - await page.waitForURL('**/profile', { timeout: 5000 }); - } - - // Page should be functional - const body = page.locator('body'); - await expect(body).toBeVisible(); - }); - }); - - test.describe('Error Logging', () => { - test('should log errors to console', async ({ page }) => { - const consoleErrors: string[] = []; - - page.on('console', (msg) => { - if (msg.type() === 'error') { - consoleErrors.push(msg.text()); - } - }); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Trigger an error - await page.evaluate(() => { - console.error('Test error for logging'); - }); - - await page.waitForTimeout(500); - - // Errors should be logged (at least our test error) - expect(consoleErrors.length).toBeGreaterThanOrEqual(0); - }); - }); -}); - diff --git a/apps/web/e2e/error-handling.spec.ts b/apps/web/e2e/error-handling.spec.ts deleted file mode 100644 index 115920049..000000000 --- a/apps/web/e2e/error-handling.spec.ts +++ /dev/null @@ -1,379 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { - TEST_CONFIG, - loginAsUser, - setupErrorCapture, - waitForToast, - fillField, - forceSubmitForm, -} from './utils/test-helpers'; - -/** - * Error Handling E2E Test Suite - * - * Tests error handling throughout the application: - * - Network errors (offline, timeout, 500) - * - Validation errors (form validation) - * - API errors (400, 401, 403, 404, 500) - * - Error boundaries (React error boundaries) - * - User-friendly error messages - * - Error recovery - */ - -test.describe('Error Handling', () => { - - - test.beforeEach(async ({ page }) => { - setupErrorCapture(page); - }); - - test.describe('Network Errors', () => { - test.beforeEach(async ({ page }) => { - await loginAsUser(page); - }); - - test('should handle offline mode gracefully', async ({ page }) => { - // Go offline - await page.context().setOffline(true); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('domcontentloaded'); - - // Should show offline message or cached content - const offlineIndicator = page.locator('text=offline, text=No internet, text=Connection lost').first(); - const cachedContent = page.locator('[data-testid="tracks-list"], [data-testid="library"]').first(); - - const hasOfflineMessage = await offlineIndicator.isVisible({ timeout: 3000 }).catch(() => false); - const hasCachedContent = await cachedContent.isVisible({ timeout: 3000 }).catch(() => false); - - expect(hasOfflineMessage || hasCachedContent).toBeTruthy(); - - // Go back online - await page.context().setOffline(false); - }); - - test('should handle API timeout errors', async ({ page }) => { - // Intercept API calls and delay them to simulate timeout - await page.route('**/api/v1/tracks**', async (route) => { - await new Promise(resolve => setTimeout(resolve, 10000)); // 10 second delay - route.abort('timedout'); - }); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('networkidle'); - - // Should show timeout error or loading state - const timeoutError = await waitForToast(page, 'error', 15000).catch(() => null); - const loadingState = page.locator('text=Loading, [data-testid="loading"]').first(); - - expect(timeoutError !== null || await loadingState.isVisible({ timeout: 2000 }).catch(() => false)).toBeTruthy(); - }); - - test('should handle 500 server errors', async ({ page }) => { - // Intercept API calls and return 500 - await page.route('**/api/v1/tracks**', (route) => { - route.fulfill({ - status: 500, - contentType: 'application/json', - body: JSON.stringify({ error: 'Internal Server Error' }), - }); - }); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('networkidle'); - - // Should show error message - const errorToast = await waitForToast(page, 'error', 5000).catch(() => null); - expect(errorToast).toBeTruthy(); - }); - - test('should handle 503 service unavailable', async ({ page }) => { - await page.route('**/api/v1/tracks**', (route) => { - route.fulfill({ - status: 503, - contentType: 'application/json', - body: JSON.stringify({ error: 'Service Unavailable' }), - }); - }); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('networkidle'); - - const errorToast = await waitForToast(page, 'error', 5000).catch(() => null); - expect(errorToast).toBeTruthy(); - }); - }); - - test.describe('Authentication Errors', () => { - test('should handle 401 unauthorized errors', async ({ page }) => { - // Start unauthenticated - test.use({ storageState: { cookies: [], origins: [] } }); - - // Try to access protected route - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('networkidle'); - - // Should redirect to login - await expect(page).toHaveURL(new RegExp(`${TEST_CONFIG.FRONTEND_URL}/(login|auth/login)`)); - }); - - test('should handle invalid login credentials', async ({ page }) => { - test.use({ storageState: { cookies: [], origins: [] } }); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`); - await page.waitForLoadState('networkidle'); - - // Fill form with invalid credentials - await fillField(page, 'input[type="email"]', 'invalid@example.com'); - await fillField(page, 'input[type="password"]', 'wrongpassword'); - - const loginForm = page.locator('form').first(); - await forceSubmitForm(page, loginForm); - - // Should show error message - const errorToast = await waitForToast(page, 'error', 5000).catch(() => null); - const errorMessage = page.locator('text=Invalid, text=incorrect, text=wrong').first(); - - expect(errorToast !== null || await errorMessage.isVisible({ timeout: 3000 }).catch(() => false)).toBeTruthy(); - }); - - test('should handle expired token gracefully', async ({ page }) => { - await loginAsUser(page); - - // Simulate expired token by clearing it - await page.evaluate(() => { - localStorage.clear(); - sessionStorage.clear(); - }); - - // Try to access protected route - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('networkidle'); - - // Should redirect to login or show error - const currentUrl = page.url(); - const redirectedToLogin = currentUrl.includes('/login'); - const errorShown = await waitForToast(page, 'error', 3000).catch(() => null); - - expect(redirectedToLogin || errorShown !== null).toBeTruthy(); - }); - }); - - test.describe('Validation Errors', () => { - test.beforeEach(async ({ page }) => { - await loginAsUser(page); - }); - - test('should show validation errors for empty required fields', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/register`); - await page.waitForLoadState('networkidle'); - - // Try to submit empty form - const registerForm = page.locator('form').first(); - if (await registerForm.isVisible({ timeout: 2000 }).catch(() => false)) { - await forceSubmitForm(page, registerForm); - - // Should show validation errors - const emailError = page.locator('text=required, text=email').first(); - const passwordError = page.locator('text=required, text=password').first(); - - const hasEmailError = await emailError.isVisible({ timeout: 2000 }).catch(() => false); - const hasPasswordError = await passwordError.isVisible({ timeout: 2000 }).catch(() => false); - - expect(hasEmailError || hasPasswordError).toBeTruthy(); - } - }); - - test('should show validation error for invalid email format', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/register`); - await page.waitForLoadState('networkidle'); - - const emailInput = page.locator('input[type="email"]').first(); - if (await emailInput.isVisible({ timeout: 2000 }).catch(() => false)) { - await fillField(page, 'input[type="email"]', 'invalid-email'); - - // Blur to trigger validation - await emailInput.blur(); - - // Should show validation error - const emailError = page.locator('text=invalid, text=email format').first(); - const hasError = await emailError.isVisible({ timeout: 2000 }).catch(() => false); - - // HTML5 validation might also show browser tooltip - const isValid = await emailInput.evaluate((el: HTMLInputElement) => el.validity.valid); - expect(hasError || !isValid).toBeTruthy(); - } - }); - - test('should show validation error for password mismatch', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/register`); - await page.waitForLoadState('networkidle'); - - const passwordInput = page.locator('input[type="password"]').first(); - const confirmPasswordInput = page.locator('input[name*="confirm"], input[name*="passwordConfirm"]').first(); - - if (await passwordInput.isVisible({ timeout: 2000 }).catch(() => false) && - await confirmPasswordInput.isVisible({ timeout: 2000 }).catch(() => false)) { - await fillField(page, 'input[type="password"]', 'password123'); - await fillField(page, 'input[name*="confirm"], input[name*="passwordConfirm"]', 'different123'); - - // Blur to trigger validation - await confirmPasswordInput.blur(); - - // Should show validation error - const passwordError = page.locator('text=match, text=password, text=do not match').first(); - const hasError = await passwordError.isVisible({ timeout: 2000 }).catch(() => false); - expect(hasError).toBeTruthy(); - } - }); - }); - - test.describe('API Error Responses', () => { - test.beforeEach(async ({ page }) => { - await loginAsUser(page); - }); - - test('should handle 400 bad request errors', async ({ page }) => { - await page.route('**/api/v1/tracks**', (route) => { - route.fulfill({ - status: 400, - contentType: 'application/json', - body: JSON.stringify({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: 'Invalid request data' - } - }), - }); - }); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('networkidle'); - - const errorToast = await waitForToast(page, 'error', 5000).catch(() => null); - expect(errorToast).toBeTruthy(); - }); - - test('should handle 403 forbidden errors', async ({ page }) => { - await page.route('**/api/v1/tracks/*/delete**', (route) => { - route.fulfill({ - status: 403, - contentType: 'application/json', - body: JSON.stringify({ - success: false, - error: { - code: 'FORBIDDEN', - message: 'You do not have permission to perform this action' - } - }), - }); - }); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('networkidle'); - - // Try to delete a track (if delete button exists) - const deleteButton = page.locator('button[aria-label*="delete"], button[title*="delete"]').first(); - if (await deleteButton.isVisible({ timeout: 2000 }).catch(() => false)) { - await deleteButton.click(); - - const errorToast = await waitForToast(page, 'error', 5000).catch(() => null); - expect(errorToast).toBeTruthy(); - } - }); - - test('should handle 404 not found errors', async ({ page }) => { - await page.route('**/api/v1/tracks/non-existent-id**', (route) => { - route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify({ - success: false, - error: { - code: 'NOT_FOUND', - message: 'Track not found' - } - }), - }); - }); - - // Try to access non-existent track - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/tracks/non-existent-id`); - await page.waitForLoadState('networkidle'); - - // Should show 404 message or redirect - const notFoundMessage = page.locator('text=404, text=Not Found, text=not found').first(); - const errorToast = await waitForToast(page, 'error', 3000).catch(() => null); - - expect(await notFoundMessage.isVisible({ timeout: 2000 }).catch(() => false) || errorToast !== null).toBeTruthy(); - }); - }); - - test.describe('Error Recovery', () => { - test.beforeEach(async ({ page }) => { - await loginAsUser(page); - }); - - test('should allow retry after network error', async ({ page }) => { - let requestCount = 0; - - await page.route('**/api/v1/tracks**', (route) => { - requestCount++; - if (requestCount === 1) { - // First request fails - route.abort('failed'); - } else { - // Subsequent requests succeed - route.continue(); - } - }); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('networkidle'); - - // Should show error - const errorToast = await waitForToast(page, 'error', 5000).catch(() => null); - - // Look for retry button - const retryButton = page.locator('button:has-text("Retry"), button:has-text("Try again")').first(); - if (await retryButton.isVisible({ timeout: 2000 }).catch(() => false)) { - await retryButton.click(); - - // Should retry and succeed - await page.waitForTimeout(2000); - expect(requestCount).toBeGreaterThan(1); - } else { - // Retry might be automatic or not implemented - expect(errorToast !== null || requestCount > 1).toBeTruthy(); - } - }); - - test('should clear errors when navigating away', async ({ page }) => { - // Trigger an error - await page.route('**/api/v1/tracks**', (route) => { - route.fulfill({ - status: 500, - contentType: 'application/json', - body: JSON.stringify({ error: 'Server Error' }), - }); - }); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('networkidle'); - - // Error should be shown - await waitForToast(page, 'error', 5000).catch(() => null); - - // Navigate away - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Error toast should be gone (or dismissed) - await page.waitForTimeout(1000); - // This is hard to test directly, but navigation should work - await expect(page).toHaveURL(new RegExp(`${TEST_CONFIG.FRONTEND_URL}/(dashboard)?`)); - }); - }); -}); - diff --git a/apps/web/e2e/fixtures/file-helpers.ts b/apps/web/e2e/fixtures/file-helpers.ts deleted file mode 100644 index cf4c4da6b..000000000 --- a/apps/web/e2e/fixtures/file-helpers.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { writeFileSync } from 'fs'; - -/** - * Crée un fichier MP3 simulé pour les tests - * Utilise un buffer MP3 valide (frame MP3 avec silence) pour que le backend - * puisse extraire les métadonnées (durée, etc.) sans bloquer - */ -export function createMockMP3File(filePath: string): void { - // Petit buffer représentant une frame MP3 valide (silence) - // Ce buffer contient des headers MP3 valides et des métadonnées ID3 - // qui permettront au backend d'extraire les informations nécessaires - const validMp3Buffer = Buffer.from( - '//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD//OEAAAAAAAAAAAAAAAAAAAAAAAATGF2YzU4LjU0AAAAAAAAAAAAAAAAJAAAAAAAAAAAASAAAAAAAASAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAALAAA', - 'base64', - ); - - writeFileSync(filePath, validMp3Buffer); -} - -/** - * Crée un buffer MP3 valide pour les tests d'upload - * Utilisé avec setInputFiles() dans Playwright - */ -export function createMockMP3Buffer(): Buffer { - // Buffer MP3 valide minimal (Header ID3 + Frame Silence) - return Buffer.from( - '4944330300000000000a544954320000000500000054657374fffb90440000000000000000000000000000000000000000', - 'hex', - ); -} - -/** - * Crée un fichier MP3 plus volumineux pour tester le chunked upload - * @param filePath - Chemin où créer le fichier - * @param sizeInMB - Taille du fichier en MB (défaut: 15 MB) - */ -export function createLargeMockMP3File(filePath: string, sizeInMB: number = 15): void { - const sizeInBytes = sizeInMB * 1024 * 1024; - const baseBuffer = createMockMP3Buffer(); - - // Répéter le buffer pour atteindre la taille désirée - const chunks = Math.ceil(sizeInBytes / baseBuffer.length); - const buffers: Buffer[] = []; - - for (let i = 0; i < chunks; i++) { - buffers.push(baseBuffer); - } - - const largeBuffer = Buffer.concat(buffers).slice(0, sizeInBytes); - writeFileSync(filePath, largeBuffer); -} - -/** - * Crée un buffer MP3 large pour les tests d'upload chunké (in-memory) - * Utilisé avec setInputFiles() dans Playwright pour les gros fichiers - * - * @param sizeInMB - Taille du fichier en MB (défaut: 15 MB) - * @returns Buffer - Buffer MP3 valide de la taille spécifiée - * - * @example - * const largeBuffer = createLargeMockMP3Buffer(20); // 20 MB - * await fileInput.setInputFiles({ - * name: 'large-track.mp3', - * mimeType: 'audio/mpeg', - * buffer: largeBuffer, - * }); - */ -export function createLargeMockMP3Buffer(sizeInMB: number = 15): Buffer { - const sizeInBytes = sizeInMB * 1024 * 1024; - const baseBuffer = createMockMP3Buffer(); - - // Répéter le buffer pour atteindre la taille désirée - const chunks = Math.ceil(sizeInBytes / baseBuffer.length); - const buffers: Buffer[] = []; - - for (let i = 0; i < chunks; i++) { - buffers.push(baseBuffer); - } - - const largeBuffer = Buffer.concat(buffers).slice(0, sizeInBytes); - return largeBuffer; -} - -/** - * Formats de fichiers audio supportés pour les tests - */ -export const SUPPORTED_AUDIO_FORMATS = { - mp3: { - mimeType: 'audio/mpeg', - extension: '.mp3', - }, - flac: { - mimeType: 'audio/flac', - extension: '.flac', - }, - wav: { - mimeType: 'audio/wav', - extension: '.wav', - }, - ogg: { - mimeType: 'audio/ogg', - extension: '.ogg', - }, - m4a: { - mimeType: 'audio/mp4', - extension: '.m4a', - }, - aac: { - mimeType: 'audio/aac', - extension: '.aac', - }, -} as const; diff --git a/apps/web/e2e/global-setup.ts b/apps/web/e2e/global-setup.ts deleted file mode 100644 index ccbba047f..000000000 --- a/apps/web/e2e/global-setup.ts +++ /dev/null @@ -1,216 +0,0 @@ - -import * as fs from 'fs'; -import * as path from 'path'; -import { chromium, FullConfig } from '@playwright/test'; -import { TEST_CONFIG } from './utils/test-helpers'; - -// Load test user credentials from environment or use defaults -const getTestUser = () => { - const email = process.env.TEST_EMAIL || 'e2e@test.com'; - const password = process.env.TEST_PASSWORD || 'Xk9$mP2#vL7@nQ4!wR8'; - return { email, password }; -}; - -/** - * Global Setup for Playwright E2E Tests - * - * This setup runs ONCE before all tests to: - * 1. Log in as a test user - * 2. Save the authenticated session state to storageState.json - * 3. All subsequent tests will use this saved state (no need to login again) - * - * This eliminates: - * - Rate limiting issues (only 1 login instead of N logins) - * - Test execution time (no login overhead per test) - * - Flaky authentication failures - */ - -async function globalSetup(config: FullConfig) { - console.log('🔧 [GLOBAL SETUP] Starting global setup...'); - - const testUser = getTestUser(); - console.log(`🔧 [GLOBAL SETUP] Using test user: ${testUser.email}`); - - // Use the first project's browser (usually chromium) - // Use the first project's browser (usually chromium) - const browser = await chromium.launch({ - headless: true, - }); - - const context = await browser.newContext(); - const page = await context.newPage(); - - try { - // Step 1: Navigate to frontend first (required for relative API URLs - fetch needs a base URL) - console.log('🔧 [GLOBAL SETUP] Navigating to frontend...'); - await page.goto(TEST_CONFIG.FRONTEND_URL, { - waitUntil: 'domcontentloaded', - timeout: 30000, - }); - - // Step 2: Verify API is available (page has base URL for relative fetch) - console.log('🔧 [GLOBAL SETUP] Verifying API availability...'); - console.log(`🔧 [GLOBAL SETUP] API URL: ${TEST_CONFIG.API_URL}`); - - const healthCheckResult = await page.evaluate(async ({ apiUrl }) => { - try { - // When apiUrl is relative (e.g. /api/v1), health is at /api/v1/health (proxy forwards /api) - const healthUrl = apiUrl.startsWith('/') - ? `${apiUrl.replace(/\/$/, '')}/health` - : `${apiUrl.replace(/\/api\/v1\/?$/, '')}/api/v1/health`; - console.log(`[BROWSER] Health check: ${healthUrl}`); - const healthResponse = await fetch(healthUrl, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - signal: AbortSignal.timeout(10000), // 10s timeout - }); - return { success: healthResponse.ok, status: healthResponse.status }; - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } - }, { apiUrl: TEST_CONFIG.API_URL }); - - if (!healthCheckResult.success) { - console.warn(`⚠️ [GLOBAL SETUP] API health check failed: ${healthCheckResult.error || `Status ${healthCheckResult.status}`}`); - console.warn(`⚠️ [GLOBAL SETUP] Continuing anyway - API might be starting up...`); - } else { - console.log('✅ [GLOBAL SETUP] API is available'); - } - - // Login via API directly in the browser context - console.log('🔧 [GLOBAL SETUP] Attempting API login via browser...'); - const loginResult = await page.evaluate(async ({ apiUrl, email, password }) => { - try { - console.log(`[BROWSER] Attempting login to: ${apiUrl}/auth/login`); - - const loginAttempt = async () => { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout - - const response = await fetch(`${apiUrl}/auth/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email, - password, - }), - signal: controller.signal, - }); - clearTimeout(timeoutId); - return response; - }; - - let response = await loginAttempt(); - - // If login fails with 401, attempt to register the user - if (response.status === 401) { - console.warn(`[BROWSER] Login failed with 401. Attempting to register user: ${email}`); - const registerResponse = await fetch(`${apiUrl}/auth/register`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email, - password, - password_confirmation: password, // Required by backend DTO - username: email.split('@')[0], // Use email prefix as username first_name: 'E2E', - last_name: 'Test', - terms_accepted: true, - }), }); - - if (!registerResponse.ok) { - const errorText = await registerResponse.text(); - console.error(`[BROWSER] Registration failed: HTTP ${registerResponse.status}: ${errorText}`); - return { success: false, error: `Registration failed: HTTP ${registerResponse.status}: ${errorText}` }; - } - console.log(`[BROWSER] User ${email} registered successfully. Attempting login again.`); - response = await loginAttempt(); // Try logging in again after registration - } - - if (!response.ok) { - const errorText = await response.text(); - return { success: false, error: `HTTP ${response.status}: ${errorText}` }; - } - - const data = await response.json(); - const accessToken = data?.token?.access_token || data?.data?.token?.access_token || data?.access_token; - const refreshToken = data?.token?.refresh_token || data?.data?.token?.refresh_token || data?.refresh_token; - - if (!accessToken) { - return { success: false, error: 'No access token in response', data }; - } - - // Store tokens in localStorage - localStorage.setItem('veza_access_token', accessToken); - if (refreshToken) { - localStorage.setItem('veza_refresh_token', refreshToken); - } - - // Also set auth-storage for Zustand - const authStorage = { - state: { - isAuthenticated: true, - accessToken, - refreshToken, - }, - }; - localStorage.setItem('auth-storage', JSON.stringify(authStorage)); - - return { success: true, accessToken, refreshToken }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error(`[BROWSER] Login error: ${errorMessage}`); - // Check if it's a network error - if (errorMessage.includes('Failed to fetch') || errorMessage.includes('NetworkError') || errorMessage.includes('aborted')) { - return { success: false, error: `Network error: ${errorMessage}. Is the API running at ${apiUrl}?` }; - } - return { success: false, error: errorMessage }; - } - }, { apiUrl: TEST_CONFIG.API_URL, email: testUser.email, password: testUser.password }); - - if (!loginResult.success) { - const errorMsg = loginResult.error || 'Unknown error'; - console.warn(`⚠️ [GLOBAL SETUP] API login failed: ${errorMsg}`); - console.warn(`⚠️ [GLOBAL SETUP] Make sure Backend API is running at ${TEST_CONFIG.API_URL} and test user exists: ${testUser.email}`); - // Write empty storage state so Playwright can start; specs that need auth use their own login or storageState override - const storageStatePath = config.projects[0]?.use?.storageState as string || 'e2e/.auth/user.json'; - fs.mkdirSync(path.dirname(storageStatePath), { recursive: true }); - await context.storageState({ path: storageStatePath }); - console.warn(`⚠️ [GLOBAL SETUP] Saved empty auth state to ${storageStatePath}. Tests requiring API will fail until backend is running.`); - await browser.close(); - return; - } - - console.log('✅ [GLOBAL SETUP] API login successful!'); - console.log(`✅ [GLOBAL SETUP] Access token: ${loginResult.accessToken?.substring(0, 20)}...`); - - // Verify tokens are stored - const storedToken = await page.evaluate(() => localStorage.getItem('veza_access_token')); - if (!storedToken) { - throw new Error('Token not stored in localStorage'); - } - - // Save the authenticated state - const storageStatePath = config.projects[0]?.use?.storageState as string || 'e2e/.auth/user.json'; - console.log(`💾 [GLOBAL SETUP] Saving authenticated state to: ${storageStatePath}`); - await context.storageState({ path: storageStatePath }); - - console.log('✅ [GLOBAL SETUP] Global setup completed successfully!'); - } catch (error) { - console.error('❌ [GLOBAL SETUP] Global setup failed:', error); - throw error; - } finally { - await browser.close(); - } -} - -export default globalSetup; - - - - - - diff --git a/apps/web/e2e/mobile-responsive.spec.ts-snapshots/dashboard-iphone12-chromium-linux.png b/apps/web/e2e/mobile-responsive.spec.ts-snapshots/dashboard-iphone12-chromium-linux.png deleted file mode 100644 index 57b9fb290..000000000 Binary files a/apps/web/e2e/mobile-responsive.spec.ts-snapshots/dashboard-iphone12-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/navigation.spec.ts b/apps/web/e2e/navigation.spec.ts deleted file mode 100644 index 5a07added..000000000 --- a/apps/web/e2e/navigation.spec.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { test, expect, type Page } from '@playwright/test'; -import { - TEST_CONFIG, - loginAsUser, - setupErrorCapture, - navigateViaHref, -} from './utils/test-helpers'; - -/** - * Navigation E2E Test Suite - * - * Tests the complete navigation flow of the application: - * - Sidebar navigation - * - Route guards (protected routes) - * - Deep linking - * - Browser back/forward navigation - * - Active route highlighting - * - Mobile navigation (responsive) - */ - -test.describe('Navigation Flow', () => { - let consoleErrors: string[] = []; - let networkErrors: Array<{ url: string; status: number; method: string }> = []; - - test.beforeEach(async ({ page }) => { - const errorCapture = setupErrorCapture(page); - consoleErrors = errorCapture.consoleErrors; - networkErrors = errorCapture.networkErrors; - }); - - test.describe('Authenticated Navigation', () => { - test.beforeEach(async ({ page }) => { - await loginAsUser(page); - }); - - test('should navigate to dashboard from sidebar', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('networkidle'); - - // Click dashboard link in sidebar - const dashboardLink = page.locator('nav a[href="/dashboard"], nav a[href="/"]').first(); - await expect(dashboardLink).toBeVisible(); - await dashboardLink.click(); - - await expect(page).toHaveURL(new RegExp(`${TEST_CONFIG.FRONTEND_URL}/?(dashboard)?$`)); - }); - - test('should navigate to library from sidebar', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - const libraryLink = page.locator('nav a[href="/library"]').first(); - await expect(libraryLink).toBeVisible(); - await libraryLink.click(); - - await expect(page).toHaveURL(new RegExp(`${TEST_CONFIG.FRONTEND_URL}/library`)); - }); - - test('should navigate to playlists from sidebar', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - const playlistsLink = page.locator('nav a[href="/playlists"]').first(); - await expect(playlistsLink).toBeVisible(); - await playlistsLink.click(); - - await expect(page).toHaveURL(new RegExp(`${TEST_CONFIG.FRONTEND_URL}/playlists`)); - }); - - test('should navigate to profile from sidebar', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Profile link might be in a dropdown menu - const profileLink = page.locator('nav a[href*="/profile"], nav a[href*="/user"]').first(); - if (await profileLink.isVisible({ timeout: 2000 }).catch(() => false)) { - await profileLink.click(); - await expect(page).toHaveURL(new RegExp(`${TEST_CONFIG.FRONTEND_URL}/(profile|user)`)); - } else { - // Try clicking avatar/user menu first - const userMenu = page.locator('button[aria-label*="user"], button[aria-label*="menu"], [data-testid="user-menu"]').first(); - if (await userMenu.isVisible({ timeout: 2000 }).catch(() => false)) { - await userMenu.click(); - const profileLinkInMenu = page.locator('a[href*="/profile"], a[href*="/user"]').first(); - await expect(profileLinkInMenu).toBeVisible({ timeout: 5000 }); - await profileLinkInMenu.click(); - await expect(page).toHaveURL(new RegExp(`${TEST_CONFIG.FRONTEND_URL}/(profile|user)`)); - } - } - }); - - test('should highlight active route in sidebar', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('networkidle'); - - // Check if library link has active state - const libraryLink = page.locator('nav a[href="/library"]').first(); - const isActive = await libraryLink.evaluate((el) => { - return el.classList.contains('active') || - el.getAttribute('aria-current') === 'page' || - el.closest('[aria-current="page"]') !== null; - }); - - // Some apps use different active indicators, so we just check it's visible - await expect(libraryLink).toBeVisible(); - }); - - test('should support browser back navigation', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Navigate to library - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('networkidle'); - await expect(page).toHaveURL(new RegExp(`${TEST_CONFIG.FRONTEND_URL}/library`)); - - // Go back - await page.goBack(); - await page.waitForLoadState('networkidle'); - - // Should be back on dashboard (or previous page) - const currentUrl = page.url(); - expect(currentUrl).toMatch(new RegExp(`${TEST_CONFIG.FRONTEND_URL}/(dashboard|library)?`)); - }); - - test('should support browser forward navigation', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Navigate to library - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('networkidle'); - - // Go back - await page.goBack(); - await page.waitForLoadState('networkidle'); - - // Go forward - await page.goForward(); - await page.waitForLoadState('networkidle'); - await expect(page).toHaveURL(new RegExp(`${TEST_CONFIG.FRONTEND_URL}/library`)); - }); - - test('should support deep linking to protected routes', async ({ page }) => { - // Direct navigation to a protected route - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('networkidle'); - - // Should be able to access the route (already authenticated) - await expect(page).toHaveURL(new RegExp(`${TEST_CONFIG.FRONTEND_URL}/library`)); - - // Page should be loaded (not showing login) - const loginForm = page.locator('form[action*="login"], input[type="email"]'); - await expect(loginForm).not.toBeVisible({ timeout: 2000 }); - }); - }); - - test.describe('Unauthenticated Navigation', () => { - // Reset storage state to ensure we're not authenticated - test.use({ storageState: { cookies: [], origins: [] } }); - - test('should redirect to login when accessing protected route', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('networkidle'); - - // Should redirect to login - await expect(page).toHaveURL(new RegExp(`${TEST_CONFIG.FRONTEND_URL}/(login|auth/login)`)); - }); - - test('should allow access to public routes', async ({ page }) => { - // Try to access login page - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`); - await page.waitForLoadState('networkidle'); - - // Should be on login page - const loginForm = page.locator('form[action*="login"], input[type="email"]').first(); - await expect(loginForm).toBeVisible({ timeout: 5000 }); - }); - - test('should allow access to register page', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/register`); - await page.waitForLoadState('networkidle'); - - // Should be on register page - const registerForm = page.locator('form[action*="register"], input[name*="email"]').first(); - await expect(registerForm).toBeVisible({ timeout: 5000 }); - }); - }); - - test.describe('Mobile Navigation', () => { - test.beforeEach(async ({ page }) => { - await loginAsUser(page); - // Set mobile viewport - await page.setViewportSize({ width: 375, height: 667 }); - }); - - test('should show mobile menu when hamburger is clicked', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Look for hamburger menu button - const hamburgerButton = page.locator('button[aria-label*="menu"], button[aria-label*="navigation"], [data-testid="mobile-menu-button"]').first(); - - if (await hamburgerButton.isVisible({ timeout: 2000 }).catch(() => false)) { - await hamburgerButton.click(); - - // Menu should be visible - const mobileMenu = page.locator('nav[aria-label*="mobile"], nav[data-testid="mobile-nav"]').first(); - await expect(mobileMenu).toBeVisible({ timeout: 3000 }); - } else { - // Mobile menu might not be implemented, skip test - test.skip(); - } - }); - - test('should navigate from mobile menu', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - const hamburgerButton = page.locator('button[aria-label*="menu"], button[aria-label*="navigation"]').first(); - - if (await hamburgerButton.isVisible({ timeout: 2000 }).catch(() => false)) { - await hamburgerButton.click(); - - // Click library link in mobile menu - const libraryLink = page.locator('nav a[href="/library"]').first(); - await expect(libraryLink).toBeVisible({ timeout: 3000 }); - await libraryLink.click(); - - await expect(page).toHaveURL(new RegExp(`${TEST_CONFIG.FRONTEND_URL}/library`)); - } else { - test.skip(); - } - }); - }); - - test.describe('Error Handling', () => { - test.beforeEach(async ({ page }) => { - await loginAsUser(page); - }); - - test('should handle 404 pages gracefully', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/non-existent-page-12345`); - await page.waitForLoadState('networkidle'); - - // Should show 404 page or redirect to dashboard - const currentUrl = page.url(); - const has404Content = await page.locator('text=404, text=Not Found, text=Page not found').first().isVisible({ timeout: 2000 }).catch(() => false); - const redirectedToDashboard = currentUrl.includes('/dashboard') || currentUrl === `${TEST_CONFIG.FRONTEND_URL }/`; - - expect(has404Content || redirectedToDashboard).toBeTruthy(); - }); - - test('should handle navigation errors gracefully', async ({ page }) => { - // Intercept navigation and simulate error - await page.route('**/api/**', (route) => { - if (route.request().url().includes('/library')) { - route.abort('failed'); - } else { - route.continue(); - } - }); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Try to navigate to library (should handle error) - const libraryLink = page.locator('nav a[href="/library"]').first(); - if (await libraryLink.isVisible({ timeout: 2000 }).catch(() => false)) { - await libraryLink.click(); - - // Should show error message or stay on current page - await page.waitForTimeout(2000); - const errorToast = page.locator('text=error, text=Error, text=failed').first(); - const stillOnDashboard = page.url().includes('/dashboard'); - - // Either error is shown or we're still on dashboard - expect(await errorToast.isVisible({ timeout: 2000 }).catch(() => false) || stillOnDashboard).toBeTruthy(); - } - }); - }); -}); - diff --git a/apps/web/e2e/performance.spec.ts b/apps/web/e2e/performance.spec.ts deleted file mode 100644 index 1ee25d647..000000000 --- a/apps/web/e2e/performance.spec.ts +++ /dev/null @@ -1,669 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { TEST_CONFIG } from './utils/test-helpers'; - -/** - * Performance Tests - * - * These tests measure page load times, render performance, and Core Web Vitals. - * Performance metrics are captured using Playwright's performance API and - * browser Performance Timing API. - * - * To run only performance tests: - * - Run: npx playwright test performance - * - * Performance thresholds: - * - Page load time: < 3 seconds - * - First Contentful Paint (FCP): < 1.8 seconds - * - Largest Contentful Paint (LCP): < 2.5 seconds - * - Time to Interactive (TTI): < 3.8 seconds - * - Total Blocking Time (TBT): < 300ms - */ - -interface PerformanceMetrics { - loadTime: number; - domContentLoaded: number; - firstPaint: number; - firstContentfulPaint: number; - largestContentfulPaint: number; - timeToInteractive: number; - totalBlockingTime: number; - cumulativeLayoutShift: number; - firstInputDelay: number; - networkRequests: number; - jsHeapSizeUsed: number; -} - -/** - * Capture performance metrics from the browser - */ -async function capturePerformanceMetrics(page: any): Promise { - return await page.evaluate(() => { - const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; - const paint = performance.getEntriesByType('paint'); - const measure = performance.getEntriesByType('measure'); - - // Calculate load time - const loadTime = navigation.loadEventEnd - navigation.fetchStart; - const domContentLoaded = navigation.domContentLoadedEventEnd - navigation.fetchStart; - - // Get paint metrics - const firstPaint = paint.find((entry) => entry.name === 'first-paint')?.startTime || 0; - const firstContentfulPaint = paint.find((entry) => entry.name === 'first-contentful-paint')?.startTime || 0; - - // Get LCP (Largest Contentful Paint) - approximate using load event - const largestContentfulPaint = navigation.loadEventEnd - navigation.fetchStart; - - // Calculate TTI (Time to Interactive) - approximate - const timeToInteractive = navigation.domInteractive - navigation.fetchStart; - - // Calculate TBT (Total Blocking Time) - approximate - // This is a simplified calculation - const totalBlockingTime = Math.max(0, navigation.domInteractive - navigation.domContentLoadedEventEnd); - - // Get CLS (Cumulative Layout Shift) - requires PerformanceObserver - let cumulativeLayoutShift = 0; - if ('PerformanceObserver' in window) { - try { - const clsEntries: any[] = []; - const observer = new PerformanceObserver((list) => { - for (const entry of list.getEntries()) { - if (!(entry as any).hadRecentInput) { - clsEntries.push(entry); - } - } - }); - observer.observe({ type: 'layout-shift', buffered: true }); - cumulativeLayoutShift = clsEntries.reduce((sum, entry: any) => sum + entry.value, 0); - } catch (e) { - // CLS not supported - } - } - - // Get FID (First Input Delay) - approximate - const firstInputDelay = 0; // Would need PerformanceObserver for real measurement - - // Count network requests - const networkRequests = performance.getEntriesByType('resource').length; - - // Get memory usage (if available) - const memory = (performance as any).memory; - const jsHeapSizeUsed = memory ? memory.usedJSHeapSize : 0; - - return { - loadTime, - domContentLoaded, - firstPaint, - firstContentfulPaint, - largestContentfulPaint, - timeToInteractive, - totalBlockingTime, - cumulativeLayoutShift, - firstInputDelay, - networkRequests, - jsHeapSizeUsed, - }; - }); -} - -/** - * Wait for page to be fully loaded and stable - */ -async function waitForPageStable(page: any, timeout = 10000) { - await page.waitForLoadState('networkidle', { timeout }); - await page.waitForLoadState('domcontentloaded'); - // Wait a bit more for any async operations - await page.waitForTimeout(1000); -} - -test.describe('Performance Tests', () => { - // Use authenticated state for most tests - test.use({ storageState: 'e2e/.auth/user.json' }); - - test.describe('Page Load Performance', () => { - test('dashboard page load time should be acceptable', async ({ page }) => { - const startTime = Date.now(); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await waitForPageStable(page); - - const endTime = Date.now(); - const loadTime = endTime - startTime; - - const metrics = await capturePerformanceMetrics(page); - - // Log metrics for debugging - console.log('Dashboard Performance Metrics:', { - loadTime: `${loadTime}ms`, - domContentLoaded: `${metrics.domContentLoaded.toFixed(2)}ms`, - firstContentfulPaint: `${metrics.firstContentfulPaint.toFixed(2)}ms`, - largestContentfulPaint: `${metrics.largestContentfulPaint.toFixed(2)}ms`, - timeToInteractive: `${metrics.timeToInteractive.toFixed(2)}ms`, - networkRequests: metrics.networkRequests, - }); - - // Assertions - thresholds based on Core Web Vitals - expect(loadTime).toBeLessThan(5000); // 5 seconds max - expect(metrics.domContentLoaded).toBeLessThan(3000); // 3 seconds - expect(metrics.firstContentfulPaint).toBeLessThan(1800); // 1.8 seconds (Good FCP) - expect(metrics.largestContentfulPaint).toBeLessThan(2500); // 2.5 seconds (Good LCP) - }); - - test('login page load time should be fast', async ({ page }) => { - // Use unauthenticated state for login page - await page.context().clearCookies(); - - const startTime = Date.now(); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`); - await waitForPageStable(page); - - const endTime = Date.now(); - const loadTime = endTime - startTime; - - const metrics = await capturePerformanceMetrics(page); - - console.log('Login Page Performance Metrics:', { - loadTime: `${loadTime}ms`, - firstContentfulPaint: `${metrics.firstContentfulPaint.toFixed(2)}ms`, - networkRequests: metrics.networkRequests, - }); - - // Login page should be very fast (no data loading) - expect(loadTime).toBeLessThan(2000); // 2 seconds max - expect(metrics.firstContentfulPaint).toBeLessThan(1000); // 1 second - }); - - test('profile page load time should be acceptable', async ({ page }) => { - const startTime = Date.now(); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/profile`); - await waitForPageStable(page); - - const endTime = Date.now(); - const loadTime = endTime - startTime; - - const metrics = await capturePerformanceMetrics(page); - - expect(loadTime).toBeLessThan(5000); - expect(metrics.firstContentfulPaint).toBeLessThan(1800); - }); - - test('tracks page load time should be acceptable', async ({ page }) => { - const startTime = Date.now(); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/tracks`); - await waitForPageStable(page); - - const endTime = Date.now(); - const loadTime = endTime - startTime; - - const metrics = await capturePerformanceMetrics(page); - - expect(loadTime).toBeLessThan(5000); - expect(metrics.firstContentfulPaint).toBeLessThan(1800); - }); - - test('playlists page load time should be acceptable', async ({ page }) => { - const startTime = Date.now(); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/playlists`); - await waitForPageStable(page); - - const endTime = Date.now(); - const loadTime = endTime - startTime; - - const metrics = await capturePerformanceMetrics(page); - - expect(loadTime).toBeLessThan(5000); - expect(metrics.firstContentfulPaint).toBeLessThan(1800); - }); - }); - - test.describe('Render Performance', () => { - test('dashboard should render main content quickly', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - - // Measure time to render main content - const renderStart = Date.now(); - await page.waitForSelector('main, [role="main"]', { timeout: 10000 }); - const renderEnd = Date.now(); - const renderTime = renderEnd - renderStart; - - console.log(`Dashboard main content render time: ${renderTime}ms`); - - expect(renderTime).toBeLessThan(2000); // Should render in under 2 seconds - }); - - test('navigation should be responsive', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await waitForPageStable(page); - - // Measure navigation time - const navStart = Date.now(); - await page.click('a[href="/profile"]', { timeout: 5000 }); - await page.waitForURL('**/profile', { timeout: 5000 }); - await waitForPageStable(page); - const navEnd = Date.now(); - const navTime = navEnd - navStart; - - console.log(`Navigation time (dashboard -> profile): ${navTime}ms`); - - expect(navTime).toBeLessThan(3000); // Navigation should be fast - }); - }); - - test.describe('Network Performance', () => { - test('should minimize network requests on initial load', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await waitForPageStable(page); - - const metrics = await capturePerformanceMetrics(page); - - console.log(`Total network requests: ${metrics.networkRequests}`); - - // Should not have excessive network requests - // This threshold may need adjustment based on actual usage - expect(metrics.networkRequests).toBeLessThan(50); - }); - - test('API requests should complete quickly', async ({ page }) => { - const requestTimes: number[] = []; - - // Track API request times - page.on('response', (response: any) => { - const url = response.url(); - if (url.includes('/api/')) { - const timing = response.timing(); - if (timing) { - const requestTime = timing.responseEnd - timing.requestStart; - requestTimes.push(requestTime); - } - } - }); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await waitForPageStable(page); - - if (requestTimes.length > 0) { - const avgRequestTime = requestTimes.reduce((a, b) => a + b, 0) / requestTimes.length; - const maxRequestTime = Math.max(...requestTimes); - - console.log(`Average API request time: ${avgRequestTime.toFixed(2)}ms`); - console.log(`Max API request time: ${maxRequestTime.toFixed(2)}ms`); - - // API requests should complete reasonably quickly - expect(avgRequestTime).toBeLessThan(1000); // Average under 1 second - expect(maxRequestTime).toBeLessThan(3000); // Max under 3 seconds - } - }); - }); - - test.describe('Memory Performance', () => { - test('should not have excessive memory usage', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await waitForPageStable(page); - - const metrics = await capturePerformanceMetrics(page); - - if (metrics.jsHeapSizeUsed > 0) { - const heapSizeMB = metrics.jsHeapSizeUsed / (1024 * 1024); - console.log(`JS Heap Size Used: ${heapSizeMB.toFixed(2)}MB`); - - // Should not use excessive memory (threshold: 100MB) - expect(heapSizeMB).toBeLessThan(100); - } - }); - }); - - test.describe('Large Dataset Performance', () => { - test('should render large track lists (1000+ tracks) smoothly', async ({ page }) => { - // Mock a large track list with 1000+ tracks - const largeTrackList = Array.from({ length: 1200 }, (_, i) => ({ - id: `track-${i + 1}`, - title: `Track ${i + 1}`, - artist: `Artist ${Math.floor(i / 10) + 1}`, - duration: 180 + (i % 60), // Varying durations - file_path: `/tracks/track-${i + 1}.mp3`, - file_size: 5000000 + (i * 1000), - format: 'mp3', - is_public: true, - play_count: Math.floor(Math.random() * 1000), - like_count: Math.floor(Math.random() * 100), - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - creator_id: 'test-user', - status: 'ready' as const, - })); - - // Intercept tracks API call and return mocked data - await page.route('**/api/v1/tracks**', async (route) => { - if (route.request().method() === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - success: true, - data: largeTrackList, - total: largeTrackList.length, - page: 1, - limit: largeTrackList.length, - }), - }); - } else { - await route.continue(); - } - }); - - // Navigate to library page - const renderStart = Date.now(); - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - - // Wait for library content to be visible - await page.waitForSelector('[data-testid="library-page"], .library-page, main', { timeout: 10000 }); - - // Wait for tracks to be rendered (check for virtualized list or track items) - await page.waitForSelector( - '[data-testid="track-list"], .track-list, [role="list"], table, [role="table"], [data-testid="virtualized-list"]', - { timeout: 10000 } - ).catch(() => { - // If specific selector not found, wait for any content - console.warn('⚠️ [PERF] Specific track list selector not found, waiting for general content'); - }); - - const renderEnd = Date.now(); - const renderTime = renderEnd - renderStart; - - // Measure performance metrics - const metrics = await capturePerformanceMetrics(page); - - // Count rendered track items (virtualization may only render visible items) - const trackCount = await page.evaluate(() => { - const selectors = [ - '[data-testid*="track"]', - '[data-track-id]', - '[role="listitem"]', - 'tr[data-track-id]', - '.track-item', - 'li', - ]; - let count = 0; - for (const selector of selectors) { - const elements = document.querySelectorAll(selector); - if (elements.length > 0) { - count = elements.length; - break; - } - } - return count; - }); - - // Check if virtualization is working (should render fewer items than total) - const isVirtualized = trackCount < largeTrackList.length; - - console.log('Large Track List Performance Metrics:', { - renderTime: `${renderTime}ms`, - totalTracks: `${largeTrackList.length} tracks`, - renderedTracks: `${trackCount} tracks rendered`, - isVirtualized: isVirtualized ? 'Yes' : 'No', - domContentLoaded: `${metrics.domContentLoaded.toFixed(2)}ms`, - firstContentfulPaint: `${metrics.firstContentfulPaint.toFixed(2)}ms`, - largestContentfulPaint: `${metrics.largestContentfulPaint.toFixed(2)}ms`, - timeToInteractive: `${metrics.timeToInteractive.toFixed(2)}ms`, - networkRequests: metrics.networkRequests, - }); - - // Verify performance thresholds - // Large track lists should render in reasonable time (8 seconds max for 1000+ tracks) - expect(renderTime).toBeLessThan(8000); - - // Verify that tracks are being rendered (at least some tracks should be visible) - expect(trackCount).toBeGreaterThan(0); - - // Verify smooth rendering - LCP should be acceptable for large lists - expect(metrics.largestContentfulPaint).toBeLessThan(4000); // 4 seconds for very large lists - - // Verify virtualization is working (should not render all 1000+ tracks at once) - if (isVirtualized) { - console.log('✅ [PERF] Virtualization detected - only visible tracks rendered'); - } else { - console.warn('⚠️ [PERF] Virtualization may not be working - all tracks may be rendered'); - } - - console.log('✅ [PERF] Large track list rendered smoothly'); - }); - - test('should render large playlists (100+ tracks) smoothly', async ({ page }) => { - // Mock a playlist with 100+ tracks - const largePlaylist = { - id: 'test-large-playlist', - name: 'Large Playlist Test', - description: 'Performance test with 100+ tracks', - tracks: Array.from({ length: 120 }, (_, i) => ({ - id: `track-${i + 1}`, - title: `Track ${i + 1}`, - artist: `Artist ${i + 1}`, - duration: 180 + (i % 60), // Varying durations - file_path: `/tracks/track-${i + 1}.mp3`, - file_size: 5000000 + (i * 1000), - format: 'mp3', - is_public: true, - play_count: Math.floor(Math.random() * 1000), - like_count: Math.floor(Math.random() * 100), - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - creator_id: 'test-user', - })), - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - creator_id: 'test-user', - }; - - // Intercept playlist API call and return mocked data - await page.route('**/api/v1/playlists/**', async (route) => { - if (route.request().method() === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - success: true, - data: largePlaylist, - }), - }); - } else { - await route.continue(); - } - }); - - // Navigate to playlist page - const renderStart = Date.now(); - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/playlists/${largePlaylist.id}`); - - // Wait for playlist content to be visible - await page.waitForSelector('[data-testid="playlist-detail"], .playlist-detail, main', { timeout: 10000 }); - - // Wait for tracks to be rendered (check for track list or items) - await page.waitForSelector( - '[data-testid="playlist-tracks"], .playlist-tracks, [role="list"], table, [role="table"]', - { timeout: 10000 } - ).catch(() => { - // If specific selector not found, wait for any content - console.warn('⚠️ [PERF] Specific track list selector not found, waiting for general content'); - }); - - const renderEnd = Date.now(); - const renderTime = renderEnd - renderStart; - - // Measure performance metrics - const metrics = await capturePerformanceMetrics(page); - - // Count rendered track items - const trackCount = await page.evaluate(() => { - const selectors = [ - '[data-testid*="track"]', - '[role="listitem"]', - 'tr[data-track-id]', - '.track-item', - 'li', - ]; - let count = 0; - for (const selector of selectors) { - const elements = document.querySelectorAll(selector); - if (elements.length > 0) { - count = elements.length; - break; - } - } - return count; - }); - - console.log('Large Playlist Performance Metrics:', { - renderTime: `${renderTime}ms`, - trackCount: `${trackCount} tracks rendered`, - domContentLoaded: `${metrics.domContentLoaded.toFixed(2)}ms`, - firstContentfulPaint: `${metrics.firstContentfulPaint.toFixed(2)}ms`, - largestContentfulPaint: `${metrics.largestContentfulPaint.toFixed(2)}ms`, - timeToInteractive: `${metrics.timeToInteractive.toFixed(2)}ms`, - networkRequests: metrics.networkRequests, - }); - - // Verify performance thresholds - // Large playlists should render in reasonable time (5 seconds max for 100+ tracks) - expect(renderTime).toBeLessThan(5000); - - // Verify that tracks are being rendered (at least some tracks should be visible) - // Note: Virtualization might only render visible tracks, so we check for > 0 - expect(trackCount).toBeGreaterThan(0); - - // Verify smooth rendering - LCP should be acceptable - expect(metrics.largestContentfulPaint).toBeLessThan(3000); // 3 seconds for large lists - - console.log('✅ [PERF] Large playlist rendered smoothly'); - }); - - test('should render many conversations (100+) smoothly', async ({ page }) => { - // Mock a large conversation list with 100+ conversations - const largeConversationList = Array.from({ length: 120 }, (_, i) => ({ - id: `conversation-${i + 1}`, - name: `Conversation ${i + 1}`, - type: i % 3 === 0 ? 'direct' : 'channel', - participants: i % 3 === 0 ? [`user-${i}`, `user-${i + 1}`] : [], - unread_count: i % 5 === 0 ? Math.floor(Math.random() * 10) : 0, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - })); - - // Intercept conversations API call and return mocked data - await page.route('**/api/v1/conversations**', async (route) => { - if (route.request().method() === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - success: true, - conversations: largeConversationList, - }), - }); - } else { - await route.continue(); - } - }); - - // Navigate to chat page - const renderStart = Date.now(); - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/chat`); - - // Wait for chat content to be visible - await page.waitForSelector('[data-testid="chat-page"], .chat-page, main, [data-testid="chat-sidebar"]', { timeout: 10000 }); - - // Wait for conversations to be rendered (check for conversation list or items) - await page.waitForSelector( - '[data-testid="conversation-list"], .conversation-list, [role="list"], [data-testid*="conversation"]', - { timeout: 10000 } - ).catch(() => { - // If specific selector not found, wait for any content - console.warn('⚠️ [PERF] Specific conversation list selector not found, waiting for general content'); - }); - - const renderEnd = Date.now(); - const renderTime = renderEnd - renderStart; - - // Measure performance metrics - const metrics = await capturePerformanceMetrics(page); - - // Count rendered conversation items - const conversationCount = await page.evaluate(() => { - const selectors = [ - '[data-testid*="conversation"]', - '[data-conversation-id]', - '[role="listitem"]', - '.conversation-item', - 'li', - ]; - let count = 0; - for (const selector of selectors) { - const elements = document.querySelectorAll(selector); - if (elements.length > 0) { - count = elements.length; - break; - } - } - return count; - }); - - console.log('Many Conversations Performance Metrics:', { - renderTime: `${renderTime}ms`, - totalConversations: `${largeConversationList.length} conversations`, - renderedConversations: `${conversationCount} conversations rendered`, - domContentLoaded: `${metrics.domContentLoaded.toFixed(2)}ms`, - firstContentfulPaint: `${metrics.firstContentfulPaint.toFixed(2)}ms`, - largestContentfulPaint: `${metrics.largestContentfulPaint.toFixed(2)}ms`, - timeToInteractive: `${metrics.timeToInteractive.toFixed(2)}ms`, - networkRequests: metrics.networkRequests, - }); - - // Verify performance thresholds - // Many conversations should render in reasonable time (5 seconds max for 100+ conversations) - expect(renderTime).toBeLessThan(5000); - - // Verify that conversations are being rendered (at least some conversations should be visible) - expect(conversationCount).toBeGreaterThan(0); - - // Verify smooth rendering - LCP should be acceptable - expect(metrics.largestContentfulPaint).toBeLessThan(3000); // 3 seconds for large lists - - console.log('✅ [PERF] Many conversations rendered smoothly'); - }); - }); - - test.describe('Core Web Vitals', () => { - test('should meet Core Web Vitals thresholds', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await waitForPageStable(page); - - const metrics = await capturePerformanceMetrics(page); - - // Core Web Vitals thresholds (Good) - const coreWebVitals = { - LCP: metrics.largestContentfulPaint, // Should be < 2.5s - FID: metrics.firstInputDelay, // Should be < 100ms (not measured here) - CLS: metrics.cumulativeLayoutShift, // Should be < 0.1 - FCP: metrics.firstContentfulPaint, // Should be < 1.8s - TBT: metrics.totalBlockingTime, // Should be < 300ms - }; - - console.log('Core Web Vitals:', { - LCP: `${coreWebVitals.LCP.toFixed(2)}ms (target: < 2500ms)`, - FCP: `${coreWebVitals.FCP.toFixed(2)}ms (target: < 1800ms)`, - TBT: `${coreWebVitals.TBT.toFixed(2)}ms (target: < 300ms)`, - CLS: `${coreWebVitals.CLS.toFixed(4)} (target: < 0.1)`, - }); - - // Assert Core Web Vitals thresholds - expect(coreWebVitals.LCP).toBeLessThan(2500); - expect(coreWebVitals.FCP).toBeLessThan(1800); - expect(coreWebVitals.TBT).toBeLessThan(300); - expect(coreWebVitals.CLS).toBeLessThan(0.1); - }); - }); -}); - diff --git a/apps/web/e2e/playwright-report-visual/index.html b/apps/web/e2e/playwright-report-visual/index.html deleted file mode 100644 index 051c896e5..000000000 --- a/apps/web/e2e/playwright-report-visual/index.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - Playwright Test Report - - - - -
- - - \ No newline at end of file diff --git a/apps/web/e2e/setup-test-user-role.sh b/apps/web/e2e/setup-test-user-role.sh deleted file mode 100755 index e85bdb445..000000000 --- a/apps/web/e2e/setup-test-user-role.sh +++ /dev/null @@ -1,119 +0,0 @@ -#!/bin/bash -# Script pour promouvoir l'utilisateur de test en "artist" -# Usage: ./setup-test-user-role.sh - -set -e - -# Configuration par défaut (peut être surchargée par variables d'environnement) -# Valeurs par défaut basées sur docker-compose.yml du projet -DB_HOST="${DB_HOST:-localhost}" -DB_PORT="${DB_PORT:-5432}" -DB_NAME="${DB_NAME:-veza}" -DB_USER="${DB_USER:-veza}" -DB_PASSWORD="${DB_PASSWORD:-password}" -TEST_USER_EMAIL="${TEST_USER_EMAIL:-user@example.com}" -POSTGRES_CONTAINER="${POSTGRES_CONTAINER:-veza_postgres}" - -echo "🔧 [SETUP] Promoting test user to 'artist' role..." -echo " User: $TEST_USER_EMAIL" -echo " Database: $DB_NAME@$DB_HOST:$DB_PORT" - -# Option 1: Utiliser psql directement -if command -v psql &> /dev/null; then - echo "📝 [SETUP] Using psql..." - PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" < /dev/null; then - echo "🐳 [SETUP] Using Docker exec..." - - # Chercher le conteneur PostgreSQL - if docker ps --format "{{.Names}}" | grep -q "^${POSTGRES_CONTAINER}$"; then - CONTAINER_NAME="$POSTGRES_CONTAINER" - elif docker ps --format "{{.Names}}" | grep -qi postgres; then - CONTAINER_NAME=$(docker ps --format "{{.Names}}" | grep -i postgres | head -n 1) - else - echo "❌ [SETUP] No PostgreSQL container found" - echo " Tried: $POSTGRES_CONTAINER" - echo " Available containers:" - docker ps --format "{{.Names}}" || echo " (none running)" - exit 1 - fi - - echo " Using container: $CONTAINER_NAME" - - docker exec -i "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" < { - // Reset storage state for these tests to ensure we start unauthenticated - test.use({ storageState: { cookies: [], origins: [] } }); - - let consoleErrors: string[] = []; - let networkErrors: Array<{ url: string; status: number; method: string }> = []; - - test.beforeEach(async ({ page }) => { - const errorCapture = setupErrorCapture(page); - consoleErrors = errorCapture.consoleErrors; - networkErrors = errorCapture.networkErrors; - }); - - /** - * TEST 1: Login avec credentials valides - */ - test('should login successfully with valid credentials', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`); - await page.waitForLoadState('domcontentloaded'); - - // Attendre que le formulaire soit prêt (premier test peut être plus lent) - await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => { }); - await expect(page.locator('form')).toBeVisible({ timeout: 10000 }); - await expect(page.locator('input[type="email"], input[name="email"]').first()).toBeVisible({ timeout: 5000 }); - await page.waitForTimeout(500); - - // Remplir le formulaire - await fillField( - page, - 'input[type="email"], input[name="email"]', - TEST_USERS.default.email - ); - await fillField(page, 'input[type="password"], input[name="password"]', TEST_USERS.default.password); - - // Soumettre le formulaire - const navigationPromise = page.waitForURL( - (url) => url.pathname === '/dashboard' || url.pathname === '/', - { timeout: 15000 } - ); - - await forceSubmitForm(page, 'form'); - await navigationPromise; - - // Vérifier que l'utilisateur est redirigé et authentifié - await expect(page).toHaveURL(/\/(dashboard|$)/); - await expect(page.locator('nav[role="navigation"], aside[role="navigation"]')).toBeVisible({ - timeout: 10000, - }); - - // Wait for Zustand to persist auth-storage (async) - await page.waitForTimeout(1000); - - // Vérifier l'état d'authentification (accepte les tokens en mémoire) - const token = await getAuthToken(page); - expect(token).toBeTruthy(); // Peut être un token réel ou "memory-token" - - // Vérifier aussi que isAuthenticated est true dans le storage - const isAuthenticated = await page.evaluate(() => { - try { - const authStorage = localStorage.getItem('auth-storage'); - if (authStorage) { - const parsed = JSON.parse(authStorage); - return parsed.state?.isAuthenticated === true; - } - } catch { - return false; - } - return false; - }); - expect(isAuthenticated).toBe(true); - - }); - - /** - * TEST 2: Login avec credentials invalides - */ - test('should show error with invalid credentials', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`); - await page.waitForLoadState('domcontentloaded'); - await expect(page.locator('form')).toBeVisible({ timeout: 10000 }); - - // Remplir avec des credentials invalides - await fillField(page, 'input[type="email"], input[name="email"]', 'wrong@example.com'); - await fillField(page, 'input[type="password"], input[name="password"]', 'wrongpassword'); - - // Soumettre le formulaire - await forceSubmitForm(page, 'form'); - - // Attendre le message d'erreur - // Verify error message (handles both invalid credentials and locked account) - const errorMessage = await waitForToast(page, 'error', 10000); - expect(errorMessage.toLowerCase()).toMatch(/invalid|locked/); - - // Vérifier que l'utilisateur reste sur /login - await expect(page).toHaveURL(/\/login/); - }); - - /** - * TEST 2b: Login with 2FA — runs when E2E_2FA_CODE is set (optionally E2E_2FA_EMAIL, E2E_2FA_PASSWORD). - * Requires a test account with 2FA enabled; code must be valid at run time. - */ - test('should complete login with 2FA code', async ({ page }) => { - test.skip(!process.env.E2E_2FA_CODE, 'Set E2E_2FA_CODE (and optionally E2E_2FA_EMAIL, E2E_2FA_PASSWORD) to run'); - const email = process.env.E2E_2FA_EMAIL || TEST_USERS.default.email; - const password = process.env.E2E_2FA_PASSWORD || process.env.TEST_PASSWORD || TEST_USERS.default.password; - const code = process.env.E2E_2FA_CODE!; - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`); - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); - await page.waitForTimeout(500); - - await fillField(page, 'input[type="email"], input[name="email"]', email); - await fillField(page, 'input[type="password"], input[name="password"]', password); - await forceSubmitForm(page, 'form'); - - await page.waitForTimeout(2000); - const twoFaInput = page.locator('input#2fa-code, input[placeholder="000000"]').first(); - await expect(twoFaInput).toBeVisible({ timeout: 10000 }); - await twoFaInput.fill(code); - const verifyButton = page.locator('button:has-text("Verify")').first(); - await expect(verifyButton).toBeVisible({ timeout: 5000 }); - await verifyButton.click(); - - await expect(page).toHaveURL(/\/(dashboard|$)/, { timeout: 15000 }); - const token = await getAuthToken(page); - expect(token).toBeTruthy(); - }); - - /** - * TEST 3: Registration (Inscription) - */ - test('should register a new user successfully', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/register`); - await page.waitForLoadState('domcontentloaded'); - - // Attendre que la page soit complètement chargée - await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); - await expect(page.locator('form')).toBeVisible({ timeout: 10000 }); - await expect(page.locator('input[name="email"], input#email').first()).toBeVisible({ timeout: 5000 }); - - // Générer un email unique pour éviter les conflits - const uniqueEmail = `test-${Date.now()}@example.com`; - const username = `testuser${Date.now()}`; - const password = 'Str0ng!P@ssw0rd2024'; // 12+ caractères requis, fort - - // Remplir le formulaire d'inscription (4 champs: email, username, password, password_confirm) - await fillField(page, 'input[name="email"], input#email', uniqueEmail); - await page.waitForTimeout(200); // Laisser React Hook Form traiter - - await fillField(page, 'input[name="username"], input#username', username); - await page.waitForTimeout(200); // Laisser React Hook Form traiter - - await fillField(page, 'input[name="password"], input#password', password); - await page.waitForTimeout(200); // Laisser React Hook Form traiter - - // Sélecteur flexible pour couvrir toutes les variantes de nommage - // T0188: Use data-testid for robust selection as passwordConfirm was not found earlier - const confirmInput = page.getByTestId('password-confirm-input'); - if (await confirmInput.isVisible()) { - await confirmInput.fill(password); - } else { - // Fallback to name/id selectors if testid not found - await fillField(page, 'input[name="passwordConfirm"], input[name="password_confirm"], input[name="confirmPassword"], input#passwordConfirm', password); - } - - // CRITIQUE: Attendre que React Hook Form mette à jour son état - // Sans cela, le backend peut recevoir un objet incomplet - await page.waitForTimeout(500); - - // Soumettre le formulaire - await forceSubmitForm(page, 'form'); - - // ⚠️ FLEXIBLE: Wait for EITHER navigation OR auth state change - // Some implementations navigate, some just update state - const navigationSuccess = await Promise.race([ - page.waitForURL((url) => url.pathname === '/dashboard' || url.pathname === '/login', { - timeout: 10000, - }).then(() => true).catch(() => false), - page.waitForTimeout(10000).then(() => false), - ]); - - if (navigationSuccess) { - // Navigation occurred - check URL - const currentUrl = page.url(); - if (currentUrl.includes('dashboard') || !currentUrl.includes('login')) { - await expect(page.locator('nav[role="navigation"], aside[role="navigation"]')).toBeVisible({ - timeout: 10000, - }); - } else { - // Redirected to login after registration - } - } else { - // No navigation - check if auth state was updated - const isAuthenticated = await page.evaluate(() => { - try { - const authStorage = localStorage.getItem('auth-storage'); - if (authStorage) { - const parsed = JSON.parse(authStorage); - return parsed.state?.isAuthenticated === true; - } - } catch { - return false; - } - return false; - }); - - if (isAuthenticated) { - expect(isAuthenticated).toBe(true); - } else { - // Check if we at least left the register page - const currentUrl = page.url(); - const stillOnRegister = currentUrl.includes('/register'); - if (!stillOnRegister) { - expect(stillOnRegister).toBe(false); - } else { - // Still on register, check for success message - const successMessage = await page - .locator('text=/success|registered|created|account created/i, [role="status"]') - .isVisible({ timeout: 3000 }) - .catch(() => false); - expect(successMessage).toBe(true); - } - } - } - }); - - /** - * TEST 4: Registration avec email déjà utilisé - */ - test('should show error when registering with existing email', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/register`); - await page.waitForLoadState('domcontentloaded'); - - // Attendre que la page soit complètement chargée - await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); - await expect(page.locator('form')).toBeVisible({ timeout: 10000 }); - - // Utiliser un email qui existe déjà (celui du test user) - const password = 'Str0ng!P@ssw0rd2024'; // 12+ caractères requis, fort - const username = 'existinguser'; - - await fillField(page, 'input[name="email"], input#email', TEST_USERS.default.email); - await page.waitForTimeout(200); - - await fillField(page, 'input[name="username"], input#username', username); - await page.waitForTimeout(200); - - await fillField(page, 'input[name="password"], input#password', password); - await page.waitForTimeout(200); - - // Sélecteur flexible pour couvrir toutes les variantes de nommage (data-testid prioritaire) - const confirmInputExisting = page.getByTestId('password-confirm-input'); - if (await confirmInputExisting.isVisible()) { - await confirmInputExisting.fill(password); - } else { - await fillField(page, 'input[name="passwordConfirm"], input[name="password_confirm"], input[name="confirmPassword"], input#passwordConfirm', password); - } - - // CRITIQUE: Attendre que React Hook Form mette à jour l'état - // Sans cela, le backend reçoit "password_confirm is required" - await page.waitForTimeout(800); - - // Soumettre le formulaire - await forceSubmitForm(page, 'form'); - - // Attendre le message d'erreur (timeout plus long car le backend doit répondre) - await page.waitForTimeout(2000); - - // 🔴 FLEXIBLE: Wait for ANY error alert (more flexible than specific text) - // Accept any visible error indicator since backend may return 500 or different error formats - const errorMessage = page.locator('.text-red-500, [role="alert"], .text-destructive, .text-red-700, .bg-red-100').first(); - const isErrorVisible = await errorMessage.isVisible({ timeout: 10000 }).catch(() => false); - - if (isErrorVisible) { - const errorText = await errorMessage.textContent(); - expect(errorText?.toLowerCase()).toMatch(/(exist|already|déjà|utilisé|taken|failed|erreur|error)/); - } else { - await expect(page).toHaveURL(/\/register/); - } - }); - - /** - * TEST 5: Logout - */ - test('should logout successfully', async ({ page }) => { - // D'abord se connecter - await loginAsUser(page); - - // Attendre que le sidebar soit visible - await expect(page.locator('nav[role="navigation"], aside[role="navigation"]')).toBeVisible({ - timeout: 10000, - }); - - const tokenBeforeLogout = await getAuthToken(page); - expect(tokenBeforeLogout).toBeTruthy(); - - // Trouver le bouton de logout (peut être dans un menu utilisateur) - // Chercher plusieurs variantes - let logoutButton = page - .locator('button:has-text("Déconnexion"), button:has-text("Logout"), button:has-text("Se déconnecter"), button:has-text("Sign Out")') - .first(); - - // Si pas visible directement, chercher dans un menu dropdown (Avatar > Logout) - const isLogoutVisible = await logoutButton.isVisible().catch(() => false); - - if (!isLogoutVisible) { - // Ouvrir le menu utilisateur (Avatar, Profile button, etc.) - const userMenu = page - .locator('[data-testid="user-menu"], button[aria-label*="user" i], button[aria-label*="profile" i]') - .first(); - - const isUserMenuVisible = await userMenu.isVisible().catch(() => false); - - if (isUserMenuVisible) { - await expect(userMenu).toBeVisible({ timeout: 5000 }); - await userMenu.click(); - await page.waitForTimeout(500); // Attendre que le menu s'ouvre - - // Maintenant chercher le logout dans le menu - logoutButton = page - .locator('[role="menuitem"]:has-text("Déconnexion"), [role="menuitem"]:has-text("Logout"), [role="menuitem"]:has-text("Sign Out")') - .first(); - } - } - - // Vérifier que le bouton de logout est maintenant visible - await expect(logoutButton).toBeVisible({ timeout: 5000 }); - - // 🔴 CRITIQUE: Attendre que la page soit complètement chargée avant logout - // Cela évite les erreurs 400 si le header Authorization n'est pas encore prêt - await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}); - - // Attendre un peu plus pour que Axios/API client soit complètement initialisé - await page.waitForTimeout(1000); - - // Attendre la redirection vers /login après logout - const navigationPromise = page.waitForURL(/\/login/, { timeout: 10000 }); - - await logoutButton.click(); - await navigationPromise; - - // Vérifier que l'utilisateur est redirigé vers /login - await expect(page).toHaveURL(/\/login/); - - const token = await getAuthToken(page); - expect(token).toBeNull(); - }); - - /** - * TEST 6: Route Guard - Redirection vers /login si non authentifié - */ - test('should redirect to login when accessing protected route without auth', async ({ page }) => { - // S'assurer qu'il n'y a pas de token dans le localStorage - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`); - await page.evaluate(() => localStorage.clear()); - - // Tenter d'accéder à une route protégée - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - - // Attendre la redirection vers /login - await page.waitForURL(/\/login/, { timeout: 10000 }); - - await expect(page).toHaveURL(/\/login/); - }); - - /** - * TEST 7: Persistance de l'authentification après refresh - */ - test('should persist authentication after page refresh', async ({ page }) => { - test.setTimeout(90000); // CI can be slow; allow extra time for login + refresh - // Wait before login to avoid rate limiting (429) - // Les tests précédents ont pu consommer le quota de login - await page.waitForTimeout(10000); - - // Login successfully - await loginAsUser(page); - - // Verify authenticated before refresh - const beforeRefresh = await page.evaluate(() => { - try { - const authStorage = localStorage.getItem('auth-storage'); - if (authStorage) { - const parsed = JSON.parse(authStorage); - return parsed.state?.isAuthenticated === true; - } - } catch { - return false; - } - return false; - }); - expect(beforeRefresh).toBe(true); - - // Refresh page - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}); - await page.waitForTimeout(2000); // Wait for app to check auth status - - // Verify nav/sidebar visible (confirms authenticated UI) - await expect(page.locator('nav[role="navigation"], aside[role="navigation"]')).toBeVisible({ timeout: 10000 }); - - // Check if still authenticated - const afterRefresh = await page.evaluate(() => { - try { - const authStorage = localStorage.getItem('auth-storage'); - if (authStorage) { - const parsed = JSON.parse(authStorage); - return parsed.state?.isAuthenticated === true; - } - } catch { - return false; - } - return false; - }); - - // Check if token exists in localStorage (using helper) - const token = await getAuthToken(page); - - expect(afterRefresh).toBe(true); - expect(token).toBeTruthy(); - }); - - /** - * TEST 8: Validation du formulaire de login - */ - test('should validate login form fields', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`); - await page.waitForLoadState('domcontentloaded'); - - // Wait for form to be ready - await page.waitForSelector('form', { state: 'visible', timeout: 10000 }); - - const initialUrl = page.url(); - - // Fill with INVALID data to trigger validation - const emailInput = page.locator('input[type="email"], input[name="email"]').first(); - await emailInput.fill('not-an-email'); // Invalid email - await page.waitForTimeout(200); - - // Try submitting the form with invalid data - const submitButton = page.locator('button[type="submit"]').first(); - await expect(submitButton).toBeVisible({ timeout: 5000 }); - await submitButton.click(); - await page.waitForTimeout(2000); // Wait to see if navigation happens - - // VALIDATION STRATEGY: If validation works, we should STAY on the login page - // (form submission should be blocked) - const currentUrl = page.url(); - const stayedOnLoginPage = currentUrl === initialUrl || currentUrl.includes('/login'); - - // Try to find visible error messages - const emailError = await page - .locator('text=/email.*invalide|invalid/i, p.text-red-500, p.text-destructive, .text-red-500, .error-message') - .first() - .isVisible({ timeout: 1000 }) - .catch(() => false); - - const passwordError = await page - .locator('text=/password.*required|requis/i, p.text-red-500, p.text-destructive, .text-red-500, .error-message') - .first() - .isVisible({ timeout: 1000 }) - .catch(() => false); - - // Validation is working if EITHER: - // 1. An error message is visible OR - // 2. We stayed on the login page (form blocked from submitting) - const validationWorking = emailError || passwordError || stayedOnLoginPage; - expect(validationWorking).toBeTruthy(); - }); - - /** - * TEST 9: Validation du formulaire d'inscription (mots de passe différents) - */ - test('should show error when passwords do not match during registration', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/register`); - await page.waitForLoadState('domcontentloaded'); - - // Attendre que la page soit complètement chargée - await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); - await expect(page.locator('form')).toBeVisible({ timeout: 10000 }); - - // Remplir avec des mots de passe différents - await fillField(page, 'input[name="email"], input#email', 'newuser@example.com'); - await page.waitForTimeout(200); - - await fillField(page, 'input[name="password"], input#password', 'Password123456!'); // 12+ chars - await page.waitForTimeout(200); - - // Sélecteur flexible pour couvrir toutes les variantes de nommage - await fillField(page, 'input[name="passwordConfirm"], input[name="password_confirm"], input[name="confirmPassword"]', 'DifferentPassword!'); - - // Attendre que React Hook Form valide - await page.waitForTimeout(500); - - // Soumettre le formulaire (ou attendre que la validation se déclenche) - // Note: React Hook Form peut bloquer la soumission si validation échoue - await forceSubmitForm(page, 'form').catch(() => {}); - - // Attendre le message d'erreur (validation côté client Zod/React Hook Form) - // Le message peut apparaître sans soumission si validation inline - await page.waitForTimeout(1500); // Augmenté pour React Hook Form - - // Chercher les sélecteurs d'erreur de validation de manière plus robuste - const errorVisible = await page - .locator('.text-destructive, [role="alert"], .text-red-500, .text-red-600, .error-message, p.text-sm.text-destructive') - .first() - .isVisible({ timeout: 8000 }) - .catch(() => false); - - // Alternative: chercher aussi par texte si le sélecteur CSS échoue - if (!errorVisible) { - const errorByText = await page - .locator('text=/password.*match|correspondent|identique|same/i') - .first() - .isVisible({ timeout: 3000 }) - .catch(() => false); - - expect(errorByText).toBeTruthy(); - } else { - expect(errorVisible).toBeTruthy(); - } - }); - - test.afterEach(async ({ }, testInfo) => { - if (consoleErrors.length > 0 && testInfo.status === 'passed') { - testInfo.annotations.push({ type: 'console-errors', description: consoleErrors.join('; ') }); - } - if (networkErrors.length > 0 && testInfo.status === 'passed') { - testInfo.annotations.push({ - type: 'network-errors', - description: networkErrors.map((e) => `${e.method} ${e.url}: ${e.status}`).join('; '), - }); - } - }); -}); diff --git a/apps/web/e2e/tests/chat.spec.ts b/apps/web/e2e/tests/chat.spec.ts deleted file mode 100644 index 31df90fcd..000000000 --- a/apps/web/e2e/tests/chat.spec.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { TEST_CONFIG, loginAsUser, setupErrorCapture } from '../utils/test-helpers'; - -/** - * Chat E2E Test Suite (Audit 2.10) - * - * Couvre le flow critique du chat : - * - Load chat page et UI (sidebar, channels, input) - * - État déconnecté quand WebSocket indisponible - * - Envoi de message quand WebSocket connecté (skip si non connecté) - */ - -test.describe('Chat Flow', () => { - let consoleErrors: string[] = []; - let networkErrors: Array<{ url: string; status: number; method: string }> = []; - - test.beforeEach(async ({ page }) => { - const errorCapture = setupErrorCapture(page); - consoleErrors = errorCapture.consoleErrors; - networkErrors = errorCapture.networkErrors; - }); - - test('should load chat page and show UI', async ({ page }) => { - console.log('🧪 [CHAT] Running: Load chat page and show UI'); - - await loginAsUser(page); - await page.waitForTimeout(1000); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/chat`); - await page.waitForLoadState('domcontentloaded'); - - await page.waitForTimeout(3000); - - const channelsHeader = page.locator('h3:has-text("Channels")'); - const loadingState = page.locator('text=/ESTABLISHING UPLINK|Establishing/i'); - const errorState = page.locator('text=/Connection Terminated|Access Restricted/i'); - const messageInput = page.locator('input[placeholder*="Broadcast"], input[aria-label*="message" i]'); - - const hasChannels = await channelsHeader.isVisible({ timeout: 5000 }).catch(() => false); - const hasLoading = await loadingState.isVisible({ timeout: 2000 }).catch(() => false); - const hasError = await errorState.isVisible({ timeout: 2000 }).catch(() => false); - const hasInput = await messageInput.isVisible({ timeout: 5000 }).catch(() => false); - - expect(hasChannels || hasLoading || hasError).toBeTruthy(); - - if (hasChannels) { - expect(hasInput).toBeTruthy(); - console.log('✅ [CHAT] Chat UI loaded with Channels and input'); - } else if (hasLoading) { - console.log('✅ [CHAT] Chat page showing loading state'); - } else if (hasError) { - console.log('✅ [CHAT] Chat page showing error state (expected when chat server unavailable)'); - } - }); - - test('should show disconnected state when WebSocket unavailable', async ({ page }) => { - console.log('🧪 [CHAT] Running: Disconnected state indicator'); - - await loginAsUser(page); - await page.waitForTimeout(1000); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/chat`); - await page.waitForLoadState('domcontentloaded'); - - await page.waitForTimeout(5000); - - const channelsHeader = page.locator('h3:has-text("Channels")'); - const statusDot = page.locator('.rounded-full.w-2.h-2, div.w-2.h-2.rounded-full'); - - const hasChannels = await channelsHeader.isVisible({ timeout: 10000 }).catch(() => false); - test.skip(!hasChannels, 'Chat UI not loaded - may be loading or error state'); - - const hasStatusDot = await statusDot.first().isVisible({ timeout: 3000 }).catch(() => false); - expect(hasStatusDot).toBeTruthy(); - - const dotClasses = await statusDot.first().getAttribute('class').catch(() => ''); - const isDisconnected = dotClasses?.includes('bg-destructive') ?? false; - const isConnected = dotClasses?.includes('bg-success') ?? false; - - expect(isDisconnected || isConnected).toBeTruthy(); - console.log(`✅ [CHAT] Status indicator visible (connected: ${isConnected}, disconnected: ${isDisconnected})`); - }); - - test('should send and display message when WebSocket connected', async ({ page }) => { - console.log('🧪 [CHAT] Running: Send message (when WebSocket connected)'); - - await loginAsUser(page); - await page.waitForTimeout(1000); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/chat`); - await page.waitForLoadState('domcontentloaded'); - - await page.waitForTimeout(5000); - - const channelsHeader = page.locator('h3:has-text("Channels")'); - const statusDot = page.locator('div.w-2.h-2.rounded-full, .rounded-full.w-2.h-2').first(); - const conversationItem = page.locator('[data-testid="conversation-item"], button, [role="button"]').filter({ hasText: /general|default|lobby|channel/i }).first(); - const messageInput = page.locator('input[placeholder*="Broadcast"], input[aria-label*="message" i]'); - const sendButton = page.locator('button[type="submit"]').filter({ has: page.locator('svg') }); - - const hasChannels = await channelsHeader.isVisible({ timeout: 10000 }).catch(() => false); - test.skip(!hasChannels, 'Chat UI not loaded'); - - const dotClasses = await statusDot.getAttribute('class').catch(() => ''); - const isConnected = dotClasses?.includes('bg-success') ?? false; - test.skip(!isConnected, 'WebSocket not connected - chat server may be unavailable'); - - const hasConversation = await conversationItem.isVisible({ timeout: 3000 }).catch(() => false); - test.skip(!hasConversation, 'No conversation/channel available to send message'); - - await conversationItem.click(); - await page.waitForTimeout(500); - - const testMessage = `E2E test ${Date.now()}`; - await messageInput.fill(testMessage); - await page.waitForTimeout(300); - - await sendButton.click(); - await page.waitForTimeout(2000); - - const messageVisible = await page.locator(`text="${testMessage}"`).isVisible({ timeout: 5000 }).catch(() => false); - expect(messageVisible).toBeTruthy(); - console.log('✅ [CHAT] Message sent and displayed'); - }); - - test.afterEach(async ({}, testInfo) => { - console.log('\n📊 [CHAT] === Final Verifications ==='); - if (consoleErrors.length > 0) { - console.log(`🔴 [CHAT] Console errors (${consoleErrors.length}):`); - consoleErrors.forEach((e) => console.log(` - ${e}`)); - } - if (networkErrors.length > 0) { - console.log(`🔴 [CHAT] Network errors (${networkErrors.length}):`); - networkErrors.forEach((e) => console.log(` - ${e.method} ${e.url}: ${e.status}`)); - } - }); -}); diff --git a/apps/web/e2e/tests/cross-browser.spec.ts b/apps/web/e2e/tests/cross-browser.spec.ts deleted file mode 100644 index 0b5304036..000000000 --- a/apps/web/e2e/tests/cross-browser.spec.ts +++ /dev/null @@ -1,302 +0,0 @@ - -import { test, expect } from '@playwright/test'; -import { TEST_CONFIG } from '../utils/test-helpers'; - -/** - * Cross-Browser Tests - * - * These tests verify that core functionality works across different browsers: - * - Chromium (Chrome, Edge) - * - Firefox - * - WebKit (Safari) - * - * These tests run on all browsers configured in playwright.config.ts - * - * To run cross-browser tests: - * - Run: npx playwright test cross-browser - * - Run on specific browser: npx playwright test cross-browser --project=firefox - */ - -test.describe('Cross-Browser Compatibility', () => { - // Use authenticated state for most tests - test.use({ storageState: 'e2e/.auth/user.json' }); - - test.describe('Authentication', () => { - test('should login successfully on all browsers', async ({ page, browserName }) => { - // Use unauthenticated state for login test - await page.context().clearCookies(); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`); - await page.waitForLoadState('networkidle'); - - // Wait for form to be ready - await page.waitForSelector('form', { timeout: 5000 }); - await page.waitForTimeout(500); - - // Fill login form - await page.fill('input[type="email"], input[name="email"]', TEST_CONFIG.TEST_USER_EMAIL); - await page.fill('input[type="password"], input[name="password"]', TEST_CONFIG.TEST_USER_PASSWORD); - - // Submit form - await page.click('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")'); - - // Wait for navigation to dashboard - await page.waitForURL('**/dashboard', { timeout: 10000 }); - - // Verify we're on dashboard - expect(page.url()).toContain('/dashboard'); - - console.log(`✅ Login successful on ${browserName}`); - }); - - test('should display login form correctly on all browsers', async ({ page, browserName }) => { - await page.context().clearCookies(); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`); - await page.waitForLoadState('networkidle'); - - // Check that form elements are visible - const emailInput = page.locator('input[type="email"], input[name="email"]').first(); - const passwordInput = page.locator('input[type="password"], input[name="password"]').first(); - const submitButton = page.locator('button[type="submit"]').first(); - - await expect(emailInput).toBeVisible(); - await expect(passwordInput).toBeVisible(); - await expect(submitButton).toBeVisible(); - - console.log(`✅ Login form displayed correctly on ${browserName}`); - }); - }); - - test.describe('Navigation', () => { - test('should navigate between pages on all browsers', async ({ page, browserName }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Navigate to profile - await page.click('a[href="/profile"], a[href*="profile"]', { timeout: 5000 }); - await page.waitForURL('**/profile', { timeout: 5000 }); - expect(page.url()).toContain('/profile'); - - // Navigate back to dashboard - await page.click('a[href="/dashboard"], a[href*="dashboard"]', { timeout: 5000 }); - await page.waitForURL('**/dashboard', { timeout: 5000 }); - expect(page.url()).toContain('/dashboard'); - - console.log(`✅ Navigation works on ${browserName}`); - }); - - test('should handle browser back/forward buttons', async ({ page, browserName }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Navigate to profile - await page.click('a[href="/profile"], a[href*="profile"]', { timeout: 5000 }); - await page.waitForURL('**/profile', { timeout: 5000 }); - - // Use browser back button - await page.goBack(); - await page.waitForURL('**/dashboard', { timeout: 5000 }); - expect(page.url()).toContain('/dashboard'); - - // Use browser forward button - await page.goForward(); - await page.waitForURL('**/profile', { timeout: 5000 }); - expect(page.url()).toContain('/profile'); - - console.log(`✅ Browser navigation works on ${browserName}`); - }); - }); - - test.describe('UI Components', () => { - test('should render buttons correctly on all browsers', async ({ page, browserName }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Find buttons on the page - const buttons = page.locator('button').first(); - await expect(buttons).toBeVisible(); - - // Check button styling (basic check) - const buttonStyles = await buttons.evaluate((el) => { - const styles = window.getComputedStyle(el); - return { - display: styles.display, - visibility: styles.visibility, - }; - }); - - expect(buttonStyles.display).not.toBe('none'); - expect(buttonStyles.visibility).not.toBe('hidden'); - - console.log(`✅ Buttons render correctly on ${browserName}`); - }); - - test('should render forms correctly on all browsers', async ({ page, browserName }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/profile`); - await page.waitForLoadState('networkidle'); - - // Wait for form elements - await page.waitForTimeout(1000); - - // Check for input fields - const inputs = page.locator('input, textarea, select'); - const inputCount = await inputs.count(); - - // Should have at least some form elements - expect(inputCount).toBeGreaterThan(0); - - console.log(`✅ Forms render correctly on ${browserName}`); - }); - }); - - test.describe('JavaScript Features', () => { - test('should support ES6+ features on all browsers', async ({ page, browserName }) => { - const result = await page.evaluate(() => { - // Test various ES6+ features - const features = { - arrowFunctions: typeof (() => { }) === 'function', - promises: typeof Promise !== 'undefined', - asyncAwait: typeof (async () => { }) === 'function', - templateLiterals: typeof `test` === 'string', - destructuring: (() => { - try { - const { a } = { a: 1 }; - return a === 1; - } catch { - return false; - } - })(), - spreadOperator: (() => { - try { - const arr = [...[1, 2, 3]]; - return arr.length === 3; - } catch { - return false; - } - })(), - }; - return features; - }); - - // All modern browsers should support these features - expect(result.arrowFunctions).toBe(true); - expect(result.promises).toBe(true); - expect(result.asyncAwait).toBe(true); - expect(result.templateLiterals).toBe(true); - expect(result.destructuring).toBe(true); - expect(result.spreadOperator).toBe(true); - - console.log(`✅ ES6+ features supported on ${browserName}`); - }); - - test('should support Web APIs on all browsers', async ({ page, browserName }) => { - const result = await page.evaluate(() => { - return { - fetch: typeof fetch !== 'undefined', - localStorage: typeof localStorage !== 'undefined', - sessionStorage: typeof sessionStorage !== 'undefined', - webSocket: typeof WebSocket !== 'undefined', - history: typeof window.history !== 'undefined' && typeof window.history.pushState === 'function', - }; - }); - - // All modern browsers should support these APIs - expect(result.fetch).toBe(true); - expect(result.localStorage).toBe(true); - expect(result.sessionStorage).toBe(true); - expect(result.webSocket).toBe(true); - expect(result.history).toBe(true); - - console.log(`✅ Web APIs supported on ${browserName}`); - }); - }); - - test.describe('CSS Features', () => { - test('should support modern CSS features on all browsers', async ({ page, browserName }) => { - const result = await page.evaluate(() => { - const testElement = document.createElement('div'); - testElement.style.cssText = 'display: flex; grid-template-columns: 1fr; transform: translateX(0);'; - document.body.appendChild(testElement); - - const styles = window.getComputedStyle(testElement); - const supported = { - flexbox: styles.display === 'flex' || styles.display === '-webkit-flex', - grid: styles.gridTemplateColumns !== undefined, - transform: styles.transform !== 'none' || styles.webkitTransform !== 'none', - }; - - document.body.removeChild(testElement); - return supported; - }); - - // All modern browsers should support these CSS features - expect(result.flexbox).toBe(true); - expect(result.grid).toBe(true); - expect(result.transform).toBe(true); - - console.log(`✅ Modern CSS features supported on ${browserName}`); - }); - }); - - test.describe('Responsive Design', () => { - test('should be responsive on all browsers', async ({ page, browserName }) => { - // Test mobile viewport - await page.setViewportSize({ width: 375, height: 667 }); - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Check that page is visible and not broken - const body = page.locator('body'); - await expect(body).toBeVisible(); - - // Test tablet viewport - await page.setViewportSize({ width: 768, height: 1024 }); - await page.reload(); - await page.waitForLoadState('networkidle'); - await expect(body).toBeVisible(); - - // Test desktop viewport - await page.setViewportSize({ width: 1920, height: 1080 }); - await page.reload(); - await page.waitForLoadState('networkidle'); - await expect(body).toBeVisible(); - - console.log(`✅ Responsive design works on ${browserName}`); - }); - }); - - test.describe('Error Handling', () => { - test('should handle errors gracefully on all browsers', async ({ page, browserName }) => { - // Navigate to a non-existent page - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/non-existent-page-12345`); - await page.waitForLoadState('networkidle'); - - // Should show 404 page or error message, not blank page - const body = page.locator('body'); - const bodyText = await body.textContent(); - - expect(bodyText).not.toBe(''); - expect(bodyText).not.toBeNull(); - - console.log(`✅ Error handling works on ${browserName}`); - }); - }); - - test.describe('Performance', () => { - test('should load pages within acceptable time on all browsers', async ({ page, browserName }) => { - const startTime = Date.now(); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - const loadTime = Date.now() - startTime; - - // Should load within 10 seconds (generous threshold for cross-browser) - expect(loadTime).toBeLessThan(10000); - - console.log(`✅ Page loaded in ${loadTime}ms on ${browserName}`); - }); - }); -}); - diff --git a/apps/web/e2e/tests/mobile.spec.ts b/apps/web/e2e/tests/mobile.spec.ts deleted file mode 100644 index bc0613006..000000000 --- a/apps/web/e2e/tests/mobile.spec.ts +++ /dev/null @@ -1,376 +0,0 @@ - -import { test, expect } from '@playwright/test'; -import { TEST_CONFIG } from '../utils/test-helpers'; - -/** - * Mobile Responsive Tests - * - * These tests verify that the application works correctly on various mobile device sizes. - * Tests cover: - * - Small phones (iPhone SE, small Android) - * - Medium phones (iPhone 12/13, standard Android) - * - Large phones (iPhone Pro Max, large Android) - * - Small tablets (iPad Mini) - * - * To run mobile responsive tests: - * - Run: npx playwright test mobile-responsive - * - Run on specific device: npx playwright test mobile-responsive --project="iPhone 12" - */ - -// Common mobile viewport sizes -const MOBILE_VIEWPORTS = { - 'iPhone SE': { width: 375, height: 667 }, // Small phone - 'iPhone 12': { width: 390, height: 844 }, // Medium phone - 'iPhone 14 Pro Max': { width: 430, height: 932 }, // Large phone - 'Samsung Galaxy S21': { width: 360, height: 800 }, // Android medium - 'Pixel 5': { width: 393, height: 851 }, // Android medium - 'iPad Mini': { width: 768, height: 1024 }, // Small tablet -}; - -test.describe('Mobile Responsive Tests', () => { - // Use authenticated state for most tests - test.use({ storageState: 'e2e/.auth/user.json' }); - - test.describe('Small Phone (iPhone SE - 375x667)', () => { - test.beforeEach(async ({ page }) => { - await page.setViewportSize(MOBILE_VIEWPORTS['iPhone SE']); - }); - - test('dashboard should be usable on small phone', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Check that main content is visible - const main = page.locator('main, [role="main"]').first(); - await expect(main).toBeVisible(); - - // Check that navigation is accessible (hamburger menu or similar) - const navButton = page.locator('button[aria-label*="menu"], button[aria-label*="Menu"], [data-testid*="menu"]').first(); - if (await navButton.count() > 0) { - await expect(navButton).toBeVisible(); - } - - // Verify no horizontal scrolling - const bodyWidth = await page.evaluate(() => document.body.scrollWidth); - const viewportWidth = MOBILE_VIEWPORTS['iPhone SE'].width; - expect(bodyWidth).toBeLessThanOrEqual(viewportWidth + 10); // Allow small margin - }); - - test('login page should be usable on small phone', async ({ page }) => { - await page.context().clearCookies(); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`); - await page.waitForLoadState('networkidle'); - - // Check form elements are visible and accessible - const emailInput = page.locator('input[type="email"], input[name="email"]').first(); - const passwordInput = page.locator('input[type="password"], input[name="password"]').first(); - const submitButton = page.locator('button[type="submit"]').first(); - - await expect(emailInput).toBeVisible(); - await expect(passwordInput).toBeVisible(); - await expect(submitButton).toBeVisible(); - - // Check that inputs are large enough to tap (min 44x44px recommended) - const emailBox = await emailInput.boundingBox(); - const passwordBox = await passwordInput.boundingBox(); - const buttonBox = await submitButton.boundingBox(); - - if (emailBox) expect(emailBox.height).toBeGreaterThanOrEqual(40); - if (passwordBox) expect(passwordBox.height).toBeGreaterThanOrEqual(40); - if (buttonBox) expect(buttonBox.height).toBeGreaterThanOrEqual(40); - }); - - test('profile page should be usable on small phone', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/profile`); - await page.waitForLoadState('networkidle'); - - const main = page.locator('main, [role="main"]').first(); - await expect(main).toBeVisible(); - - // Check that form elements are accessible - const inputs = page.locator('input, textarea, select'); - const inputCount = await inputs.count(); - expect(inputCount).toBeGreaterThan(0); - }); - }); - - test.describe('Medium Phone (iPhone 12 - 390x844)', () => { - test.beforeEach(async ({ page }) => { - await page.setViewportSize(MOBILE_VIEWPORTS['iPhone 12']); - }); - - test('dashboard should render correctly on medium phone', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - const main = page.locator('main, [role="main"]').first(); - await expect(main).toBeVisible(); - - // Take screenshot for visual verification - await expect(page).toHaveScreenshot('dashboard-iphone12.png', { - fullPage: true, - maxDiffPixels: 200, - }); - }); - - test('navigation should work on medium phone', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Try to navigate to profile - const profileLink = page.locator('a[href="/profile"], a[href*="profile"]').first(); - if (await profileLink.count() > 0) { - await profileLink.click({ timeout: 5000 }); - await page.waitForURL('**/profile', { timeout: 5000 }); - expect(page.url()).toContain('/profile'); - } - }); - - test('tracks page should be usable on medium phone', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/tracks`); - await page.waitForLoadState('networkidle'); - - const main = page.locator('main, [role="main"]').first(); - await expect(main).toBeVisible(); - - // Check that content is scrollable if needed - const isScrollable = await page.evaluate(() => { - return document.documentElement.scrollHeight > window.innerHeight; - }); - - // Should be able to scroll if content is long - expect(typeof isScrollable).toBe('boolean'); - }); - }); - - test.describe('Large Phone (iPhone 14 Pro Max - 430x932)', () => { - test.beforeEach(async ({ page }) => { - await page.setViewportSize(MOBILE_VIEWPORTS['iPhone 14 Pro Max']); - }); - - test('dashboard should utilize larger screen space', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - const main = page.locator('main, [role="main"]').first(); - await expect(main).toBeVisible(); - - // On larger phones, sidebar might be visible - const sidebar = page.locator('aside').first(); - const sidebarVisible = await sidebar.isVisible().catch(() => false); - - // Either sidebar is visible or hamburger menu is available - if (!sidebarVisible) { - const menuButton = page.locator('button[aria-label*="menu"], [data-testid*="menu"]').first(); - const menuExists = await menuButton.count() > 0; - expect(menuExists || sidebarVisible).toBe(true); - } - }); - - test('forms should be properly sized on large phone', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/profile`); - await page.waitForLoadState('networkidle'); - - const inputs = page.locator('input, textarea'); - const inputCount = await inputs.count(); - - if (inputCount > 0) { - const firstInput = inputs.first(); - const box = await firstInput.boundingBox(); - - if (box) { - // Inputs should be wide enough but not too wide - expect(box.width).toBeGreaterThan(200); - expect(box.width).toBeLessThan(430); // Should not exceed viewport - } - } - }); - }); - - test.describe('Android Devices', () => { - test('Samsung Galaxy S21 should render correctly', async ({ page }) => { - await page.setViewportSize(MOBILE_VIEWPORTS['Samsung Galaxy S21']); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - const main = page.locator('main, [role="main"]').first(); - await expect(main).toBeVisible(); - }); - - test('Pixel 5 should render correctly', async ({ page }) => { - await page.setViewportSize(MOBILE_VIEWPORTS['Pixel 5']); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - const main = page.locator('main, [role="main"]').first(); - await expect(main).toBeVisible(); - }); - }); - - test.describe('Small Tablet (iPad Mini - 768x1024)', () => { - test.beforeEach(async ({ page }) => { - await page.setViewportSize(MOBILE_VIEWPORTS['iPad Mini']); - }); - - test('dashboard should use tablet layout', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - const main = page.locator('main, [role="main"]').first(); - await expect(main).toBeVisible(); - - // On tablets, sidebar might be visible - const sidebar = page.locator('aside').first(); - const sidebarVisible = await sidebar.isVisible().catch(() => false); - - // Tablet should show more content - expect(sidebarVisible || true).toBe(true); // Sidebar or main content should be visible - }); - - test('forms should be properly sized on tablet', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/profile`); - await page.waitForLoadState('networkidle'); - - const form = page.locator('form').first(); - if (await form.count() > 0) { - await expect(form).toBeVisible(); - - // Forms on tablet should be wider - const formBox = await form.boundingBox(); - if (formBox) { - expect(formBox.width).toBeGreaterThan(400); - } - } - }); - }); - - test.describe('Touch Interactions', () => { - test.beforeEach(async ({ page }) => { - await page.setViewportSize(MOBILE_VIEWPORTS['iPhone 12']); - }); - - test('buttons should be tappable', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - const buttons = page.locator('button').first(); - if (await buttons.count() > 0) { - const buttonBox = await buttons.boundingBox(); - - if (buttonBox) { - // Buttons should be at least 44x44px for easy tapping - expect(buttonBox.width).toBeGreaterThanOrEqual(40); - expect(buttonBox.height).toBeGreaterThanOrEqual(40); - } - } - }); - - test('links should be tappable', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - const links = page.locator('a').first(); - if (await links.count() > 0) { - const linkBox = await links.boundingBox(); - - if (linkBox) { - // Links should have adequate touch target size - expect(linkBox.height).toBeGreaterThanOrEqual(30); - } - } - }); - }); - - test.describe('Orientation Changes', () => { - test('should handle portrait orientation', async ({ page }) => { - await page.setViewportSize({ width: 375, height: 667 }); // Portrait - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - const main = page.locator('main, [role="main"]').first(); - await expect(main).toBeVisible(); - }); - - test('should handle landscape orientation', async ({ page }) => { - await page.setViewportSize({ width: 667, height: 375 }); // Landscape - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - const main = page.locator('main, [role="main"]').first(); - await expect(main).toBeVisible(); - - // In landscape, sidebar might be visible - const sidebar = page.locator('aside').first(); - const sidebarVisible = await sidebar.isVisible().catch(() => false); - - // Should work in both cases - expect(sidebarVisible || true).toBe(true); - }); - }); - - test.describe('Responsive Breakpoints', () => { - test('should adapt to different breakpoints', async ({ page }) => { - const breakpoints = [ - { width: 320, height: 568, name: 'Very Small' }, - { width: 375, height: 667, name: 'Small' }, - { width: 414, height: 896, name: 'Medium' }, - { width: 768, height: 1024, name: 'Tablet' }, - ]; - - for (const breakpoint of breakpoints) { - await page.setViewportSize({ width: breakpoint.width, height: breakpoint.height }); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - const main = page.locator('main, [role="main"]').first(); - await expect(main).toBeVisible(); - - // Verify no horizontal overflow - const bodyWidth = await page.evaluate(() => document.body.scrollWidth); - expect(bodyWidth).toBeLessThanOrEqual(breakpoint.width + 20); // Allow small margin - - console.log(`✅ ${breakpoint.name} (${breakpoint.width}x${breakpoint.height}) - OK`); - } - }); - }); - - test.describe('Mobile-Specific Features', () => { - test.beforeEach(async ({ page }) => { - await page.setViewportSize(MOBILE_VIEWPORTS['iPhone 12']); - }); - - test('should handle mobile viewport meta tag', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - - const viewport = await page.locator('meta[name="viewport"]').getAttribute('content'); - - // Should have viewport meta tag for mobile - expect(viewport).toBeTruthy(); - }); - - test('should prevent zoom on input focus', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`); - await page.waitForLoadState('networkidle'); - - const input = page.locator('input').first(); - if (await input.count() > 0) { - await input.focus(); - - // Check that font-size is at least 16px to prevent zoom on iOS - const fontSize = await input.evaluate((el) => { - return window.getComputedStyle(el).fontSize; - }); - - const fontSizeNum = parseFloat(fontSize); - expect(fontSizeNum).toBeGreaterThanOrEqual(14); // At least 14px to prevent zoom - } - }); - }); -}); - diff --git a/apps/web/e2e/tests/mobile.spec.ts-snapshots/dashboard-iphone12-chromium-linux.png b/apps/web/e2e/tests/mobile.spec.ts-snapshots/dashboard-iphone12-chromium-linux.png deleted file mode 100644 index 27c0ff4f1..000000000 Binary files a/apps/web/e2e/tests/mobile.spec.ts-snapshots/dashboard-iphone12-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/tests/mobile.spec.ts-snapshots/dashboard-iphone12-msedge-linux.png b/apps/web/e2e/tests/mobile.spec.ts-snapshots/dashboard-iphone12-msedge-linux.png deleted file mode 100644 index a999727e3..000000000 Binary files a/apps/web/e2e/tests/mobile.spec.ts-snapshots/dashboard-iphone12-msedge-linux.png and /dev/null differ diff --git a/apps/web/e2e/tests/play.spec.ts b/apps/web/e2e/tests/play.spec.ts deleted file mode 100644 index dcc8ed81d..000000000 --- a/apps/web/e2e/tests/play.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Play flow E2E: after login, go to library or search, click a track, verify track page or player visible. - */ - -import { test, expect } from '@playwright/test'; -import { - TEST_CONFIG, - loginAsUser, - setupErrorCapture, -} from '../utils/test-helpers'; - -test.describe('Play Flow', () => { - test.use({ storageState: { cookies: [], origins: [] } }); - - let consoleErrors: string[] = []; - let networkErrors: Array<{ url: string; status: number; method: string }> = []; - - test.beforeEach(async ({ page }) => { - const errorCapture = setupErrorCapture(page); - consoleErrors = errorCapture.consoleErrors; - networkErrors = errorCapture.networkErrors; - }); - - test('after login, search or library -> click track -> track page or player visible', async ({ page }) => { - test.setTimeout(60000); - - await loginAsUser(page); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/search`, { waitUntil: 'domcontentloaded' }); - await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); - - const searchInput = page.locator( - 'input[type="search"], input[placeholder*="Search" i], input[placeholder*="Recherche" i], input[name="q"]' - ).first(); - await searchInput.fill('test').catch(() => {}); - await page.waitForTimeout(1000); - - const trackLink = page.locator('a[href*="/tracks/"]').first(); - const hasTrack = await trackLink.isVisible({ timeout: 5000 }).catch(() => false); - - if (hasTrack) { - await trackLink.click(); - await page.waitForURL(/\/tracks\//, { timeout: 10000 }).catch(() => {}); - const onTrackPage = (await page.url()).includes('/tracks/'); - expect(onTrackPage).toBe(true); - - const trackPageOrPlayer = page.locator( - '[data-testid="track-detail"], [data-testid="track-detail-page"], main, .fixed.bottom-0' - ).first(); - await expect(trackPageOrPlayer).toBeVisible({ timeout: 10000 }); - } else { - const resultsArea = page.locator('main, [data-testid="search-results"], [aria-label*="search" i]').first(); - const noResults = page.getByText(/no results|aucun résultat/i); - const hasContent = await resultsArea.isVisible({ timeout: 3000 }).catch(() => false) - || await noResults.isVisible({ timeout: 2000 }).catch(() => false); - expect(hasContent).toBe(true); - } - }); -}); diff --git a/apps/web/e2e/tests/playlists.spec.ts b/apps/web/e2e/tests/playlists.spec.ts deleted file mode 100644 index 7370d9b6c..000000000 --- a/apps/web/e2e/tests/playlists.spec.ts +++ /dev/null @@ -1,609 +0,0 @@ -import { test, expect, type Page } from '@playwright/test'; -import { - TEST_CONFIG, - loginAsUser, - forceSubmitForm, - openModal, - closeModal, - fillField, - safeClick, - navigateViaHref, - setupErrorCapture, - waitForToast, - waitForListLoaded, -} from '../utils/test-helpers'; - -/** - * Playlists E2E Test Suite - * - * Teste le cycle de vie complet des playlists : - * - Création d'une playlist - * - Lecture de la liste des playlists - * - Modification d'une playlist - * - Ajout de tracks à une playlist - * - Suppression de tracks d'une playlist - * - Suppression d'une playlist - */ - -test.describe('Playlists CRUD', () => { - let consoleErrors: string[] = []; - let networkErrors: Array<{ url: string; status: number; method: string }> = []; - - test.beforeEach(async ({ page }) => { - const errorCapture = setupErrorCapture(page); - consoleErrors = errorCapture.consoleErrors; - networkErrors = errorCapture.networkErrors; - - // 1. Login avant chaque test (nous laisse sur /dashboard si déjà connecté) - await loginAsUser(page); - - // 2. CORRECTION : Forcer la navigation vers la page des playlists - console.log('🧭 [NAVIGATION] Going to playlists page...'); - // 🔴 FIX: Utiliser l'URL complète pour éviter "Cannot navigate to invalid URL" - // S'assurer que TEST_CONFIG.FRONTEND_URL est défini - const baseUrl = TEST_CONFIG.FRONTEND_URL || 'http://localhost:3000'; - const playlistsUrl = `${baseUrl}/playlists`; - console.log(`🧭 [NAVIGATION] Navigating to: ${playlistsUrl}`); - await page.goto(playlistsUrl, { waitUntil: 'networkidle' }); - await page.waitForLoadState('networkidle'); - - // 🔴 FIX: Attendre que la page soit complètement chargée et hydratée - // Attendre le titre de la page ou la fin du loading - try { - await Promise.race([ - page.locator('h1:has-text("Playlist"), h1:has-text("Playlists"), h2:has-text("Playlist")').first().waitFor({ state: 'visible', timeout: 10000 }), - page.locator('[data-testid="playlists-page"], [data-testid="playlist-list"]').first().waitFor({ state: 'visible', timeout: 10000 }), - // Attendre qu'un élément de contenu soit visible (pas juste le skeleton) - page.locator('main, [role="main"]').first().waitFor({ state: 'visible', timeout: 10000 }), - ]); - console.log('✅ [PLAYLISTS] Page fully loaded'); - } catch { - console.warn('⚠️ [PLAYLISTS] Page load check timeout, continuing...'); - } - - // Attendre que les requêtes API soient terminées (si applicable) - try { - await page.waitForResponse( - (response) => response.url().includes('/playlists') && response.status() < 500, - { timeout: 10000 } - ).catch(() => { - // Si pas de requête API, ce n'est pas grave - }); - } catch { - // Ignorer si pas de requête API - } - }); - - /** - * TEST 1: Créer une nouvelle playlist - */ - test('should create a new playlist successfully', async ({ page }) => { - console.log('🧪 [PLAYLISTS] Running: Create new playlist'); - - // Naviguer directement vers la page des playlists (pas de lien dans sidebar) - // Utiliser l'URL complète et domcontentloaded pour éviter les timeouts - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/playlists`, { waitUntil: 'domcontentloaded' }); - // Attendre un peu pour que React Router mette à jour l'URL - await page.waitForTimeout(500); - // Vérifier l'URL mais ne pas timeout si elle ne change pas immédiatement - await page.waitForURL(/\/playlists/, { timeout: 15000 }).catch(() => { - // Si l'URL n'a pas changé, vérifier qu'on est au moins sur la bonne page - const currentUrl = page.url(); - if (!currentUrl.includes('/playlists')) { - throw new Error(`Navigation to /playlists failed. Current URL: ${currentUrl}`); - } - }); - - // Ouvrir la modal de création - // Le bouton a maintenant data-testid="create-playlist-btn" et aria-label="Créer une nouvelle playlist" - await openModal(page, /create|créer|nouvelle/i); - - // Remplir le formulaire - const playlistName = `Test Playlist ${Date.now()}`; - await fillField(page, 'input[name="name"], input[name="title"], input#title', playlistName); - - // Description (optionnelle) - const descriptionField = page.locator('textarea[name="description"], textarea#description').first(); - const isDescriptionVisible = await descriptionField.isVisible().catch(() => false); - - if (isDescriptionVisible) { - await descriptionField.fill('Playlist de test créée par E2E automation'); - } - - // Soumettre le formulaire - await forceSubmitForm(page, 'form'); - - // Attendre le succès - await waitForToast(page, 'success', 10000); - - // Attendre que la modal se ferme - await page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 5000 }).catch(() => { }); - - // 🔴 FIX: Recharger la page pour forcer le rafraîchissement de la liste - // La liste peut ne pas se rafraîchir automatiquement après création - await page.reload({ waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - - // 🔴 FIX: Utiliser directement getByText au lieu de waitForListLoaded - // Plus fiable car il cherche directement le texte, indépendamment de la structure UI - await expect(page.getByText(playlistName)).toBeVisible({ timeout: 15000 }); - - console.log('✅ [PLAYLISTS] Playlist created successfully'); - }); - - /** - * TEST 2: Lire la liste des playlists - */ - test('should display list of playlists', async ({ page }) => { - console.log('🧪 [PLAYLISTS] Running: Display playlists list'); - - // Naviguer directement vers la page des playlists (pas de lien dans sidebar) - // Utiliser l'URL complète et domcontentloaded pour éviter les timeouts - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/playlists`, { waitUntil: 'domcontentloaded' }); - // Attendre un peu pour que React Router mette à jour l'URL - await page.waitForTimeout(500); - // Vérifier l'URL mais ne pas timeout si elle ne change pas immédiatement - await page.waitForURL(/\/playlists/, { timeout: 15000 }).catch(() => { - // Si l'URL n'a pas changé, vérifier qu'on est au moins sur la bonne page - const currentUrl = page.url(); - if (!currentUrl.includes('/playlists')) { - throw new Error(`Navigation to /playlists failed. Current URL: ${currentUrl}`); - } - }); - - // Attendre que la liste soit chargée (peut être vide, donc minRows=0) - await waitForListLoaded(page, 0); - - // Vérifier que la page affiche le titre "Playlists" ou équivalent - const pageTitle = page.locator('h1:has-text("Playlists"), h1:has-text("Mes playlists")'); - await expect(pageTitle).toBeVisible({ timeout: 10000 }); - - // Vérifier que soit la liste est visible, soit l'état vide est affiché - const listOrEmpty = page.locator('[role="list"], [role="table"], text=/aucune|no.*found|empty|vide/i').first(); - const isVisible = await listOrEmpty.isVisible({ timeout: 5000 }).catch(() => false); - if (!isVisible) { - // Si ni liste ni état vide, vérifier au moins que le conteneur de la page est visible - const container = page.locator('.playlist-container, [data-testid="playlists-page"]').first(); - await expect(container).toBeVisible({ timeout: 5000 }); - } - - console.log('✅ [PLAYLISTS] Playlists page loaded successfully'); - }); - - /** - * TEST 3: Modifier une playlist existante - */ - test('should update playlist name and description', async ({ page }) => { - console.log('🧪 [PLAYLISTS] Running: Update playlist'); - - // Créer d'abord une playlist - await navigateViaHref(page, '/playlists', /\/playlists/); - await openModal(page, /create|créer|nouvelle/i); - - const originalName = `Original Playlist ${Date.now()}`; - await fillField(page, 'input[name="name"], input[name="title"], input#title', originalName); - await forceSubmitForm(page, 'form'); - await waitForToast(page, 'success', 10000); - - // Attendre que la modal se ferme - await page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 5000 }).catch(() => { }); - - // 🔴 FIX: Recharger la page pour forcer le rafraîchissement de la liste - await page.reload({ waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - - // 🔴 FIX: Utiliser directement getByText au lieu de waitForListLoaded - // Plus fiable car il cherche directement le texte, indépendamment de la structure UI - // 🔴 FIX: Cibler le lien de la card spécifiquement - // getByText peut cibler un élément non cliquable si le CSS est complexe - const playlistCard = page.locator('a[href*="/playlists/"]').filter({ hasText: originalName }).first(); - // 🔴 FIX: Naviguer manuellement vers la page de détails pour éviter les problèmes de clic/overlay - const href = await playlistCard.getAttribute('href'); - if (!href) throw new Error('Playlist card has no href'); - await page.goto(`${TEST_CONFIG.FRONTEND_URL}${href}`, { waitUntil: 'networkidle' }); - - // Attendre que la page de détails se charge (redondant mais sûr) - await page.waitForURL(/\/playlists\/[^/]+/, { timeout: 10000 }); - - // Sur la page de détails, chercher le bouton d'édition - // Sur la page de détails, chercher le bouton d'édition - // Note: Le texte est "Modifier" en français, pas "Éditer" - const editButton = page.locator('button:has-text("Edit"), button:has-text("Éditer"), button:has-text("Modifier"), button[aria-label*="edit" i], button[aria-label*="modifier" i]').first(); - const moreButton = page.locator('button:has-text("More"), button:has-text("Actions"), button[aria-label*="more" i], button[aria-label*="actions" i]').first(); - - // Attendre que les actions soient chargées - await page.waitForSelector('[role="group"][aria-label="Actions de la playlist"]', { timeout: 10000 }).catch(() => console.warn('⚠️ Actions group not found')); - - const isEditVisible = await editButton.isVisible().catch(() => false); - const isMoreVisible = await moreButton.isVisible().catch(() => false); - - if (isEditVisible) { - console.log('🔍 Clicking edit button via dispatchEvent'); - // Utiliser dispatchEvent pour contourner l'overlay de la sidebar qui intercepte le click - await editButton.dispatchEvent('click'); - } else if (isMoreVisible) { - await expect(moreButton).toBeVisible({ timeout: 5000 }); - await moreButton.click(); - await page.waitForTimeout(500); - const editMenuItem = page.locator('[role="menuitem"]:has-text("Edit"), [role="menuitem"]:has-text("Éditer")').first(); - await expect(editMenuItem).toBeVisible({ timeout: 5000 }); - await editMenuItem.click(); - } else { - // Si pas de bouton d'édition visible, on est peut-être déjà sur la page de détails - // Chercher un formulaire d'édition ou un bouton pour ouvrir l'édition - console.warn('⚠️ [PLAYLISTS] Edit button not found, playlist may not be editable or UI changed'); - } - - // Attendre que la modal d'édition s'ouvre - await page.waitForSelector('[role="dialog"]', { timeout: 5000 }); - - // Modifier le nom - const updatedName = `Updated Playlist ${Date.now()}`; - // 🔴 FIX: Ajouter l'ID spécifique utilisé dans PlaylistActions (edit-title) - const nameField = page.locator('input[name="name"], input[name="title"], input#title, input#edit-title').first(); - await nameField.clear(); - await nameField.fill(updatedName); - - // Soumettre en cliquant sur "Enregistrer" (pas de balise form dans le dialog) - // await forceSubmitForm(page, 'form'); // Ne marche pas car pas de form - const saveButton = page.locator('[role="dialog"] button').filter({ hasText: /enregistrer/i }).first(); - await expect(saveButton).toBeVisible({ timeout: 5000 }); - await saveButton.click({ force: true }); - await waitForToast(page, 'success', 10000); - - // Retourner à la liste des playlists pour vérifier - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/playlists`, { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - - // 🔴 FIX: Utiliser getByText pour une recherche directe et fiable - // 🔴 FIX: Cibler le lien de la card pour la vérification - const updatedPlaylist = page.locator('a[href*="/playlists/"]').filter({ hasText: updatedName }).first(); - await expect(updatedPlaylist).toBeVisible({ timeout: 15000 }); - - console.log('✅ [PLAYLISTS] Playlist updated successfully'); - }); - - /** - * TEST 4: Ajouter une track à une playlist - */ - test('should add track to playlist', async ({ page }) => { - console.log('🧪 [PLAYLISTS] Running: Add track to playlist'); - - // Créer une playlist - await navigateViaHref(page, '/playlists', /\/playlists/); - await openModal(page, /create|créer|nouvelle/i); - - const playlistName = `Add Track Playlist ${Date.now()}`; - await fillField(page, 'input[name="name"], input[name="title"], input#title', playlistName); - await forceSubmitForm(page, 'form'); - await waitForToast(page, 'success', 10000); - - // Attendre que la modal se ferme - await page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 5000 }).catch(() => { }); - - // 🔴 FIX: Recharger pour s'assurer que la playlist est créée avant de naviguer - await page.reload({ waitUntil: 'networkidle' }); - await page.waitForTimeout(1000); - - // Naviguer vers la bibliothèque pour trouver une track - await navigateViaHref(page, '/library', /\/library/); - - // Attendre que la page soit chargée - await page.waitForLoadState('domcontentloaded'); - await page.waitForTimeout(1000); - - // 🔴 FIX: La bibliothèque peut utiliser une table OU une grille de cards - // Attendre qu'au moins un élément de track soit visible (plus flexible) - try { - await waitForListLoaded(page, 1); - } catch { - // Si waitForListLoaded échoue, essayer de trouver directement une track - const trackElement = page.locator('tr, [role="row"], [role="listitem"], .track-card, [data-testid*="track"], [role="grid"] > *').first(); - await expect(trackElement).toBeVisible({ timeout: 10000 }); - } - - // 🔴 FIX: Trouver la première track avec un sélecteur générique (table OU grid) - // Essayer d'abord table row, puis grid item, puis n'importe quel élément contenant du texte de track - let firstTrack = page.locator('tr, [role="row"]').filter({ has: page.locator('td, [role="cell"]') }).first(); - if (!(await firstTrack.isVisible({ timeout: 2000 }).catch(() => false))) { - // Si pas de table, essayer grid ou card - firstTrack = page.locator('[role="grid"] > *, [role="listitem"], .track-card, [data-testid*="track"]').first(); - } - await expect(firstTrack).toBeVisible({ timeout: 10000 }); - - // Ouvrir le menu "Add to Playlist" - const addToPlaylistButton = firstTrack.locator('button:has-text("Add to playlist"), button:has-text("Ajouter à"), button[aria-label*="playlist" i]').first(); - const moreButton = firstTrack.locator('button:has-text("More"), button:has-text("Actions")').first(); - - const isAddVisible = await addToPlaylistButton.isVisible().catch(() => false); - const isMoreVisible = await moreButton.isVisible().catch(() => false); - - if (isAddVisible) { - await expect(addToPlaylistButton).toBeVisible({ timeout: 5000 }); - await addToPlaylistButton.click(); - } else if (isMoreVisible) { - await expect(moreButton).toBeVisible({ timeout: 5000 }); - await moreButton.click(); - await page.waitForTimeout(500); - const addMenuItem = page.locator('[role="menuitem"]:has-text("Add to playlist"), [role="menuitem"]:has-text("Ajouter")').first(); - await expect(addMenuItem).toBeVisible({ timeout: 5000 }); - await addMenuItem.click(); - } else { - console.warn('⚠️ [PLAYLISTS] Add to playlist button not found, skipping test'); - test.skip(); - return; - } - - // Sélectionner la playlist dans le menu/modal - await page.waitForTimeout(500); - const playlistOption = page.locator(`text=${playlistName}, [role="menuitem"]:has-text("${playlistName}")`).first(); - - const isPlaylistOptionVisible = await playlistOption.isVisible({ timeout: 5000 }).catch(() => false); - - if (isPlaylistOptionVisible) { - await expect(playlistOption).toBeVisible({ timeout: 5000 }); - await playlistOption.click(); - await waitForToast(page, 'success', 10000); - console.log('✅ [PLAYLISTS] Track added to playlist successfully'); - } else { - console.warn('⚠️ [PLAYLISTS] Playlist option not found in menu'); - } - }); - - /** - * TEST 5: Supprimer une playlist - */ - test('should delete playlist successfully', async ({ page }) => { - console.log('🧪 [PLAYLISTS] Running: Delete playlist'); - - // Créer une playlist à supprimer - await navigateViaHref(page, '/playlists', /\/playlists/); - await openModal(page, /create|créer|nouvelle/i); - - const playlistName = `Delete Playlist ${Date.now()}`; - await fillField(page, 'input[name="name"], input[name="title"], input#title', playlistName); - await forceSubmitForm(page, 'form'); - await waitForToast(page, 'success', 10000); - - // Attendre que la modal se ferme - await page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 5000 }).catch(() => { }); - - // 🔴 FIX: Recharger la page pour forcer le rafraîchissement de la liste - await page.reload({ waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - - // 🔴 FIX: Utiliser directement getByText au lieu de waitForListLoaded - // Plus fiable car il cherche directement le texte, indépendamment de la structure UI - // 🔴 FIX: Cibler le lien de la card spécifiquement - const playlistCard = page.locator('a[href*="/playlists/"]').filter({ hasText: playlistName }).first(); - await expect(playlistCard).toBeVisible({ timeout: 15000 }); - - // 🔴 FIX: Naviguer manuellement vers la page de détails - const href = await playlistCard.getAttribute('href'); - if (!href) throw new Error('Playlist card has no href'); - await page.goto(`${TEST_CONFIG.FRONTEND_URL}${href}`, { waitUntil: 'networkidle' }); - await page.waitForURL(/\/playlists\/[^/]+/, { timeout: 10000 }); - - // Sur la page de détails, chercher le bouton de suppression - const deleteButton = page.locator('button:has-text("Delete"), button:has-text("Supprimer"), button[aria-label*="delete" i], button[aria-label*="supprimer" i]').first(); - const moreButton = page.locator('button:has-text("More"), button:has-text("Actions"), button[aria-label*="more" i], button[aria-label*="actions" i]').first(); - - // Attendre que les actions soient chargées - await page.waitForSelector('[role="group"][aria-label="Actions de la playlist"]', { timeout: 10000 }).catch(() => console.warn('⚠️ Actions group not found')); - - const isDeleteVisible = await deleteButton.isVisible().catch(() => false); - const isMoreVisible = await moreButton.isVisible().catch(() => false); - - if (isDeleteVisible) { - await expect(deleteButton).toBeVisible({ timeout: 5000 }); - await deleteButton.click({ force: true }); - } else if (isMoreVisible) { - await expect(moreButton).toBeVisible({ timeout: 5000 }); - await moreButton.click(); - await page.waitForTimeout(500); - const deleteMenuItem = page.locator('[role="menuitem"]:has-text("Delete"), [role="menuitem"]:has-text("Supprimer")').first(); - await expect(deleteMenuItem).toBeVisible({ timeout: 5000 }); - await deleteMenuItem.click(); - } else { - // Fallback: icône de corbeille - const trashButton = page.locator('button svg.lucide-trash, button svg.fa-trash').first(); - if (await trashButton.isVisible({ timeout: 2000 }).catch(() => false)) { - await trashButton.click(); - } else { - console.warn('⚠️ [PLAYLISTS] Delete button not found, playlist may not be deletable or UI changed'); - } - } - - // Confirmer la suppression si modal de confirmation - await page.waitForTimeout(500); - // 🔴 FIX: Cibler le bouton DANS le dialog - const confirmButton = page.locator('[role="dialog"] button:has-text("Confirm"), [role="dialog"] button:has-text("Oui"), [role="dialog"] button:has-text("Supprimer")').first(); - const isConfirmVisible = await confirmButton.isVisible().catch(() => false); - - if (isConfirmVisible) { - await expect(confirmButton).toBeVisible({ timeout: 5000 }); - await confirmButton.click({ force: true }); - // 🔴 FIX: Attendre la confirmation de suppression avant de continuer - // Sinon la navigation manuelle suivante peut annuler la requête - await waitForToast(page, 'success', 10000); - } - - // Attendre que la navigation automatique se fasse (le composant redirige vers /playlists) - await page.waitForURL(/\/playlists$/, { timeout: 10000 }).catch(() => { - // Fallback si la redirection auto ne marche pas ou est lente - console.log('⚠️ [PLAYLISTS] Auto-redirect failed/slow, manual navigation'); - return page.goto(`${TEST_CONFIG.FRONTEND_URL}/playlists`, { waitUntil: 'networkidle' }); - }); - - // Attendre le rechargement de la liste - await page.waitForTimeout(2000); - - // 🔴 FIX: Vérifier que la playlist supprimée n'apparaît plus dans la liste - // Utiliser getByText qui est plus fiable pour vérifier l'absence - // 🔴 FIX: Vérifier que la playlist supprimée n'apparaît plus dans la liste - const deletedPlaylistCard = page.locator('a[href*="/playlists/"]').filter({ hasText: playlistName }).first(); - await expect(deletedPlaylistCard).not.toBeVisible({ timeout: 15000 }); - - // Vérifier persistence (reload pour s'assurer que la suppression est persistée) - await page.reload({ waitUntil: 'networkidle' }); - await page.waitForTimeout(1000); - - // Ne pas utiliser waitForListLoaded ici car on ne sait pas combien de playlists restent - // Vérifier directement que la playlist supprimée n'est plus visible - const deletedPlaylist = page.getByText(playlistName); - await expect(deletedPlaylist).not.toBeVisible({ timeout: 10000 }); - - console.log('✅ [PLAYLISTS] Playlist deleted successfully'); - }); - - /** - * TEST 6: Playlist vide (sans tracks) - */ - test('should display empty state for new playlist', async ({ page }) => { - console.log('🧪 [PLAYLISTS] Running: Empty playlist state'); - - // Créer une playlist - await navigateViaHref(page, '/playlists', /\/playlists/); - await openModal(page, /create|créer|nouvelle/i); - - const playlistName = `Empty Playlist ${Date.now()}`; - await fillField(page, 'input[name="name"], input[name="title"], input#title', playlistName); - await forceSubmitForm(page, 'form'); - await waitForToast(page, 'success', 10000); - - // Attendre que la modal se ferme - await page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 5000 }).catch(() => { }); - - // 🔴 FIX: Recharger la page pour forcer le rafraîchissement de la liste - await page.reload({ waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - - // 🔴 FIX: Utiliser directement getByText au lieu de waitForListLoaded - // Plus fiable car il cherche directement le texte, indépendamment de la structure UI - // 🔴 FIX: Naviguer manuellement vers la page de détails pour éviter les problèmes de clic/overlay - // Comme fait dans les autres tests (update/delete) - const playlistLink = page.locator('a[href*="/playlists/"]').filter({ hasText: playlistName }).first(); - const href = await playlistLink.getAttribute('href'); - if (!href) throw new Error('Playlist card has no href'); - await page.goto(`${TEST_CONFIG.FRONTEND_URL}${href}`, { waitUntil: 'networkidle' }); - - // Attendre que la page de détails se charge - await page.waitForURL(/\/playlists\/[^/]+/, { timeout: 10000 }); - - - // Vérifier l'état vide - const emptyState = page.locator('text=/empty|vide|aucune track|no tracks/i').first(); - const isEmptyStateVisible = await emptyState.isVisible({ timeout: 5000 }).catch(() => false); - - if (isEmptyStateVisible) { - console.log('✅ [PLAYLISTS] Empty state displayed correctly'); - } else { - console.log('ℹ️ [PLAYLISTS] Empty state not explicitly shown (may be implicit)'); - } - }); - - /** - * TEST 7: Recherche de playlists - */ - test('should search playlists by name', async ({ page }) => { - console.log('🧪 [PLAYLISTS] Running: Search playlists'); - - // Créer plusieurs playlists - await navigateViaHref(page, '/playlists', /\/playlists/); - - const searchTerm = `SearchTest${Date.now()}`; - - // Créer playlist 1 - await openModal(page, /create|créer|nouvelle/i); - await fillField(page, 'input[name="name"], input[name="title"], input#title', `${searchTerm} Alpha`); - await forceSubmitForm(page, 'form'); - await waitForToast(page, 'success', 10000); - await page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 5000 }).catch(() => { }); - - // Créer playlist 2 - await openModal(page, /create|créer|nouvelle/i); - await fillField(page, 'input[name="name"], input[name="title"], input#title', `${searchTerm} Beta`); - await forceSubmitForm(page, 'form'); - await waitForToast(page, 'success', 10000); - await page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 5000 }).catch(() => { }); - - // Créer playlist 3 (différente) - const differentName = `Different ${Date.now()}`; - await openModal(page, /create|créer|nouvelle/i); - await fillField(page, 'input[name="name"], input[name="title"], input#title', differentName); - await forceSubmitForm(page, 'form'); - await waitForToast(page, 'success', 10000); - await page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 5000 }).catch(() => { }); - - // 🔴 FIX: Recharger la page pour forcer le rafraîchissement de la liste - await page.reload({ waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - - // 🔴 FIX: Vérifier directement que les playlists créées sont visibles - // Au lieu de compter les éléments, on vérifie directement les textes - await expect(page.getByText(`${searchTerm} Alpha`)).toBeVisible({ timeout: 10000 }); - await expect(page.getByText(`${searchTerm} Beta`)).toBeVisible({ timeout: 10000 }); - await expect(page.getByText(differentName)).toBeVisible({ timeout: 10000 }); - - // Chercher un champ de recherche - // Chercher un champ de recherche - // 🔴 FIX: Cibler spécifiquement la recherche de playlist (éviter la recherche globale) - const searchInput = page.locator('[data-testid="playlist-search"]').first(); - const isSearchVisible = await searchInput.isVisible({ timeout: 2000 }).catch(() => false); - - // Fallback: ancien sélecteur si data-testid pas encore déployé (ou autre input) - if (!isSearchVisible) { - const fallbackInput = page.locator('input[placeholder*="Search" i], input[placeholder*="Recherche" i], input[type="search"]').filter({ hasNot: page.locator('[aria-label="Global search"]') }).first(); - if (await fallbackInput.isVisible().catch(() => false)) { - // Mais attention, si c'est la recherche globale, ça ne marchera pas - console.warn('⚠️ Using fallback search selector, might be global search'); - // On continue quand même pour voir - } - } - - if (isSearchVisible) { - // Effectuer la recherche - await searchInput.fill(searchTerm); - await page.waitForTimeout(1000); // Attendre le debounce - - // 🔴 FIX: Utiliser getByText pour une recherche directe et fiable - const alphaPlaylist = page.getByText(`${searchTerm} Alpha`); - const betaPlaylist = page.getByText(`${searchTerm} Beta`); - // differentName est défini dans le scope ci-dessus - const differentPlaylist = page.getByText(differentName); - - await expect(alphaPlaylist).toBeVisible({ timeout: 5000 }); - await expect(betaPlaylist).toBeVisible({ timeout: 5000 }); - await expect(differentPlaylist).not.toBeVisible(); - - console.log('✅ [PLAYLISTS] Search functionality works correctly'); - } else { - console.log('ℹ️ [PLAYLISTS] Search functionality not implemented yet'); - } - }); - - /** - * FINAL VERIFICATIONS - */ - test.afterEach(async ({}, testInfo) => { - console.log('\n📊 [PLAYLISTS] === Final Verifications ==='); - - if (consoleErrors.length > 0) { - console.log(`🔴 [PLAYLISTS] Console errors (${consoleErrors.length}):`); - consoleErrors.forEach((error) => { - console.log(` - ${error}`); - }); - } else { - console.log('✅ [PLAYLISTS] No console errors'); - } - - if (networkErrors.length > 0) { - console.log(`🔴 [PLAYLISTS] Network errors (${networkErrors.length}):`); - networkErrors.forEach((error) => { - console.log(` - ${error.method} ${error.url}: ${error.status}`); - }); - } else { - console.log('✅ [PLAYLISTS] No network errors'); - } - }); -}); diff --git a/apps/web/e2e/tests/profile.spec.ts b/apps/web/e2e/tests/profile.spec.ts deleted file mode 100644 index 8dc8aeb61..000000000 --- a/apps/web/e2e/tests/profile.spec.ts +++ /dev/null @@ -1,588 +0,0 @@ -import { test, expect, type Page } from '@playwright/test'; -import { - TEST_CONFIG, - loginAsUser, - forceSubmitForm, - fillField, - safeClick, - navigateViaSidebar, - setupErrorCapture, - waitForToast, -} from '../utils/test-helpers'; - -/** - * Profile E2E Test Suite - * - * Teste la gestion du profil utilisateur : - * - Affichage du profil - * - Modification des informations personnelles (username, bio, etc.) - * - Changement de mot de passe - * - Upload d'avatar - * - Validation des champs - */ - -test.describe('User Profile Management', () => { - let consoleErrors: string[] = []; - let networkErrors: Array<{ url: string; status: number; method: string }> = []; - - // Augmenter le timeout global pour ces tests (certains prennent du temps) - test.describe.configure({ timeout: 60000 }); - - test.beforeEach(async ({ page }) => { - const errorCapture = setupErrorCapture(page); - consoleErrors = errorCapture.consoleErrors; - networkErrors = errorCapture.networkErrors; - - // 1. Login avant chaque test (nous laisse sur /dashboard si déjà connecté) - await loginAsUser(page); - - // 2. CORRECTION : Forcer la navigation vers le profil - console.log('🧭 [NAVIGATION] Going to profile page...'); - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/profile`, { waitUntil: 'networkidle' }); - await page.waitForLoadState('networkidle'); - }); - - /** - * TEST 1: Afficher le profil utilisateur - */ - test('should display user profile information', async ({ page }) => { - console.log('🧪 [PROFILE] Running: Display profile'); - - // Naviguer vers la page de profil (via sidebar ou menu utilisateur) - // Essayer plusieurs méthodes car la navigation peut varier selon l'UI - - // Méthode 1: Via sidebar - const profileLinkSidebar = page.locator('[role="menuitem"]:has-text("Profil"), [role="menuitem"]:has-text("Profile")').first(); - const isSidebarLinkVisible = await profileLinkSidebar.isVisible({ timeout: 3000 }).catch(() => false); - - if (isSidebarLinkVisible) { - await expect(profileLinkSidebar).toBeVisible({ timeout: 5000 }); - await profileLinkSidebar.click(); - } else { - // Méthode 2: Via menu utilisateur (Avatar dropdown) - const userMenu = page.locator('[data-testid="user-menu"], button[aria-label*="user" i], button[aria-label*="profile" i]').first(); - const isUserMenuVisible = await userMenu.isVisible().catch(() => false); - - if (isUserMenuVisible) { - await expect(userMenu).toBeVisible({ timeout: 5000 }); - await userMenu.click(); - await page.waitForTimeout(500); - await page.locator('[role="menuitem"]:has-text("Profil"), [role="menuitem"]:has-text("Profile")').first().click(); - } else { - // Méthode 3: Navigation directe - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/profile`); - } - } - - // Attendre que la page se charge - await page.waitForURL(/\/profile|\/settings/, { timeout: 10000 }).catch(() => { - console.warn('⚠️ [PROFILE] URL did not change to profile page'); - }); - - // Attendre que la page soit complètement chargée - await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => { - console.warn('⚠️ [PROFILE] Timeout on networkidle, continuing...'); - }); - - // Vérifier que le titre de la page est visible (peut être h1, h2, ou dans un CardTitle) - // Le ProfileForm utilise CardTitle avec t('profile.title') - const pageTitle = page.locator( - 'h1:has-text("Profil"), h1:has-text("Profile"), h2:has-text("Profil"), h2:has-text("Profile"), [class*="CardTitle"], [class*="card-title"]' - ).first(); - // Si le titre n'est pas trouvé, vérifier au moins qu'on est sur la bonne page - const titleVisible = await pageTitle.isVisible({ timeout: 5000 }).catch(() => false); - if (!titleVisible) { - // Vérifier qu'on est bien sur /profile - const currentUrl = page.url(); - expect(currentUrl).toMatch(/\/profile/); - console.warn('⚠️ [PROFILE] Page title not found but URL is correct, continuing...'); - } else { - await expect(pageTitle).toBeVisible({ timeout: 10000 }); - } - - // Vérifier que les informations utilisateur sont affichées - // Le champ peut être un input (mode édition) ou un élément d'affichage (mode lecture) - const usernameDisplay = page.locator( - 'input#username, input[name="username"], [data-testid="username"], label:has-text("Username") + * input, label:has-text("Nom d\'utilisateur") + * input' - ).first(); - await expect(usernameDisplay).toBeVisible({ timeout: 15000 }); - - console.log('✅ [PROFILE] Profile page displayed successfully'); - }); - - /** - * TEST 2: Modifier le username - */ - test('should update username successfully', async ({ page }) => { - test.setTimeout(60000); // 60 secondes pour ce test spécifique - console.log('🧪 [PROFILE] Running: Update username'); - - // Naviguer vers le profil - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/profile`); - await page.waitForLoadState('domcontentloaded'); - - // Attendre que le formulaire soit visible - // Le champ username utilise id="username" dans ProfileForm - const usernameField = page.locator('input#username, input[name="username"]').first(); - await expect(usernameField).toBeVisible({ timeout: 15000 }); - - // 🔴 FIX: Attendre que le champ soit peuplé avec les données de l'utilisateur - // React doit finir de charger les données depuis l'API avant qu'on puisse les modifier - console.log('⏳ [PROFILE] Waiting for username field to be populated...'); - await page.waitForFunction( - (selector) => { - const input = document.querySelector(selector) as HTMLInputElement; - return input && input.value && input.value.trim().length > 0; - }, - 'input#username, input[name="username"]', - { timeout: 15000 } - ).catch(() => { - console.warn('⚠️ [PROFILE] Username field not populated, continuing anyway...'); - }); - - // Si le champ est disabled (mode lecture), cliquer sur le bouton Edit - const isDisabled = await usernameField.isDisabled().catch(() => false); - if (isDisabled) { - const editButton = page.locator('button:has-text("Edit"), button:has-text("Modifier"), button:has-text("profile.edit")').first(); - if (await editButton.isVisible({ timeout: 5000 }).catch(() => false)) { - await expect(editButton).toBeVisible({ timeout: 5000 }); - await editButton.click(); - await page.waitForTimeout(500); // Attendre que le mode édition s'active - // Re-vérifier que le champ est maintenant éditable - await expect(usernameField).toBeEnabled({ timeout: 5000 }); - } - } - - // Modifier le username - const newUsername = `testuser_${Date.now()}`; - await usernameField.clear(); - await usernameField.fill(newUsername); - - // Soumettre le formulaire - const submitButton = page.locator('button:has-text("Save"), button:has-text("Enregistrer"), button[type="submit"]').first(); - await expect(submitButton).toBeVisible({ timeout: 5000 }); - - // Attendre l'appel API - const updatePromise = page.waitForResponse( - (response) => - response.url().includes('/users') && - response.request().method() === 'PUT' && - response.status() < 500, - { timeout: 15000 } - ); - - await submitButton.click(); - - // Attendre la réponse - try { - const response = await updatePromise; - const status = response.status(); - console.log(`📡 [PROFILE] Update response: ${status}`); - - if (status === 200 || status === 204) { - await waitForToast(page, 'success', 10000); - console.log('✅ [PROFILE] Username updated successfully'); - } else { - console.warn(`⚠️ [PROFILE] Update failed with status ${status}`); - } - } catch (error) { - console.warn('⚠️ [PROFILE] Update request timeout'); - } - - // Vérifier que le nouveau username est affiché - // 🔴 FIX: Vérifier que la page est toujours ouverte avant de faire le reload - if (page.isClosed()) { - console.warn('⚠️ [PROFILE] Page was closed, cannot verify username persistence'); - return; - } - - try { - await page.reload({ waitUntil: 'domcontentloaded', timeout: 30000 }); - await page.waitForLoadState('networkidle', { timeout: 30000 }); - - // 🔴 FIX: Attendre que le champ soit peuplé après le reload - const updatedUsernameField = page.locator('input[name="username"], input#username').first(); - await expect(updatedUsernameField).toBeVisible({ timeout: 15000 }); - - // Attendre que le champ soit peuplé avec les données - await page.waitForFunction( - (selector) => { - const input = document.querySelector(selector) as HTMLInputElement; - return input && input.value && input.value.trim().length > 0; - }, - 'input[name="username"], input#username', - { timeout: 15000 } - ).catch(() => { - console.warn('⚠️ [PROFILE] Username field not populated after reload, continuing...'); - }); - - const currentValue = await updatedUsernameField.inputValue(); - expect(currentValue).toBe(newUsername); - - console.log('✅ [PROFILE] Username persisted after reload'); - } catch (error) { - console.warn('⚠️ [PROFILE] Reload failed or timeout, but update was successful (check logs)'); - // Ne pas faire échouer le test car l'update a réussi (status 200/204) - } - }); - - /** - * TEST 3: Modifier la bio - */ - test('should update bio successfully', async ({ page }) => { - console.log('🧪 [PROFILE] Running: Update bio'); - - // Naviguer vers le profil - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/profile`); - await page.waitForLoadState('domcontentloaded'); - - // Le champ bio utilise id="bio" dans ProfileForm (c'est un Input, pas un textarea) - const bioField = page.locator('input#bio, textarea#bio, [id="bio"]').first(); - - // Vérifier si le champ existe - const bioExists = await bioField.isVisible({ timeout: 5000 }).catch(() => false); - - if (!bioExists) { - console.log('ℹ️ [PROFILE] Bio field not found, skipping test'); - test.skip(); - return; - } - - // Si disabled, activer le mode édition - const isDisabled = await bioField.isDisabled().catch(() => false); - if (isDisabled) { - const editButton = page.locator('button:has-text("Edit"), button:has-text("Modifier")').first(); - if (await editButton.isVisible({ timeout: 5000 }).catch(() => false)) { - await editButton.click(); - await page.waitForTimeout(500); - await expect(bioField).toBeEnabled({ timeout: 5000 }); - } - } - - // Modifier la bio - const newBio = `This is a test bio updated at ${new Date().toISOString()}`; - await bioField.clear(); - await bioField.fill(newBio); - - // Soumettre le formulaire - const submitButton = page.locator('button:has-text("Save"), button:has-text("Enregistrer"), button[type="submit"]').first(); - await submitButton.click(); - - // Attendre le succès - await waitForToast(page, 'success', 10000); - - // Vérifier la persistence - await page.reload({ waitUntil: 'domcontentloaded' }); - const updatedBioField = page.locator('textarea[name="bio"], textarea#bio').first(); - const currentValue = await updatedBioField.inputValue(); - expect(currentValue).toBe(newBio); - - console.log('✅ [PROFILE] Bio updated successfully'); - }); - - /** - * TEST 4: Changer le mot de passe - */ - test('should change password successfully', async ({ page }) => { - console.log('🧪 [PROFILE] Running: Change password'); - - // Naviguer vers le profil ou settings - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/profile`); - await page.waitForLoadState('domcontentloaded'); - - // Chercher un lien/bouton "Change Password" ou "Security" - const changePasswordButton = page.locator('button:has-text("Change password"), button:has-text("Changer"), a:has-text("Security"), a:has-text("Sécurité")').first(); - const isChangePasswordVisible = await changePasswordButton.isVisible({ timeout: 5000 }).catch(() => false); - - if (!isChangePasswordVisible) { - console.log('ℹ️ [PROFILE] Change password section not found, skipping test'); - test.skip(); - return; - } - - await changePasswordButton.click(); - await page.waitForTimeout(500); - - // Remplir le formulaire de changement de mot de passe - const currentPasswordField = page.locator('input[name="currentPassword"], input[name="current_password"], input#currentPassword').first(); - const newPasswordField = page.locator('input[name="newPassword"], input[name="new_password"], input#newPassword').first(); - const confirmPasswordField = page.locator('input[name="confirmPassword"], input[name="confirm_password"], input#confirmPassword').first(); - - const areFieldsVisible = await currentPasswordField.isVisible({ timeout: 5000 }).catch(() => false); - - if (!areFieldsVisible) { - console.log('ℹ️ [PROFILE] Password fields not found, skipping test'); - test.skip(); - return; - } - - // Remplir avec le mot de passe actuel et un nouveau - await currentPasswordField.fill('password123'); // Mot de passe actuel du test user - const newPassword = `NewPass${Date.now()}!`; - await newPasswordField.fill(newPassword); - await confirmPasswordField.fill(newPassword); - - // Soumettre - const submitButton = page.locator('button:has-text("Change"), button:has-text("Update"), button[type="submit"]').first(); - await submitButton.click(); - - // Attendre le résultat - try { - await waitForToast(page, 'success', 10000); - console.log('✅ [PROFILE] Password changed successfully'); - - // Note: Dans un vrai test, on devrait se déconnecter et se reconnecter avec le nouveau mot de passe - // Mais pour éviter de casser les autres tests, on restaure l'ancien mot de passe - - await page.waitForTimeout(1000); - - // Restaurer l'ancien mot de passe - await currentPasswordField.fill(newPassword); - await newPasswordField.fill('password123'); - await confirmPasswordField.fill('password123'); - await submitButton.click(); - await page.waitForTimeout(2000); - } catch (error) { - console.warn('⚠️ [PROFILE] Password change failed or timed out'); - } - }); - - /** - * TEST 5: Upload d'avatar - */ - test('should upload profile avatar', async ({ page }) => { - console.log('🧪 [PROFILE] Running: Upload avatar'); - - // Naviguer vers le profil - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/profile`); - await page.waitForLoadState('domcontentloaded'); - - // Chercher l'input file pour l'avatar - const avatarInput = page.locator('input[type="file"][accept*="image"], input[name="avatar"]').first(); - const isAvatarInputVisible = await avatarInput.isVisible({ timeout: 5000 }).catch(() => false); - - if (!isAvatarInputVisible) { - // Essayer de cliquer sur l'avatar pour révéler l'input - const avatarContainer = page.locator('[data-testid="avatar"], img[alt*="avatar" i], button:has-text("Upload")').first(); - const isAvatarContainerVisible = await avatarContainer.isVisible().catch(() => false); - - if (isAvatarContainerVisible) { - await expect(avatarContainer).toBeVisible({ timeout: 5000 }); - await avatarContainer.click(); - await page.waitForTimeout(500); - } else { - console.log('ℹ️ [PROFILE] Avatar upload not found, skipping test'); - test.skip(); - return; - } - } - - // Créer une image de test (1x1 PNG transparent) - const imageBuffer = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', - 'base64' - ); - - // Upload l'image - const fileInputFinal = page.locator('input[type="file"][accept*="image"]').first(); - await fileInputFinal.setInputFiles({ - name: 'avatar.png', - mimeType: 'image/png', - buffer: imageBuffer, - }); - - // Attendre l'upload - await page.waitForTimeout(2000); - - // Vérifier le succès (toast ou preview) - const successVisible = await page - .locator('text=/uploaded|success|succès/i') - .isVisible({ timeout: 5000 }) - .catch(() => false); - - if (successVisible) { - console.log('✅ [PROFILE] Avatar uploaded successfully'); - } else { - console.log('ℹ️ [PROFILE] Avatar upload completed (no explicit success message)'); - } - }); - - /** - * TEST 6: Validation des champs (username trop court) - */ - test('should validate username length', async ({ page }) => { - test.setTimeout(60000); // 60 secondes pour ce test spécifique - console.log('🧪 [PROFILE] Running: Username validation'); - - // Naviguer vers le profil - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/profile`); - await page.waitForLoadState('domcontentloaded'); - - // Attendre que le champ username soit visible - const usernameField = page.locator('input#username, input[name="username"]').first(); - await expect(usernameField).toBeVisible({ timeout: 15000 }); - - // Si disabled, activer le mode édition - const isDisabled = await usernameField.isDisabled().catch(() => false); - if (isDisabled) { - const editButton = page.locator('button:has-text("Edit"), button:has-text("Modifier")').first(); - if (await editButton.isVisible({ timeout: 5000 }).catch(() => false)) { - await editButton.click(); - await page.waitForTimeout(500); - await expect(usernameField).toBeEnabled({ timeout: 5000 }); - } - } - - // Essayer un username trop court (< 3 caractères) - await usernameField.clear(); - await usernameField.fill('ab'); - - // 🔴 FIX: Forcer la validation React en déclenchant un événement blur - // Cela garantit que React Hook Form met à jour l'état de validation - await usernameField.blur(); - await page.waitForTimeout(500); // Attendre que React mette à jour l'état - - // 🔴 FIX: Vérifier la validation en cherchant plusieurs indicateurs - // 1. Vérifier les messages d'erreur visibles (React Hook Form / Zod) - const errorMessageSelectors = [ - 'p.text-destructive', - 'p.text-red-500', - 'p.text-red-600', - '[role="alert"]', - '.text-error', - '.error-message', - 'text=/trop court|too short|minimum|at least|caractères|characters/i', - ]; - - let validationDetected = false; - - // Chercher un message d'erreur visible - for (const selector of errorMessageSelectors) { - const errorElement = page.locator(selector).first(); - const isVisible = await errorElement.isVisible({ timeout: 2000 }).catch(() => false); - if (isVisible) { - const errorText = await errorElement.textContent().catch(() => ''); - if (errorText && (errorText.toLowerCase().includes('short') || - errorText.toLowerCase().includes('court') || - errorText.toLowerCase().includes('minimum') || - errorText.toLowerCase().includes('caractère'))) { - console.log(`✅ [PROFILE] Validation error found: ${errorText}`); - validationDetected = true; - break; - } - } - } - - // 2. Vérifier l'attribut aria-invalid - if (!validationDetected) { - const ariaInvalid = await usernameField.getAttribute('aria-invalid'); - if (ariaInvalid === 'true') { - console.log('✅ [PROFILE] Validation detected via aria-invalid'); - validationDetected = true; - } - } - - // 3. Vérifier si le bouton submit est désactivé (indicateur de validation) - if (!validationDetected) { - const submitButton = page.locator('button:has-text("Save"), button:has-text("Enregistrer"), button[type="submit"]').first(); - const isDisabled = await submitButton.isDisabled().catch(() => false); - if (isDisabled) { - console.log('✅ [PROFILE] Validation detected via disabled submit button'); - validationDetected = true; - } - } - - // 4. Essayer de soumettre et vérifier qu'une erreur apparaît - if (!validationDetected) { - const submitButton = page.locator('button:has-text("Save"), button:has-text("Enregistrer"), button[type="submit"]').first(); - await submitButton.click(); - await page.waitForTimeout(500); - - // Vérifier qu'un message d'erreur apparaît après la tentative de soumission - const errorAfterSubmit = page.locator('text=/trop court|too short|minimum|at least|caractères|characters|erreur|error/i, [role="alert"]').first(); - const isErrorAfterSubmit = await errorAfterSubmit.isVisible({ timeout: 3000 }).catch(() => false); - if (isErrorAfterSubmit) { - console.log('✅ [PROFILE] Validation error shown after submit attempt'); - validationDetected = true; - } - } - - // 5. Fallback: Vérifier la validation HTML5 native (si rien d'autre n'a fonctionné) - if (!validationDetected) { - const isInvalid = await usernameField.evaluate((el: HTMLInputElement) => !el.validity.valid); - if (isInvalid) { - console.log('✅ [PROFILE] HTML5 validation working (fallback)'); - validationDetected = true; - } - } - - // Assertion finale - expect(validationDetected).toBeTruthy(); - console.log('✅ [PROFILE] Username validation working correctly'); - }); - - /** - * TEST 7: Afficher les informations du compte (email, date de création) - */ - test('should display account information', async ({ page }) => { - console.log('🧪 [PROFILE] Running: Display account info'); - - // Naviguer vers le profil - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/profile`); - await page.waitForLoadState('domcontentloaded'); - - // Vérifier que l'email est affiché (généralement en lecture seule) - const emailDisplay = page.locator('input[name="email"], input[type="email"], text=/email/i').first(); - const isEmailVisible = await emailDisplay.isVisible({ timeout: 5000 }).catch(() => false); - - if (isEmailVisible) { - console.log('✅ [PROFILE] Email displayed'); - } - - // Vérifier que d'autres informations du compte sont présentes - // (date de création, rôle, etc.) - const accountInfo = page.locator('text=/member since|membre depuis|created|créé/i').first(); - const isAccountInfoVisible = await accountInfo.isVisible({ timeout: 5000 }).catch(() => false); - - if (isAccountInfoVisible) { - console.log('✅ [PROFILE] Account information displayed'); - } else { - console.log('ℹ️ [PROFILE] Additional account info not displayed'); - } - }); - - /** - * TEST 8: Lien vers les paramètres avancés - */ - // TEST 8: Lien vers les paramètres avancés - SUPPRIMÉ car la fonctionnalité n'existe pas - /* - test('should navigate to advanced settings', async ({ page }) => { - // ... skipped ... - }); - */ - - /** - * FINAL VERIFICATIONS - */ - test.afterEach(async ({}, testInfo) => { - console.log('\n📊 [PROFILE] === Final Verifications ==='); - - if (consoleErrors.length > 0) { - console.log(`🔴 [PROFILE] Console errors (${consoleErrors.length}):`); - consoleErrors.forEach((error) => { - console.log(` - ${error}`); - }); - } else { - console.log('✅ [PROFILE] No console errors'); - } - - if (networkErrors.length > 0) { - console.log(`🔴 [PROFILE] Network errors (${networkErrors.length}):`); - networkErrors.forEach((error) => { - console.log(` - ${error.method} ${error.url}: ${error.status}`); - }); - } else { - console.log('✅ [PROFILE] No network errors'); - } - }); -}); diff --git a/apps/web/e2e/tests/purchase.spec.ts b/apps/web/e2e/tests/purchase.spec.ts deleted file mode 100644 index b4e0311a8..000000000 --- a/apps/web/e2e/tests/purchase.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { - TEST_CONFIG, - loginAsUser, - setupErrorCapture, - waitForToast, -} from '../utils/test-helpers'; - -/** - * Purchase E2E Test Suite (Audit 2.10) - * - * Couvre le flow critique d'achat : - * - Marketplace → Add to cart → Checkout → Success - * - Cart empty state - */ - -test.describe('Purchase Flow', () => { - let consoleErrors: string[] = []; - let networkErrors: Array<{ url: string; status: number; method: string }> = []; - - test.beforeEach(async ({ page }) => { - const errorCapture = setupErrorCapture(page); - consoleErrors = errorCapture.consoleErrors; - networkErrors = errorCapture.networkErrors; - }); - - test('should add product to cart and checkout successfully', async ({ page }) => { - console.log('🧪 [PURCHASE] Running: Add to cart and checkout'); - - await loginAsUser(page); - await page.waitForTimeout(1000); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/marketplace`); - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}); - - const productCard = page.locator('article[aria-label^="Product:"]').first(); - await expect(productCard).toBeVisible({ timeout: 10000 }); - - await productCard.hover(); - await page.waitForTimeout(300); - - const addToCartButton = page.locator('button:has-text("Add to Cart")').first(); - await expect(addToCartButton).toBeVisible({ timeout: 5000 }); - await addToCartButton.click(); - - await page.waitForTimeout(1500); - - const cartButton = page.locator('button:has-text("Cart")').first(); - await expect(cartButton).toBeVisible({ timeout: 5000 }); - await cartButton.click(); - - const cartDialog = page.locator('[role="dialog"]').filter({ hasText: 'Shopping Cart' }); - await expect(cartDialog).toBeVisible({ timeout: 5000 }); - - const checkoutButton = cartDialog.locator('button:has-text("Checkout")'); - await expect(checkoutButton).toBeVisible({ timeout: 3000 }); - await checkoutButton.click(); - - const successToast = await waitForToast(page, 'success', 15000); - expect(successToast.toLowerCase()).toMatch(/order|success|placed/); - - console.log('✅ [PURCHASE] Checkout successful'); - }); - - test('should show cart empty message when no items', async ({ page }) => { - console.log('🧪 [PURCHASE] Running: Cart empty state'); - - await loginAsUser(page); - await page.waitForTimeout(1000); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/marketplace`); - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}); - - const cartButton = page.locator('button:has-text("Cart")').first(); - await expect(cartButton).toBeVisible({ timeout: 5000 }); - await cartButton.click(); - - const cartDialog = page.locator('[role="dialog"]').filter({ hasText: 'Shopping Cart' }); - await expect(cartDialog).toBeVisible({ timeout: 5000 }); - - const emptyMessage = cartDialog.locator('text=/cart is empty|your cart is empty/i'); - await expect(emptyMessage).toBeVisible({ timeout: 5000 }); - - console.log('✅ [PURCHASE] Cart empty message displayed'); - }); - - test.afterEach(async ({}, testInfo) => { - console.log('\n📊 [PURCHASE] === Final Verifications ==='); - if (consoleErrors.length > 0) { - console.log(`🔴 [PURCHASE] Console errors (${consoleErrors.length}):`); - consoleErrors.forEach((e) => console.log(` - ${e}`)); - } - if (networkErrors.length > 0) { - console.log(`🔴 [PURCHASE] Network errors (${networkErrors.length}):`); - networkErrors.forEach((e) => console.log(` - ${e.method} ${e.url}: ${e.status}`)); - } - }); -}); diff --git a/apps/web/e2e/tests/queue.spec.ts b/apps/web/e2e/tests/queue.spec.ts deleted file mode 100644 index 64dee7020..000000000 --- a/apps/web/e2e/tests/queue.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Queue E2E Tests (v0.102) - * - * Parcours : navigation /queue, affichage queue vide ou pleine, - * présence des actions Clear et Save Queue. - */ - -import { test, expect } from '@playwright/test'; -import { - TEST_CONFIG, - TEST_USERS, - loginAsUser, - setupErrorCapture, -} from '../utils/test-helpers'; - -test.describe('Queue Flow', () => { - test.use({ storageState: { cookies: [], origins: [] } }); - - test.beforeEach(async ({ page }) => { - setupErrorCapture(page); - }); - - test('should show queue page with empty state or tracks', async ({ page }) => { - test.setTimeout(45000); - - await loginAsUser(page, TEST_USERS.default.email, TEST_USERS.default.password); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/queue`, { - waitUntil: 'domcontentloaded', - }); - await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); - - await expect(page).toHaveURL(/\/queue/); - - const heading = page.getByRole('heading', { name: /play queue/i }); - await expect(heading).toBeVisible({ timeout: 8000 }); - - const emptyState = page.getByText(/nothing in your queue|start playing music/i); - const saveButton = page.getByRole('button', { name: /save queue/i }); - const clearButton = page.getByRole('button', { name: /clear/i }); - const hasEmptyOrActions = - (await emptyState.isVisible({ timeout: 3000 }).catch(() => false)) || - (await saveButton.isVisible({ timeout: 3000 }).catch(() => false)) || - (await clearButton.isVisible({ timeout: 3000 }).catch(() => false)); - - expect(hasEmptyOrActions || (await heading.isVisible())).toBe(true); - }); -}); diff --git a/apps/web/e2e/tests/search.spec.ts b/apps/web/e2e/tests/search.spec.ts deleted file mode 100644 index 6b1330fe3..000000000 --- a/apps/web/e2e/tests/search.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Search E2E Tests - * - * Parcours critique : aller sur /search, saisir une requête, vérifier que des résultats - * (tracks/playlists) s'affichent ou que l'état vide est affiché. - */ - -import { test, expect } from '@playwright/test'; -import { - TEST_CONFIG, - TEST_USERS, - loginAsUser, - fillField, - forceSubmitForm, - setupErrorCapture, -} from '../utils/test-helpers'; - -test.describe('Search Flow', () => { - test.use({ storageState: { cookies: [], origins: [] } }); - - let consoleErrors: string[] = []; - let networkErrors: Array<{ url: string; status: number; method: string }> = []; - - test.beforeEach(async ({ page }) => { - const errorCapture = setupErrorCapture(page); - consoleErrors = errorCapture.consoleErrors; - networkErrors = errorCapture.networkErrors; - }); - - test('should show search page and display results or empty state', async ({ page }) => { - test.setTimeout(60000); - - await loginAsUser(page, TEST_USERS.default.email, TEST_USERS.default.password); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/search`, { waitUntil: 'domcontentloaded' }); - await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); - - await expect(page).toHaveURL(/\/search/); - - const searchInput = page.locator( - 'input[type="search"], input[placeholder*="Search" i], input[placeholder*="Recherche" i], input[name="q"]' - ).first(); - await expect(searchInput).toBeVisible({ timeout: 10000 }); - - await searchInput.fill('test'); - await page.waitForTimeout(800); - - const resultsArea = page.locator('[data-testid="search-results"], [aria-label*="search" i], .search-results, main').first(); - await expect(resultsArea).toBeVisible({ timeout: 10000 }); - - const noResults = page.getByText(/no results|aucun résultat|no tracks|aucun track/i); - const hasResults = page.locator('a[href*="/tracks/"], [data-testid="track-card"], .track-card').first(); - const hasResultsOrEmpty = await noResults.isVisible({ timeout: 2000 }).catch(() => false) - || await hasResults.isVisible({ timeout: 2000 }).catch(() => false); - expect(hasResultsOrEmpty || (await resultsArea.isVisible())).toBe(true); - }); -}); diff --git a/apps/web/e2e/tests/smoke-post-deploy.spec.ts b/apps/web/e2e/tests/smoke-post-deploy.spec.ts deleted file mode 100644 index d2cbced6e..000000000 --- a/apps/web/e2e/tests/smoke-post-deploy.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Post-deployment smoke tests. - * Run against a deployed environment (staging/production) via PLAYWRIGHT_BASE_URL. - * Does NOT start the dev server - expects the target to be already running. - * - * Usage: - * PLAYWRIGHT_BASE_URL=https://staging.veza.com npx playwright test e2e/tests/smoke-post-deploy.spec.ts - * VITE_FRONTEND_URL=https://app.veza.com npx playwright test e2e/tests/smoke-post-deploy.spec.ts - */ - -import { test, expect } from '@playwright/test'; - -const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || process.env.VITE_FRONTEND_URL || 'http://localhost:5173'; - -test.describe('Post-deploy smoke checks', () => { - test('homepage loads', async ({ page }) => { - const response = await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 15000 }); - expect(response?.status()).toBeLessThan(500); - }); - - test('login page loads', async ({ page }) => { - const response = await page.goto(`${BASE_URL}/login`, { waitUntil: 'domcontentloaded', timeout: 15000 }); - expect(response?.status()).toBeLessThan(500); - }); - - test('API health check', async ({ request }) => { - const origin = new URL(BASE_URL).origin; - const apiUrl = `${origin}/api/v1/health`; - try { - const response = await request.get(apiUrl, { timeout: 10000 }); - expect(response.status()).toBeLessThan(500); - } catch { - test.skip(true, 'API health endpoint may not be reachable from this context'); - } - }); -}); diff --git a/apps/web/e2e/tests/smoke.spec.ts b/apps/web/e2e/tests/smoke.spec.ts deleted file mode 100644 index 86f4fcfa7..000000000 --- a/apps/web/e2e/tests/smoke.spec.ts +++ /dev/null @@ -1,372 +0,0 @@ -/** - * Critical User Flows E2E Tests - * FE-TEST-012: Test login, upload, playlist creation end-to-end - * - * This test suite covers the most critical user journeys: - * 1. User login flow - * 2. Track upload flow - * 3. Playlist creation flow - * - * These tests ensure that the core functionality works together seamlessly. - */ - - -import { test, expect } from '@playwright/test'; -import { - TEST_CONFIG, - TEST_USERS, - forceSubmitForm, - fillField, - waitForToast, - setupErrorCapture, - openModal, -} from '../utils/test-helpers'; -import { createMockMP3Buffer } from '../fixtures/file-helpers'; - -test.describe('Critical User Flows - End-to-End', () => { - // Reset storage state for these tests to ensure we start unauthenticated - test.use({ storageState: { cookies: [], origins: [] } }); - - let consoleErrors: string[] = []; - let networkErrors: Array<{ url: string; status: number; method: string }> = []; - - test.beforeEach(async ({ page }) => { - const errorCapture = setupErrorCapture(page); - consoleErrors = errorCapture.consoleErrors; - networkErrors = errorCapture.networkErrors; - }); - - /** - * CRITICAL FLOW 1: Complete user journey from login to playlist creation - * - * This test simulates a real user scenario: - * 1. User logs in - * 2. User uploads a track - * 3. User creates a playlist - * 4. User adds the uploaded track to the playlist - */ - test('Complete user journey: Login → Upload → Create Playlist → Add Track', async ({ page }) => { - test.setTimeout(180000); // 3 minutes for complete flow - - console.log('🚀 [CRITICAL FLOW] Starting complete user journey test...'); - - // ========== STEP 1: LOGIN ========== - console.log('📝 [CRITICAL FLOW] Step 1: User login...'); - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`); - await page.waitForLoadState('domcontentloaded'); - - // Wait for form to be ready (align with auth.spec.ts) - await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => { }); - await expect(page.locator('form')).toBeVisible({ timeout: 10000 }); - await expect(page.locator('input[type="email"], input[name="email"]').first()).toBeVisible({ timeout: 5000 }); - await page.waitForTimeout(500); - - // Fill login form - await fillField( - page, - 'input[type="email"], input[name="email"]', - TEST_USERS.default.email - ); - await fillField( - page, - 'input[type="password"], input[name="password"]', - TEST_USERS.default.password - ); - - // Submit form and wait for navigation - const navigationPromise = page.waitForURL( - (url) => url.pathname === '/dashboard' || url.pathname === '/', - { timeout: 15000 } - ); - - await forceSubmitForm(page, 'form'); - await navigationPromise; - - // Verify user is authenticated - await expect(page).toHaveURL(/\/(dashboard|$)/); - await expect(page.locator('nav[role="navigation"], aside[role="navigation"]')).toBeVisible({ - timeout: 10000, - }); - - // Wait for auth state to be persisted - await page.waitForTimeout(1000); - - const isAuthenticated = await page.evaluate(() => { - try { - const authStorage = localStorage.getItem('auth-storage'); - if (authStorage) { - const parsed = JSON.parse(authStorage); - return parsed.state?.isAuthenticated === true; - } - } catch { - return false; - } - return false; - }); - expect(isAuthenticated).toBe(true); - console.log('✅ [CRITICAL FLOW] Step 1: Login successful'); - - // ========== STEP 2: UPLOAD TRACK ========== - console.log('📤 [CRITICAL FLOW] Step 2: Uploading track...'); - - // Navigate to library - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => { - console.warn('⚠️ [CRITICAL FLOW] Timeout on networkidle, continuing...'); - }); - - // Open upload modal - await openModal(page, /upload/i); - - // Select and upload file - const fileInput = page.locator('input[type="file"][accept*="audio"]').first(); - await expect(fileInput).toBeAttached({ timeout: 5000 }); - - const validMp3Buffer = createMockMP3Buffer(); - await fileInput.setInputFiles({ - name: 'critical-flow-test.mp3', - mimeType: 'audio/mpeg', - buffer: validMp3Buffer, - }); - - console.log('✅ [CRITICAL FLOW] File selected'); - await page.waitForTimeout(1000); - - // Check for rejection errors - const errorMessage = page.locator('[data-testid="upload-error"], [role="alert"]:has-text("Format")').first(); - const hasRejectionError = await errorMessage.isVisible().catch(() => false); - - if (hasRejectionError) { - const errorText = await errorMessage.textContent(); - throw new Error(`File was rejected: ${errorText}`); - } - - // Fill metadata - await fillField(page, 'input[id="title"], input[name="title"]', 'Critical Flow Test Track'); - await fillField(page, 'input[id="artist"], input[name="artist"]', 'Test Artist'); - - // Submit upload - const uploadButton = page.locator('button:has-text("Upload"), button:has-text("Envoyer"), button[type="submit"]').first(); - await expect(uploadButton).toBeVisible({ timeout: 5000 }); - await uploadButton.click(); - - // Wait for upload success - await waitForToast(page, /success|succès|uploaded|téléchargé/i, { timeout: 60000 }); - console.log('✅ [CRITICAL FLOW] Step 2: Track uploaded successfully'); - - // Close modal if still open - const closeButton = page.locator('button[aria-label*="close"], button[aria-label*="fermer"]').first(); - if (await closeButton.isVisible().catch(() => false)) { - await expect(closeButton).toBeVisible({ timeout: 3000 }); - await closeButton.click(); - await page.waitForTimeout(500); - } - - // ========== STEP 3: CREATE PLAYLIST ========== - console.log('📋 [CRITICAL FLOW] Step 3: Creating playlist...'); - - // Navigate to playlists - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/playlists`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(500); - await page.waitForURL(/\/playlists/, { timeout: 15000 }).catch(() => { }); - - // Wait for page to load - try { - await Promise.race([ - page.locator('h1:has-text("Playlist"), h1:has-text("Playlists")').first().waitFor({ state: 'visible', timeout: 10000 }), - page.locator('[data-testid="playlists-page"]').first().waitFor({ state: 'visible', timeout: 10000 }), - ]); - } catch { - console.warn('⚠️ [CRITICAL FLOW] Page load check timeout, continuing...'); - } - - // Open create playlist modal - await openModal(page, /create|créer|nouvelle/i); - - // Fill playlist form - await fillField( - page, - 'input[id="title"], input[name="title"]', - 'Critical Flow Test Playlist' - ); - await fillField( - page, - 'textarea[id="description"], textarea[name="description"]', - 'Playlist created during critical flow test' - ); - - // Submit playlist creation - const createButton = page.locator('button:has-text("Créer"), button:has-text("Create"), button[type="submit"]').first(); - await expect(createButton).toBeVisible({ timeout: 5000 }); - await createButton.click(); - - // Wait for success - await waitForToast(page, /success|succès|created|créé/i, { timeout: 15000 }); - console.log('✅ [CRITICAL FLOW] Step 3: Playlist created successfully'); - - // ========== STEP 4: VERIFY PLAYLIST EXISTS ========== - console.log('🔍 [CRITICAL FLOW] Step 4: Verifying playlist exists...'); - - // Wait for modal to close - await page.waitForTimeout(1000); - - // Check that playlist appears in the list - const playlistTitle = page.locator('text="Critical Flow Test Playlist"').first(); - await expect(playlistTitle).toBeVisible({ timeout: 10000 }); - console.log('✅ [CRITICAL FLOW] Step 4: Playlist verified in list'); - - // ========== VERIFY NO ERRORS ========== - console.log('🔍 [CRITICAL FLOW] Verifying no errors occurred...'); - - // Check for console errors - if (consoleErrors.length > 0) { - console.warn('⚠️ [CRITICAL FLOW] Console errors detected:', consoleErrors); - } - - // Check for network errors (excluding expected ones) - const criticalNetworkErrors = networkErrors.filter( - (error) => error.status >= 500 || (error.status >= 400 && !error.url.includes('favicon')) - ); - - if (criticalNetworkErrors.length > 0) { - console.warn('⚠️ [CRITICAL FLOW] Network errors detected:', criticalNetworkErrors); - } - - console.log('✅ [CRITICAL FLOW] Complete user journey test passed!'); - }); - - /** - * CRITICAL FLOW 2: Login and immediate playlist creation - * - * Tests the scenario where a user logs in and immediately creates a playlist - * without uploading anything first. - */ - test('Login → Create Playlist (no upload)', async ({ page }) => { - test.setTimeout(90000); // 90 seconds - - console.log('🚀 [CRITICAL FLOW] Starting login → playlist creation test...'); - - // Login - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`); - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => { }); - await expect(page.locator('form')).toBeVisible({ timeout: 10000 }); - await expect(page.locator('input[type="email"], input[name="email"]').first()).toBeVisible({ timeout: 5000 }); - await page.waitForTimeout(500); - - await fillField( - page, - 'input[type="email"], input[name="email"]', - TEST_USERS.default.email - ); - await fillField( - page, - 'input[type="password"], input[name="password"]', - TEST_USERS.default.password - ); - - const navigationPromise = page.waitForURL( - (url) => url.pathname === '/dashboard' || url.pathname === '/', - { timeout: 15000 } - ); - - await forceSubmitForm(page, 'form'); - await navigationPromise; - - await expect(page).toHaveURL(/\/(dashboard|$)/); - console.log('✅ [CRITICAL FLOW] Login successful'); - - // Navigate to playlists - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/playlists`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - - // Create playlist - await openModal(page, /create|créer|nouvelle/i); - - await fillField( - page, - 'input[id="title"], input[name="title"]', - 'Quick Test Playlist' - ); - - const createButton = page.locator('button:has-text("Créer"), button:has-text("Create"), button[type="submit"]').first(); - await expect(createButton).toBeVisible({ timeout: 5000 }); - await createButton.click(); - - await waitForToast(page, /success|succès|created|créé/i, { timeout: 15000 }); - console.log('✅ [CRITICAL FLOW] Playlist created successfully'); - }); - - /** - * CRITICAL FLOW 3: Login and upload only - * - * Tests the scenario where a user logs in and uploads a track - * without creating a playlist. - */ - test('Login → Upload Track (no playlist)', async ({ page }) => { - test.setTimeout(120000); // 2 minutes - - console.log('🚀 [CRITICAL FLOW] Starting login → upload test...'); - - // Login - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`); - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => { }); - await expect(page.locator('form')).toBeVisible({ timeout: 10000 }); - await expect(page.locator('input[type="email"], input[name="email"]').first()).toBeVisible({ timeout: 5000 }); - await page.waitForTimeout(500); - - await fillField( - page, - 'input[type="email"], input[name="email"]', - TEST_USERS.default.email - ); - await fillField( - page, - 'input[type="password"], input[name="password"]', - TEST_USERS.default.password - ); - - const navigationPromise = page.waitForURL( - (url) => url.pathname === '/dashboard' || url.pathname === '/', - { timeout: 15000 } - ); - - await forceSubmitForm(page, 'form'); - await navigationPromise; - - await expect(page).toHaveURL(/\/(dashboard|$)/); - console.log('✅ [CRITICAL FLOW] Login successful'); - - // Navigate to library and upload - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => { }); - - await openModal(page, /upload/i); - - const fileInput = page.locator('input[type="file"][accept*="audio"]').first(); - await expect(fileInput).toBeAttached({ timeout: 5000 }); - - const validMp3Buffer = createMockMP3Buffer(); - await fileInput.setInputFiles({ - name: 'upload-only-test.mp3', - mimeType: 'audio/mpeg', - buffer: validMp3Buffer, - }); - - await page.waitForTimeout(1000); - - await fillField(page, 'input[id="title"], input[name="title"]', 'Upload Only Test'); - await fillField(page, 'input[id="artist"], input[name="artist"]', 'Test Artist'); - - const uploadButton = page.locator('button:has-text("Upload"), button:has-text("Envoyer"), button[type="submit"]').first(); - await expect(uploadButton).toBeVisible({ timeout: 5000 }); - await uploadButton.click(); - - await waitForToast(page, /success|succès|uploaded|téléchargé/i, { timeout: 60000 }); - console.log('✅ [CRITICAL FLOW] Upload successful'); - }); -}); - diff --git a/apps/web/e2e/tests/storybook/storybook-all.spec.ts b/apps/web/e2e/tests/storybook/storybook-all.spec.ts deleted file mode 100644 index 8888ad398..000000000 --- a/apps/web/e2e/tests/storybook/storybook-all.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { test, expect } from '@playwright/test'; -import * as fs from 'fs'; -import * as path from 'path'; - -const INDEX_PATH = path.join(process.cwd(), 'storybook-static', 'index.json'); -const IFRAME_URL = (id: string) => `/iframe.html?id=${encodeURIComponent(id)}&viewMode=story`; -const NAV_TIMEOUT_MS = 20000; -const POST_LOAD_MS = 200; - -/** Story IDs from built Storybook index (available at load time). */ -function getStoryIds(): string[] { - if (!fs.existsSync(INDEX_PATH)) return []; - try { - const index = JSON.parse(fs.readFileSync(INDEX_PATH, 'utf8')); - const entries = index.entries ?? {}; - return Object.values(entries).map((e: { id?: string }) => e.id).filter(Boolean); - } catch { - return []; - } -} - -const storyIds = getStoryIds(); - -test.describe('Storybook – all stories', () => { - if (storyIds.length === 0) { - test('run build-storybook first', async () => { - test.skip(true, 'Run npm run build-storybook first. Then start a server on port 6007 (e.g. npx serve storybook-static -l 6007) or use the Playwright webServer.'); - }); - return; - } - - for (const storyId of storyIds) { - test(storyId, async ({ page }) => { - const consoleErrors: string[] = []; - const pageErrors: string[] = []; - - page.on('console', (msg) => { - const type = msg.type(); - if (type === 'error') { - const text = msg.text(); - if (!isIgnoredConsoleError(text)) consoleErrors.push(text); - } - }); - page.on('pageerror', (err) => { - pageErrors.push(err.message); - }); - - const response = await page.goto(IFRAME_URL(storyId), { - waitUntil: 'domcontentloaded', - timeout: NAV_TIMEOUT_MS, - }); - expect(response?.status()).toBe(200); - await page.waitForTimeout(POST_LOAD_MS); - - const errors = [...pageErrors, ...consoleErrors]; - expect( - errors, - errors.length ? `Story ${storyId}: ${errors.slice(0, 3).join('; ')}` : undefined - ).toHaveLength(0); - }); - } -}); - -/** Ignore known benign Storybook/addon or runtime messages. */ -function isIgnoredConsoleError(text: string): boolean { - const ignored = [ - 'ResizeObserver', - 'Warning: ReactDOM.render', - 'Download the React DevTools', - 'sb-manager', - 'sb-addons', - 'sb-common-assets', - 'mockServiceWorker', - 'Failed to load resource: net::ERR_ABORTED', - 'ChunkLoadError', - 'Loading chunk', - 'hydration', - ]; - return ignored.some((s) => text.includes(s)); -} diff --git a/apps/web/e2e/tests/ui-audit.spec.ts b/apps/web/e2e/tests/ui-audit.spec.ts deleted file mode 100644 index c705d1ed1..000000000 --- a/apps/web/e2e/tests/ui-audit.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { test, expect, Page } from '@playwright/test'; -import { loginAsUser, TEST_CONFIG } from '../utils/test-helpers'; - -/** - * UI/UX Dynamic Audit Suite - * Scans pages for: - * - Interactive elements that are too small (< 44x44px) - * - Console errors - * - Broken images/links - * - Overflow issues - */ - -const PAGES_TO_AUDIT = [ - '/dashboard', - '/library', - '/marketplace', - '/settings', - '/profile/me', - '/studio', - '/messages' // Often problematic -]; - -test.describe('Dynamic UI/UX Audit', () => { - let consoleErrors: string[] = []; - - test.beforeEach(async ({ page }) => { - // Capture console errors - page.on('console', msg => { - if (msg.type() === 'error') consoleErrors.push(`[${page.url()}] ${msg.text()}`); - }); - - // Login once - await loginAsUser(page); - }); - - for (const path of PAGES_TO_AUDIT) { - test(`Audit page: ${path}`, async ({ page }) => { - console.log(`\n🔍 Auditing ${path}...`); - await page.goto(`${TEST_CONFIG.FRONTEND_URL}${path}`); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); // Allow animations/layout to settle - - // 1. Check for Console Errors - if (consoleErrors.length > 0) { - console.log(`⚠️ Console Errors on ${path}:`, consoleErrors); - consoleErrors = []; // Reset for next check logic (though beforeeach resets too, strictly speaking this is shared scope in this loop impl if not careful, but playwright isolates tests) - } - - // 2. Interactive Element Sizing (Mobile Friendly Check) - // Find all buttons and anchors - const interactiveElements = await page.locator('button:visible, a:visible, [role="button"]:visible').all(); - - let smallTargets = 0; - for (const el of interactiveElements) { - const box = await el.boundingBox(); - if (box) { - // Check if smaller than 44px in either dimension (Apple guidelines) - // We allow smaller if it's strictly an icon-only button inside a toolbar, but warn generally - if (box.width < 32 || box.height < 32) { // 32 is lenient, 44 is ideal - const html = await el.evaluate(e => e.outerHTML); - // console.log(`⚠️ Small touch target (${Math.round(box.width)}x${Math.round(box.height)}):`, html.substring(0, 100)); - smallTargets++; - } - } - } - if (smallTargets > 0) { - console.log(`⚠️ Found ${smallTargets} interactive elements smaller than 32x32px on ${path}`); - } - - // 3. Overflow Detection - const hasHorizontalScroll = await page.evaluate(() => { - return document.body.scrollWidth > window.innerWidth; - }); - if (hasHorizontalScroll) { - console.log(`🔴 Layout Issue: Horizontal scroll detected on ${path}`); - } - - // 4. Broken Image Detection - const images = await page.locator('img').all(); - for (const img of images) { - const isBroken = await img.evaluate((i: HTMLImageElement) => { - return !i.complete || i.naturalWidth === 0; - }); - if (isBroken) { - const src = await img.getAttribute('src'); - console.log(`🔴 Broken Image: ${src}`); - } - } - - console.log(`✅ Audit complete for ${path}`); - }); - } -}); diff --git a/apps/web/e2e/tests/upload.spec.ts b/apps/web/e2e/tests/upload.spec.ts deleted file mode 100644 index 3791fee4e..000000000 --- a/apps/web/e2e/tests/upload.spec.ts +++ /dev/null @@ -1,482 +0,0 @@ -import { test, expect, type Page } from '@playwright/test'; -import { - TEST_CONFIG, - loginAsUser, - forceSubmitForm, - openModal, - fillField, - setupErrorCapture, - waitForToast, -} from '../utils/test-helpers'; -import { createLargeMockMP3Buffer } from '../fixtures/file-helpers'; - -/** - * Chunked Upload E2E Test (TASK-006) - * - * Teste le mécanisme d'upload par morceaux (chunking) pour les gros fichiers. - * - * Scénario : - * 1. Login - * 2. Upload d'un fichier > 10 MB (déclenchement du chunking) - * 3. Vérification des appels réseau : /tracks/initiate, /tracks/chunk, /tracks/complete - * 4. Vérification de la progression (progress bar) - * 5. Vérification du succès final - * - * Référence : INTEGRATION_REFERENCE.md Section 2 (API Surface Coverage) - * - POST /api/v1/tracks/initiate - * - POST /api/v1/tracks/chunk - * - POST /api/v1/tracks/complete - */ - -test.describe('Chunked Upload Flow', () => { - let consoleErrors: string[] = []; - let networkErrors: Array<{ url: string; status: number; method: string }> = []; - - // Augmenter le timeout global pour ces tests (uploads longs) - test.setTimeout(180000); // 3 minutes - - test.beforeEach(async ({ page }) => { - const errorCapture = setupErrorCapture(page); - consoleErrors = errorCapture.consoleErrors; - networkErrors = errorCapture.networkErrors; - }); - - /** - * TEST 1: Upload d'un fichier de 15 MB avec chunking - */ - test('should upload large file (15 MB) using chunked upload', async ({ page }) => { - console.log('🧪 [CHUNKED UPLOAD] Running: Large file upload with chunking'); - - // Tracker les appels API pour le chunking - const apiCalls = { - initiate: false, - chunks: [] as number[], - complete: false, - }; - - // Intercepter les appels API - page.on('request', (request) => { - const url = request.url(); - const method = request.method(); - - if (method === 'POST') { - if (url.includes('/tracks/initiate')) { - apiCalls.initiate = true; - console.log('✅ [CHUNKED UPLOAD] API Call: POST /tracks/initiate'); - } else if (url.includes('/tracks/chunk')) { - apiCalls.chunks.push(apiCalls.chunks.length + 1); - console.log(`✅ [CHUNKED UPLOAD] API Call: POST /tracks/chunk (chunk #${apiCalls.chunks.length})`); - } else if (url.includes('/tracks/complete')) { - apiCalls.complete = true; - console.log('✅ [CHUNKED UPLOAD] API Call: POST /tracks/complete'); - } - } - }); - - // ========== ÉTAPE 1: LOGIN ========== - console.log('🔍 [CHUNKED UPLOAD] Step 1: Login'); - await loginAsUser(page); - - // Attendre que l'auth soit complètement stabilisée - await page.waitForTimeout(1000); - - // ========== ÉTAPE 2: NAVIGATION VERS /library ========== - console.log('🔍 [CHUNKED UPLOAD] Step 2: Navigate to /library'); - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('domcontentloaded'); - - // Attendre que la page soit complètement chargée - await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => { - console.warn('⚠️ [CHUNKED UPLOAD] Timeout on networkidle, continuing...'); - }); - - // ========== ÉTAPE 3: OUVRIR LA MODAL D'UPLOAD ========== - console.log('🔍 [CHUNKED UPLOAD] Step 3: Open upload modal'); - await openModal(page, /upload/i); - - // ========== ÉTAPE 4: SÉLECTIONNER UN GROS FICHIER (15 MB) ========== - console.log('🔍 [CHUNKED UPLOAD] Step 4: Select large file (15 MB)'); - - const largeBuffer = createLargeMockMP3Buffer(12); // 12 MB (suffisant pour déclencher chunking > 10MB) - const fileInput = page.locator('input[type="file"][accept*="audio"]').first(); - - await expect(fileInput).toBeAttached({ timeout: 5000 }); - - await fileInput.setInputFiles({ - name: 'large-track.mp3', - mimeType: 'audio/mpeg', - buffer: largeBuffer, - }); - - console.log(`✅ [CHUNKED UPLOAD] Large file selected: ${(largeBuffer.length / 1024 / 1024).toFixed(2)} MB`); - - // Attendre que le fichier soit traité - await page.waitForTimeout(1000); - - // Vérifier que le fichier est affiché - const fileDisplay = page.locator('[data-testid="upload-file-display"]').first(); - await expect(fileDisplay).toBeVisible({ timeout: 5000 }); - - // ========== ÉTAPE 5: REMPLIR LES MÉTADONNÉES ========== - console.log('🔍 [CHUNKED UPLOAD] Step 5: Fill metadata'); - - await fillField(page, 'input[id="title"]', 'Large Track Test'); - await fillField(page, 'input[id="artist"]', 'QA Bot'); - - // ========== ÉTAPE 6: LANCER L'UPLOAD ========== - console.log('🔍 [CHUNKED UPLOAD] Step 6: Start upload'); - - // Attendre les appels API - const initiatePromise = page.waitForResponse( - (response) => - response.url().includes('/tracks/initiate') && - response.request().method() === 'POST' && - response.status() < 500, - { timeout: TEST_CONFIG.UPLOAD_TIMEOUT } - ); - - // Soumettre le formulaire - await forceSubmitForm(page, 'form#upload-track-form'); - - // 🔴 FIX: Attendre la réponse /tracks/complete APRÈS le submit (optionnel - peut être direct upload) - // Ne pas créer la promesse avant le submit pour éviter que le timeout commence trop tôt - // Utiliser un timeout court (10s) car si c'est un direct upload, il n'y aura pas de /complete - const completePromise = page - .waitForResponse( - (response) => - response.url().includes('/tracks/complete') && - response.request().method() === 'POST' && - response.status() < 500, - { timeout: 10000 } // Timeout court car peut être direct upload - ) - .catch(() => { - // Si timeout, c'est probablement un direct upload (pas de /complete) - return null; - }); - - // ========== ÉTAPE 7: VÉRIFIER LES APPELS API ========== - console.log('🔍 [CHUNKED UPLOAD] Step 7: Verify API calls'); - - // Attendre l'appel initiate - try { - const initiateResponse = await initiatePromise; - const initiateStatus = initiateResponse.status(); - - console.log(`📡 [CHUNKED UPLOAD] Initiate response: ${initiateStatus}`); - - if (initiateStatus === 200 || initiateStatus === 201) { - const initiateBody = await initiateResponse.json().catch(() => ({})); - console.log(`✅ [CHUNKED UPLOAD] Upload initiated:`, initiateBody); - } else { - console.warn(`⚠️ [CHUNKED UPLOAD] Initiate failed with status ${initiateStatus}`); - } - } catch (error) { - console.warn('⚠️ [CHUNKED UPLOAD] Initiate call not detected - may be using direct upload'); - } - - // Attendre quelques chunks - await page.waitForTimeout(2000); - - // Vérifier la progression - const progressBar = page.locator('[role="progressbar"], text=/%/'); - const hasProgressBar = await progressBar.isVisible().catch(() => false); - - if (hasProgressBar) { - console.log('✅ [CHUNKED UPLOAD] Progress bar visible'); - - // Attendre que la progression augmente - await page.waitForTimeout(2000); - } - - // Attendre l'appel complete - // Attendre l'appel complete (optionnel - peut être null si direct upload) - const completeResponse = await completePromise; - if (completeResponse) { - try { - const completeStatus = completeResponse.status(); - - console.log(`📡 [CHUNKED UPLOAD] Complete response: ${completeStatus}`); - - if (completeStatus === 200 || completeStatus === 201 || completeStatus === 202) { - const completeBody = await completeResponse.json().catch(() => ({})); - console.log(`✅ [CHUNKED UPLOAD] Upload completed:`, completeBody); - } else { - console.warn(`⚠️ [CHUNKED UPLOAD] Complete failed with status ${completeStatus}`); - } - } catch (error) { - console.warn('⚠️ [CHUNKED UPLOAD] Error processing complete response'); - } - } else { - console.warn('⚠️ [CHUNKED UPLOAD] Complete call not detected - may be using direct upload'); - } - - // ========== ÉTAPE 8: VÉRIFIER LE SUCCÈS ========== - console.log('🔍 [CHUNKED UPLOAD] Step 8: Verify success'); - - // Attendre le message de succès - Plus flexible: accepter soit le toast, soit la fermeture de la modale - // The frontend may show a toast OR just close the modal after 1.5s - let uploadCompleted = false; - - try { - // Try to wait for success toast (timeout: 5s) - const successMessage = page.locator('[role="alert"]').filter({ hasText: /succès|success|uploadé/i }).first(); - await expect(successMessage).toBeVisible({ timeout: 5000 }); - console.log('✅ [CHUNKED UPLOAD] Success message displayed'); - uploadCompleted = true; - } catch (error) { - console.warn('⚠️ [CHUNKED UPLOAD] No success message, checking modal closure'); - } - - // Si pas de toast, attendre que la modale se ferme (indique que l'upload est terminé) - // The modal closes after 1.5s on success (see UploadModal.tsx) - if (!uploadCompleted) { - try { - // Vérifier d'abord que la page est toujours active - if (page.isClosed()) { - throw new Error('Page was closed during upload'); - } - - await page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 60000 }); - console.log('✅ [CHUNKED UPLOAD] Modal closed (upload likely succeeded)'); - uploadCompleted = true; - } catch (modalError) { - // Si la modale ne se ferme pas non plus, vérifier que la page est toujours active - if (page.isClosed()) { - throw new Error('Page was closed during upload'); - } - // Le backend a confirmé l'upload (on a vu les logs), donc on considère que c'est un succès - // même si l'UI n'a pas réagi assez vite - console.warn('⚠️ [CHUNKED UPLOAD] Modal did not close, but backend confirmed upload (check logs)'); - uploadCompleted = true; // Backend confirmed, so consider it success - } - } - - // ========== ÉTAPE 9: VÉRIFIER LES APPELS API ENREGISTRÉS ========== - console.log('\n📊 [CHUNKED UPLOAD] === API Calls Summary ==='); - console.log(`Initiate called: ${apiCalls.initiate ? '✅' : '❌'}`); - console.log(`Chunks uploaded: ${apiCalls.chunks.length} ${apiCalls.chunks.length > 0 ? '✅' : '⚠️'}`); - console.log(`Complete called: ${apiCalls.complete ? '✅' : '❌'}`); - - // Assertions sur les appels API - // Note: Si l'implémentation frontend n'utilise pas encore le chunking, - // ces assertions peuvent échouer. C'est normal et indique que TASK-006 n'est pas encore implémenté. - if (apiCalls.initiate || apiCalls.chunks.length > 0 || apiCalls.complete) { - console.log('✅ [CHUNKED UPLOAD] Chunked upload API detected'); - - // Si le chunking est détecté, vérifier la séquence complète - expect(apiCalls.initiate).toBeTruthy(); - expect(apiCalls.chunks.length).toBeGreaterThan(0); - expect(apiCalls.complete).toBeTruthy(); - } else { - console.warn('⚠️ [CHUNKED UPLOAD] Chunked upload API not detected - using direct upload'); - console.warn('⚠️ [CHUNKED UPLOAD] TASK-006 may not be implemented yet'); - - // Si pas de chunking, au moins vérifier qu'un upload normal a eu lieu - const directUploadCall = networkErrors.find( - (err) => err.url.includes('/tracks') && err.method === 'POST' - ); - - if (!directUploadCall) { - console.log('ℹ️ [CHUNKED UPLOAD] Using direct upload method (POST /tracks)'); - } - } - - // ========== ÉTAPE 10: VÉRIFIER QUE LA PISTE APPARAÎT ========== - console.log('🔍 [CHUNKED UPLOAD] Step 10: Verify track appears in library'); - - // Fermer la modal si encore ouverte - const modalStillOpen = await page.locator('[role="dialog"]').isVisible().catch(() => false); - if (modalStillOpen) { - const closeButton = page.locator('button:has-text("Fermer"), button:has-text("Close")').first(); - if (await closeButton.isVisible().catch(() => false)) { - await closeButton.click(); - } - } - - // Recharger la page - await page.reload({ waitUntil: 'networkidle', timeout: 30000 }); - - // Vérifier que la piste apparaît - const trackList = page.locator('table, [role="table"], .track-list').first(); - await expect(trackList).toBeVisible({ timeout: 10000 }); - - const newTrack = page.locator('text=Large Track Test').first(); - - try { - await expect(newTrack).toBeVisible({ timeout: 10000 }); - console.log('✅ [CHUNKED UPLOAD] Track appears in library'); - } catch (error) { - console.warn('⚠️ [CHUNKED UPLOAD] Track not visible yet (may still be processing)'); - } - }); - - /** - * TEST 2: Upload d'un fichier de 25 MB (test de performance) - */ - test('should handle very large file (25 MB) with chunking', async ({ page }) => { - console.log('🧪 [CHUNKED UPLOAD] Running: Very large file upload (25 MB)'); - - // Tracker le nombre de chunks - let chunkCount = 0; - - page.on('request', (request) => { - if (request.method() === 'POST' && request.url().includes('/tracks/chunk')) { - chunkCount++; - } - }); - - // Login - await loginAsUser(page); - - // Attendre stabilisation - await page.waitForTimeout(1000); - - // Navigation - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => { }); - - // Ouvrir modal - await openModal(page, /upload/i); - - // Créer un fichier de 25 MB - const veryLargeBuffer = createLargeMockMP3Buffer(20); - const fileInput = page.locator('input[type="file"][accept*="audio"]').first(); - - await fileInput.setInputFiles({ - name: 'very-large-track.mp3', - mimeType: 'audio/mpeg', - buffer: veryLargeBuffer, - }); - - console.log(`✅ [CHUNKED UPLOAD] Very large file selected: ${(veryLargeBuffer.length / 1024 / 1024).toFixed(2)} MB`); - - await page.waitForTimeout(1000); - - // Remplir métadonnées - await fillField(page, 'input[id="title"]', 'Very Large Track'); - await fillField(page, 'input[id="artist"]', 'Performance Test'); - - // Lancer l'upload - await forceSubmitForm(page, 'form#upload-track-form'); - - // Attendre quelques secondes pour voir les chunks - await page.waitForTimeout(5000); - - // Vérifier la progression - const progressBar = page.locator('[role="progressbar"], text=/%/'); - const hasProgressBar = await progressBar.isVisible().catch(() => false); - - if (hasProgressBar) { - console.log('✅ [CHUNKED UPLOAD] Progress bar tracking upload'); - } - - // Attendre le succès ou la fermeture de la modal - try { - await Promise.race([ - page.locator('[role="alert"]').filter({ hasText: /succès|success/i }).first().waitFor({ state: 'visible', timeout: 90000 }), - page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 90000 }), - ]); - console.log('✅ [CHUNKED UPLOAD] Very large file uploaded successfully'); - } catch (error) { - console.warn('⚠️ [CHUNKED UPLOAD] Upload timeout (90s) - file may still be processing'); - } - - // Log chunk count - if (chunkCount > 0) { - console.log(`📊 [CHUNKED UPLOAD] Total chunks uploaded: ${chunkCount}`); - - // Pour un fichier de 25 MB avec des chunks de ~5 MB, on attend ~5 chunks - expect(chunkCount).toBeGreaterThanOrEqual(3); - } else { - console.warn('⚠️ [CHUNKED UPLOAD] No chunks detected - direct upload used'); - } - }); - - /** - * TEST 3: Vérifier que les petits fichiers n'utilisent PAS le chunking - */ - test('should use direct upload for small files (< 10 MB)', async ({ page }) => { - console.log('🧪 [CHUNKED UPLOAD] Running: Small file direct upload'); - - let chunkCallDetected = false; - - page.on('request', (request) => { - if (request.method() === 'POST' && request.url().includes('/tracks/chunk')) { - chunkCallDetected = true; - } - }); - - // Login - await loginAsUser(page); - - // Attendre stabilisation - await page.waitForTimeout(1000); - - // Navigation - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => { }); - - // Ouvrir modal - await openModal(page, /upload/i); - - // Créer un petit fichier (5 MB - sous le seuil de chunking) - const smallBuffer = createLargeMockMP3Buffer(5); - const fileInput = page.locator('input[type="file"][accept*="audio"]').first(); - - await fileInput.setInputFiles({ - name: 'small-track.mp3', - mimeType: 'audio/mpeg', - buffer: smallBuffer, - }); - - console.log(`✅ [CHUNKED UPLOAD] Small file selected: ${(smallBuffer.length / 1024 / 1024).toFixed(2)} MB`); - - await page.waitForTimeout(1000); - - // Remplir métadonnées - await fillField(page, 'input[id="title"]', 'Small Track'); - await fillField(page, 'input[id="artist"]', 'Direct Upload Test'); - - // Lancer l'upload - await forceSubmitForm(page, 'form#upload-track-form'); - - // Attendre le succès - try { - await page.locator('[role="alert"]').filter({ hasText: /succès|success/i }).first().waitFor({ state: 'visible', timeout: 30000 }); - console.log('✅ [CHUNKED UPLOAD] Small file uploaded successfully'); - } catch (error) { - console.warn('⚠️ [CHUNKED UPLOAD] Upload timeout for small file'); - } - - // Vérifier qu'aucun chunk n'a été uploadé - expect(chunkCallDetected).toBeFalsy(); - console.log('✅ [CHUNKED UPLOAD] Direct upload used (no chunking) as expected'); - }); - - /** - * FINAL VERIFICATIONS - */ - test.afterEach(async ({}, testInfo) => { - console.log('\n📊 [CHUNKED UPLOAD] === Final Verifications ==='); - - if (consoleErrors.length > 0) { - console.log(`🔴 [CHUNKED UPLOAD] Console errors (${consoleErrors.length}):`); - consoleErrors.forEach((error) => { - console.log(` - ${error}`); - }); - } else { - console.log('✅ [CHUNKED UPLOAD] No console errors'); - } - - if (networkErrors.length > 0) { - console.log(`🔴 [CHUNKED UPLOAD] Network errors (${networkErrors.length}):`); - networkErrors.forEach((error) => { - console.log(` - ${error.method} ${error.url}: ${error.status}`); - }); - } else { - console.log('✅ [CHUNKED UPLOAD] No network errors'); - } - }); -}); diff --git a/apps/web/e2e/tests/visual/__snapshots__/404-page-chromium-desktop.png b/apps/web/e2e/tests/visual/__snapshots__/404-page-chromium-desktop.png deleted file mode 100644 index e3c21313c..000000000 Binary files a/apps/web/e2e/tests/visual/__snapshots__/404-page-chromium-desktop.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/__snapshots__/dashboard-full-chromium-desktop.png b/apps/web/e2e/tests/visual/__snapshots__/dashboard-full-chromium-desktop.png deleted file mode 100644 index 5dd5bf168..000000000 Binary files a/apps/web/e2e/tests/visual/__snapshots__/dashboard-full-chromium-desktop.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/__snapshots__/dashboard-header-chromium-desktop.png b/apps/web/e2e/tests/visual/__snapshots__/dashboard-header-chromium-desktop.png deleted file mode 100644 index 470ff72d2..000000000 Binary files a/apps/web/e2e/tests/visual/__snapshots__/dashboard-header-chromium-desktop.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/__snapshots__/dashboard-mobile-chromium-desktop.png b/apps/web/e2e/tests/visual/__snapshots__/dashboard-mobile-chromium-desktop.png deleted file mode 100644 index 0d4526fb2..000000000 Binary files a/apps/web/e2e/tests/visual/__snapshots__/dashboard-mobile-chromium-desktop.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/__snapshots__/dashboard-tablet-chromium-desktop.png b/apps/web/e2e/tests/visual/__snapshots__/dashboard-tablet-chromium-desktop.png deleted file mode 100644 index ab8ffdee7..000000000 Binary files a/apps/web/e2e/tests/visual/__snapshots__/dashboard-tablet-chromium-desktop.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/__snapshots__/login-page-chromium-desktop.png b/apps/web/e2e/tests/visual/__snapshots__/login-page-chromium-desktop.png deleted file mode 100644 index 5dd5bf168..000000000 Binary files a/apps/web/e2e/tests/visual/__snapshots__/login-page-chromium-desktop.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/__snapshots__/playlists-page-chromium-desktop.png b/apps/web/e2e/tests/visual/__snapshots__/playlists-page-chromium-desktop.png deleted file mode 100644 index 5dd5bf168..000000000 Binary files a/apps/web/e2e/tests/visual/__snapshots__/playlists-page-chromium-desktop.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/__snapshots__/register-page-chromium-desktop.png b/apps/web/e2e/tests/visual/__snapshots__/register-page-chromium-desktop.png deleted file mode 100644 index 8eeb9d23b..000000000 Binary files a/apps/web/e2e/tests/visual/__snapshots__/register-page-chromium-desktop.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/sidebar.spec.ts b/apps/web/e2e/tests/visual/sidebar.spec.ts deleted file mode 100644 index b659cfbed..000000000 --- a/apps/web/e2e/tests/visual/sidebar.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('Visual Regression - Sidebar', () => { - test('should match design tokens implementation', async ({ page }) => { - // Visit the Storybook iframe directly for isolation - // ID derived from title: 'App/Layouts/Sidebar' -> 'app-layouts-sidebar' - await page.goto('http://localhost:6006/iframe.html?id=app-layouts-sidebar--default&viewMode=story'); - - // Wait for component to be stable - await page.waitForSelector('aside'); - - // Take snapshot of the entire customized layout (Side bar is fixed position in the story) - // We target the aside element directly for the component snapshot - const sidebar = page.locator('aside'); - await expect(sidebar).toBeVisible(); - - // Initial State Snapshot - await expect(sidebar).toHaveScreenshot('sidebar-default.png'); - - // Interaction Test: Hover over an item - const studioItem = page.getByText('Cloud Files'); - await studioItem.hover(); - - // Allow animation to complete (transition duration-200) - await page.waitForTimeout(300); - - // Hover State Snapshot - await expect(sidebar).toHaveScreenshot('sidebar-hover-item.png'); - }); -}); diff --git a/apps/web/e2e/tests/visual/visual-regression.spec.ts b/apps/web/e2e/tests/visual/visual-regression.spec.ts deleted file mode 100644 index 2c7c99cea..000000000 --- a/apps/web/e2e/tests/visual/visual-regression.spec.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { TEST_CONFIG } from '../../utils/test-helpers'; - -/** Pixel-perfect visual regression: strict by default. Relax in CI if needed via VISUAL_MAX_DIFF_PIXELS. */ -const MAX_DIFF_PIXELS = process.env.VISUAL_MAX_DIFF_PIXELS ? parseInt(process.env.VISUAL_MAX_DIFF_PIXELS, 10) : 0; -const ANIMATION_SETTLE_MS = 800; - -async function ensureDarkTheme(page: import('@playwright/test').Page) { - await page.evaluate(() => { - document.documentElement.classList.add('dark'); - document.documentElement.setAttribute('data-theme', 'dark'); - }); - await page.waitForTimeout(100); -} - -test.describe('Visual regression (pixel-perfect)', () => { - test.beforeEach(async ({ page }) => { - await page.emulateMedia({ reducedMotion: 'reduce' }); - }); - - test.describe('Auth pages (no storage)', () => { - test.use({ storageState: { cookies: [], origins: [] } }); - - test('login page', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`); - await page.waitForLoadState('networkidle'); - await page.waitForSelector('form', { timeout: 10000 }); - await ensureDarkTheme(page); - await page.waitForTimeout(ANIMATION_SETTLE_MS); - - await expect(page).toHaveScreenshot('login-page.png', { - fullPage: true, - maxDiffPixels: MAX_DIFF_PIXELS, - }); - }); - - test('register page', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/register`); - await page.waitForLoadState('networkidle'); - await page.waitForSelector('form, [role="form"], input[type="email"]', { timeout: 15000 }).catch(() => {}); - await ensureDarkTheme(page); - await page.waitForTimeout(ANIMATION_SETTLE_MS); - - await expect(page).toHaveScreenshot('register-page.png', { - fullPage: true, - maxDiffPixels: Math.max(MAX_DIFF_PIXELS, 10), // allow minor font/subpixel variance - }); - }); - }); - - test.describe('App shell (authenticated)', () => { - test('dashboard full page', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - await page.waitForSelector('main, [role="main"]', { timeout: 15000 }); - await ensureDarkTheme(page); - await page.waitForTimeout(ANIMATION_SETTLE_MS); - - await expect(page).toHaveScreenshot('dashboard-full.png', { - fullPage: true, - maxDiffPixels: MAX_DIFF_PIXELS, - }); - }); - - test('dashboard header only', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - const header = page.locator('header').first(); - await header.waitFor({ timeout: 10000 }); - await ensureDarkTheme(page); - await page.waitForTimeout(ANIMATION_SETTLE_MS); - - await expect(header).toHaveScreenshot('dashboard-header.png', { - maxDiffPixels: MAX_DIFF_PIXELS, - }); - }); - - test('dashboard sidebar only', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - const sidebar = page.locator('aside').first(); - const visible = await sidebar.waitFor({ state: 'visible', timeout: 12000 }).then(() => true).catch(() => false); - if (!visible) { - test.skip(true, 'Sidebar not visible (e.g. not authenticated or mobile layout)'); - return; - } - await ensureDarkTheme(page); - await page.waitForTimeout(ANIMATION_SETTLE_MS); - - await expect(sidebar).toHaveScreenshot('dashboard-sidebar.png', { - maxDiffPixels: MAX_DIFF_PIXELS, - }); - }); - - test('global player bar', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - await ensureDarkTheme(page); - await page.waitForTimeout(ANIMATION_SETTLE_MS); - - const playerBar = page.locator('div.fixed.bottom-0.left-0.right-0').first(); - await playerBar.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {}); - if ((await playerBar.count()) === 0) { - test.skip(); - return; - } - await expect(playerBar).toHaveScreenshot('player-bar.png', { - maxDiffPixels: MAX_DIFF_PIXELS, - }); - }); - }); - - test.describe('Key routes', () => { - test('playlists page', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/playlists`); - await page.waitForLoadState('networkidle'); - await page.waitForSelector('main, [role="main"]', { timeout: 10000 }).catch(() => {}); - await ensureDarkTheme(page); - await page.waitForTimeout(ANIMATION_SETTLE_MS); - - await expect(page).toHaveScreenshot('playlists-page.png', { - fullPage: true, - maxDiffPixels: MAX_DIFF_PIXELS, - }); - }); - - test('404 page', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/non-existent-route-404`); - await page.waitForLoadState('networkidle'); - await ensureDarkTheme(page); - await page.waitForTimeout(ANIMATION_SETTLE_MS); - - await expect(page).toHaveScreenshot('404-page.png', { - fullPage: true, - maxDiffPixels: MAX_DIFF_PIXELS, - }); - }); - }); - - test.describe('Viewports', () => { - test('dashboard mobile 375x667', async ({ page }) => { - await page.setViewportSize({ width: 375, height: 667 }); - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(ANIMATION_SETTLE_MS); - await ensureDarkTheme(page); - - await expect(page).toHaveScreenshot('dashboard-mobile.png', { - fullPage: true, - maxDiffPixels: MAX_DIFF_PIXELS, - }); - }); - - test('dashboard tablet 768x1024', async ({ page }) => { - await page.setViewportSize({ width: 768, height: 1024 }); - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(ANIMATION_SETTLE_MS); - await ensureDarkTheme(page); - - await expect(page).toHaveScreenshot('dashboard-tablet.png', { - fullPage: true, - maxDiffPixels: MAX_DIFF_PIXELS, - }); - }); - }); -}); diff --git a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/404-page-chromium-linux.png b/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/404-page-chromium-linux.png deleted file mode 100644 index afd825d96..000000000 Binary files a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/404-page-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/404-page-msedge-linux.png b/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/404-page-msedge-linux.png deleted file mode 100644 index 20ced2737..000000000 Binary files a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/404-page-msedge-linux.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-full-chromium-linux.png b/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-full-chromium-linux.png deleted file mode 100644 index 99a9a3f0c..000000000 Binary files a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-full-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-full-msedge-linux.png b/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-full-msedge-linux.png deleted file mode 100644 index 1d1000a43..000000000 Binary files a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-full-msedge-linux.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-header-chromium-linux.png b/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-header-chromium-linux.png deleted file mode 100644 index b17de8278..000000000 Binary files a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-header-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-header-msedge-linux.png b/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-header-msedge-linux.png deleted file mode 100644 index bd2c55de2..000000000 Binary files a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-header-msedge-linux.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-mobile-chromium-linux.png b/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-mobile-chromium-linux.png deleted file mode 100644 index f8bb8f4b1..000000000 Binary files a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-mobile-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-mobile-msedge-linux.png b/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-mobile-msedge-linux.png deleted file mode 100644 index 6ad13738a..000000000 Binary files a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-mobile-msedge-linux.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-sidebar-chromium-linux.png b/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-sidebar-chromium-linux.png deleted file mode 100644 index 9dfe8ada6..000000000 Binary files a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-sidebar-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-sidebar-msedge-linux.png b/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-sidebar-msedge-linux.png deleted file mode 100644 index 9dfe8ada6..000000000 Binary files a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-sidebar-msedge-linux.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-tablet-chromium-linux.png b/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-tablet-chromium-linux.png deleted file mode 100644 index d86c1bb0a..000000000 Binary files a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-tablet-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-tablet-msedge-linux.png b/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-tablet-msedge-linux.png deleted file mode 100644 index 2b9f60c3b..000000000 Binary files a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/dashboard-tablet-msedge-linux.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/playlists-page-chromium-linux.png b/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/playlists-page-chromium-linux.png deleted file mode 100644 index cc4f3e6d3..000000000 Binary files a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/playlists-page-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/playlists-page-msedge-linux.png b/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/playlists-page-msedge-linux.png deleted file mode 100644 index 9f0206011..000000000 Binary files a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/playlists-page-msedge-linux.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/register-page-chromium-linux.png b/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/register-page-chromium-linux.png deleted file mode 100644 index 2563b6696..000000000 Binary files a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/register-page-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/register-page-msedge-linux.png b/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/register-page-msedge-linux.png deleted file mode 100644 index 80fe019a7..000000000 Binary files a/apps/web/e2e/tests/visual/visual-regression.spec.ts-snapshots/register-page-msedge-linux.png and /dev/null differ diff --git a/apps/web/e2e/track_lifecycle.spec.ts b/apps/web/e2e/track_lifecycle.spec.ts deleted file mode 100644 index 1f5e059b4..000000000 --- a/apps/web/e2e/track_lifecycle.spec.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { test, expect, type Page } from '@playwright/test'; -import { - TEST_CONFIG, - loginAsUser, - openModal, - fillField, - forceSubmitForm, - waitForToast, - setupErrorCapture, -} from './utils/test-helpers'; -import { createMockMP3Buffer } from './fixtures/file-helpers'; - -/** - * Track Lifecycle E2E Test (CRUD) - * - * Scénario : - * 1. Login - * 2. Upload Riche (MP3 + Métadonnées complètes) - * 3. Vérification Métadonnées (My Hit Song, Synthwave, AI Star) - * 4. Suppression - * 5. Vérification persistance (Reload) - */ - -test.describe('Track Lifecycle - CRUD', () => { - let consoleErrors: string[] = []; - let networkErrors: Array<{ url: string; status: number; method: string }> = []; - - // Augmenter le timeout global pour ces tests - test.setTimeout(90000); // 90 secondes - - test.beforeEach(async ({ page }) => { - const errorCapture = setupErrorCapture(page); - consoleErrors = errorCapture.consoleErrors; - networkErrors = errorCapture.networkErrors; - }); - - test('Complete Track Lifecycle: Upload -> Verify -> Delete', async ({ page }) => { - // 1. Login - console.log('🔍 [LIFECYCLE] Step 1: Login'); - await loginAsUser(page); - - // Attendre que l'auth soit complètement stabilisée - await page.waitForTimeout(1000); - - // 2. Upload Riche - console.log('🔍 [LIFECYCLE] Step 2: Rich Upload'); - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); - await page.waitForLoadState('domcontentloaded'); - - // Attendre que la page soit complètement chargée - await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => { - console.warn('⚠️ [LIFECYCLE] Timeout on networkidle, continuing...'); - }); - - // Open Modal - await openModal(page, /upload/i); - - // Prepare File - const validMp3Buffer = createMockMP3Buffer(); - - // Attach File - const fileInput = page.locator('input[type="file"][accept*="audio"]'); - await fileInput.setInputFiles({ - name: 'lifecycle-test.mp3', - mimeType: 'audio/mpeg', - buffer: validMp3Buffer, - }); - - // Fill Metadata - console.log('🔍 [LIFECYCLE] Step 2b: Filling Metadata'); - await fillField(page, '#title', 'My Hit Song'); - await fillField(page, '#artist', 'AI Star'); - - // Handle Genre - const genreInput = page.locator('#genre, input[name="genre"]').first(); - const isGenreVisible = await genreInput.isVisible().catch(() => false); - - if (isGenreVisible) { - await genreInput.fill('Synthwave'); - } else { - const genreLabelInput = page.getByLabel(/Genre/i).first(); - const isGenreLabelVisible = await genreLabelInput.isVisible().catch(() => false); - if (isGenreLabelVisible) { - await genreLabelInput.fill('Synthwave'); - } - } - - // Submit - await forceSubmitForm(page, 'form#upload-track-form'); - - // Wait for Success - More flexible: accept either toast OR modal closure - // The frontend may show a toast OR just close the modal after 1.5s - let uploadCompleted = false; - - try { - // Try to wait for success toast (timeout: 5s) - await waitForToast(page, 'success', 5000); - console.log('✅ [LIFECYCLE] Success toast shown'); - uploadCompleted = true; - } catch (e) { - console.log('⚠️ [LIFECYCLE] No success toast, checking if upload completed via modal closure...'); - } - - // If no toast, wait for modal to close (indicates upload completed) - // The modal closes after 1.5s on success (see UploadModal.tsx) - if (!uploadCompleted) { - try { - // Vérifier d'abord que la page est toujours active - if (page.isClosed()) { - // Backend a confirmé l'upload (logs montrent succès), donc on considère que c'est un succès - console.warn('⚠️ [LIFECYCLE] Page was closed, but backend confirmed upload (check logs)'); - uploadCompleted = true; - } else { - await page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 20000 }); - console.log('✅ [LIFECYCLE] Upload completed (modal closed)'); - uploadCompleted = true; - } - } catch (modalError) { - // Si la modale ne se ferme pas, vérifier que la page est toujours active - if (page.isClosed()) { - // Backend a confirmé l'upload (logs montrent succès), donc on considère que c'est un succès - console.warn('⚠️ [LIFECYCLE] Page was closed, but backend confirmed upload (check logs)'); - uploadCompleted = true; - } else { - // Le backend a confirmé l'upload (logs montrent succès), donc on considère que c'est un succès - console.warn('⚠️ [LIFECYCLE] Modal did not close, but backend confirmed upload (check logs)'); - uploadCompleted = true; // Backend confirmed, so consider it success - } - } - } - - // Close modal if not auto-closed - const modalStillOpen = await page.locator('[role="dialog"]').isVisible().catch(() => false); - if (modalStillOpen) { - const closeButton = page.locator('button[aria-label="Close"], button:has-text("Fermer")').first(); - if (await closeButton.isVisible().catch(() => false)) { - await closeButton.click(); - } - } - - // 3. Verification Metadata - console.log('🔍 [LIFECYCLE] Step 3: Verify Metadata'); - - // CORRECTION : Recharger la page pour être sûr que la liste est à jour - // S'assurer qu'on est sur la page library avant de recharger - console.log('🔄 [LIFECYCLE] Reloading page to fetch new tracks...'); - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => { - console.warn('⚠️ [LIFECYCLE] Navigation timeout, trying reload instead...'); - return page.reload({ waitUntil: 'networkidle', timeout: 30000 }); - }); - await page.waitForLoadState('networkidle'); - - // Attendre que la table soit visible avec un timeout plus long (optionnel) - const tableVisible = await page.locator('table, [role="table"]').isVisible({ timeout: 15000 }).catch(() => false); - if (!tableVisible) { - console.warn('⚠️ [LIFECYCLE] Table not visible, but backend confirmed upload (check logs)'); - } - - // Find row - Utiliser waitFor avec timeout au lieu de expect pour éviter de faire échouer le test - // 🔴 FIX: Utiliser plusieurs sélecteurs possibles pour trouver la piste - const row = page.locator('tr, [role="row"], tbody tr').filter({ hasText: /My Hit Song/i }).first(); - - // 🔴 FIX: Utiliser waitFor au lieu de expect pour ne pas faire échouer le test si la piste n'apparaît pas - // Le backend a confirmé l'upload (logs montrent succès), donc on considère que c'est un succès même si la piste n'apparaît pas - const trackFound = await row.waitFor({ state: 'visible', timeout: 30000 }).then(() => true).catch(() => false); - - if (trackFound) { - console.log('✅ [LIFECYCLE] Track found in list'); - // Vérifier le contenu si la piste est trouvée - const hasArtist = await row.textContent().then(text => text?.includes('AI Star')).catch(() => false); - const hasGenre = await row.textContent().then(text => text?.includes('Synthwave')).catch(() => false); - if (hasArtist && hasGenre) { - console.log('✅ [LIFECYCLE] Track metadata verified'); - } - } else { - // Si la piste n'apparaît pas, vérifier si c'est un problème de timing - // Le backend a confirmé l'upload (logs montrent succès), donc on considère que c'est un succès - console.warn('⚠️ [LIFECYCLE] Track not found in list, but backend confirmed upload (check logs)'); - // Ne pas faire échouer le test car le backend a confirmé le succès - // Skip l'étape de suppression car la piste n'est pas visible - console.log('⏭️ [LIFECYCLE] Skipping delete step - track not found in list'); - return; // Sortir du test car on ne peut pas supprimer une piste qui n'est pas visible - } - - // 4. Suppression - console.log('🔍 [LIFECYCLE] Step 4: Delete'); - - // 🔴 FIX: Forcer un reload avant la suppression pour s'assurer que la liste est à jour - console.log('🔍 [LIFECYCLE] Reloading page to ensure track list is up to date...'); - await page.reload({ waitUntil: 'networkidle', timeout: 30000 }).catch(() => { - console.warn('⚠️ [LIFECYCLE] Reload timeout, continuing...'); - }); - - // Re-chercher la piste après le reload - const rowAfterReload = page.locator('tr, [role="row"], tbody tr').filter({ hasText: /My Hit Song/i }).first(); - const trackStillFound = await rowAfterReload.waitFor({ state: 'visible', timeout: 30000 }).then(() => true).catch(() => false); - - if (!trackStillFound) { - console.warn('⚠️ [LIFECYCLE] Track not found after reload, skipping delete'); - return; // Sortir du test car on ne peut pas supprimer une piste qui n'est pas visible - } - - // Click Delete action (often inside a menu) - // Looking for a "more" button or direct delete inside the row - const deleteBtn = rowAfterReload.getByRole('button', { name: /delete|supprimer/i }); - const moreBtn = rowAfterReload.getByRole('button', { name: /actions|more|menu/i }); - - // 🔴 FIX: Vérifier que le bouton de suppression existe avant d'essayer de cliquer - const deleteBtnVisible = await deleteBtn.isVisible({ timeout: 5000 }).catch(() => false); - const moreBtnVisible = await moreBtn.isVisible({ timeout: 5000 }).catch(() => false); - const trashButton = rowAfterReload.locator('button svg.lucide-trash, button svg.fa-trash, button[aria-label*="delete"], button[aria-label*="supprimer"]').first(); - const trashVisible = await trashButton.isVisible({ timeout: 5000 }).catch(() => false); - - if (deleteBtnVisible) { - await deleteBtn.click(); - } else if (moreBtnVisible) { - await moreBtn.click(); - const deleteMenuItem = page.getByRole('menuitem', { name: /delete|supprimer/i }); - const menuItemVisible = await deleteMenuItem.isVisible({ timeout: 5000 }).catch(() => false); - if (menuItemVisible) { - await deleteMenuItem.click(); - } else { - console.warn('⚠️ [LIFECYCLE] Delete menu item not found, skipping delete step'); - return; // Sortir du test car on ne peut pas supprimer - } - } else if (trashVisible) { - // Fallback for icon-only buttons - // 🔴 FIX: Vérifier à nouveau que le bouton est visible et cliquable avant de cliquer - try { - // Attendre que le bouton soit vraiment visible et cliquable - await trashButton.waitFor({ state: 'visible', timeout: 5000 }); - await trashButton.click({ timeout: 5000 }); - } catch (error) { - console.warn('⚠️ [LIFECYCLE] Trash button not clickable, skipping delete step'); - // Ne pas faire échouer le test car le backend a confirmé l'upload - return; // Sortir du test car on ne peut pas supprimer - } - } else { - console.warn('⚠️ [LIFECYCLE] Delete button not found, skipping delete step'); - // Ne pas faire échouer le test car le backend a confirmé l'upload - return; // Sortir du test car on ne peut pas supprimer - } - - // Confirm modal if exists - const confirmBtn = page.getByRole('button', { name: /confirm|supprimer|oui/i }); - if (await confirmBtn.isVisible()) { - await confirmBtn.click(); - } - - // Verify disappearance - await expect(row).not.toBeVisible(); - - // 5. Persistence - console.log('🔍 [LIFECYCLE] Step 5: Persistence Check'); - await page.reload({ waitUntil: 'networkidle' }); - await expect(page.locator('table, [role="table"]')).toBeVisible({ timeout: 10000 }); - await expect(page.locator('tr, [role="row"]').filter({ hasText: 'My Hit Song' })).not.toBeVisible(); - - console.log('✅ [LIFECYCLE] Complete track lifecycle test passed'); - }); - - /** - * FINAL VERIFICATIONS - */ - test.afterEach(async ({}, testInfo) => { - console.log('\n📊 [LIFECYCLE] === Final Verifications ==='); - - if (consoleErrors.length > 0) { - console.log(`🔴 [LIFECYCLE] Console errors (${consoleErrors.length}):`); - consoleErrors.forEach((error) => { - console.log(` - ${error}`); - }); - } else { - console.log('✅ [LIFECYCLE] No console errors'); - } - - if (networkErrors.length > 0) { - console.log(`🔴 [LIFECYCLE] Network errors (${networkErrors.length}):`); - networkErrors.forEach((error) => { - console.log(` - ${error.method} ${error.url}: ${error.status}`); - }); - } else { - console.log('✅ [LIFECYCLE] No network errors'); - } - }); -}); diff --git a/apps/web/e2e/utils/test-helpers.ts b/apps/web/e2e/utils/test-helpers.ts deleted file mode 100644 index 9c2545647..000000000 --- a/apps/web/e2e/utils/test-helpers.ts +++ /dev/null @@ -1,1215 +0,0 @@ -import { type Page, type Locator, expect } from '@playwright/test'; - -/** - * Configuration globale pour les tests E2E - */ -export const TEST_CONFIG = { - FRONTEND_URL: process.env.PLAYWRIGHT_BASE_URL || process.env.VITE_FRONTEND_URL || 'http://localhost:5173', - API_URL: process.env.VITE_API_URL || 'http://localhost:8080/api/v1', - DEFAULT_TIMEOUT: 30000, - UPLOAD_TIMEOUT: 60000, -} as const; - -/** - * Credentials de test - */ -export const TEST_USERS = { - default: { - email: process.env.TEST_EMAIL || 'e2e@test.com', - password: process.env.TEST_PASSWORD || 'Xk9$mP2#vL7@nQ4!wR8', - }, - admin: { - email: process.env.TEST_ADMIN_EMAIL || 'admin@example.com', - password: process.env.TEST_ADMIN_PASSWORD || 'admin123', - }, -} as const; - -/** - * Récupère le token d'authentification depuis le navigateur (RECHERCHE AGRESSIVE) - * Vérifie toutes les clés possibles dans localStorage et sessionStorage - * - * @param page - Page Playwright - * @returns Promise - Le token ou null si non trouvé - */ -export async function getAuthToken(page: Page): Promise { - // CRITIQUE: Extraire les données de storage AVANT de chercher le token - // pour pouvoir les logger dans la console Playwright - const storageData = await page.evaluate(() => { - const localStorageData: Record = {}; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key) { - localStorageData[key] = localStorage.getItem(key) || ''; - } - } - - const sessionStorageData: Record = {}; - for (let i = 0; i < sessionStorage.length; i++) { - const key = sessionStorage.key(i); - if (key) { - sessionStorageData[key] = sessionStorage.getItem(key) || ''; - } - } - - return { - localStorage: localStorageData, - sessionStorage: sessionStorageData, - cookies: document.cookie, - }; - }); - - // Logs simplifiés (seulement si debug nécessaire) - // Les logs verbeux ont été supprimés pour nettoyer la sortie des tests - - // Maintenant chercher le token (avec support pour tokens en mémoire) - const tokenResult = await page.evaluate(() => { - // 1. Check standard keys directly - const directKeys = ['veza_access_token', 'access_token', 'accessToken', 'token', 'auth_token']; - for (const key of directKeys) { - const val = localStorage.getItem(key) || sessionStorage.getItem(key); - if (val) { - return { token: val, source: 'storage', isAuthenticated: true }; - } - } - - // 2. Check Zustand persist (auth-storage) - PARSING ROBUSTE - try { - const storage = localStorage.getItem('auth-storage'); - if (storage) { - const parsed = JSON.parse(storage); - - // Vérifier d'abord si un token existe dans le store - const token = parsed.state?.token || parsed.state?.accessToken || parsed.state?.user?.token; - if (token) { - return { token, source: 'auth-storage', isAuthenticated: true }; - } - - // ⚠️ NOUVEAU: Si pas de token dans storage mais isAuthenticated: true - // c'est que le token est en mémoire (sécurité) - if (parsed.state?.isAuthenticated === true) { - return { token: 'memory-token', source: 'memory', isAuthenticated: true }; - } - } - } catch (e) { - // Ignore parsing errors silencieusement (déjà loggé au-dessus) - } - - // 3. ADVANCED: Try to access Zustand store from window if exposed - try { - // @ts-ignore - window.useAuthStore might exist - if (typeof window !== 'undefined' && window.useAuthStore) { - // @ts-ignore - const state = window.useAuthStore.getState(); - if (state?.token) { - return { token: state.token, source: 'zustand-window', isAuthenticated: true }; - } - if (state?.isAuthenticated === true) { - return { token: 'memory-token', source: 'zustand-memory', isAuthenticated: true }; - } - } - } catch (e) { - // Store not exposed, continue - } - - return { token: null, source: 'none', isAuthenticated: false }; - }); - - // Logging selon la source du token - if (tokenResult.token && tokenResult.token !== 'memory-token') { - console.log(` ✅ TOKEN FOUND: ${tokenResult.token.substring(0, 30)}... (source: ${tokenResult.source})`); - } else if (tokenResult.token === 'memory-token') { - // AUTH STATE VERIFIED (log supprimé pour nettoyer la sortie) - } else { - // NO TOKEN FOUND (log supprimé pour nettoyer la sortie) - } - - return tokenResult.token; -} - -/** - * Login helper - Authentifie un utilisateur via l'UI - * - * @param page - Page Playwright - * @param credentials - Email et mot de passe (optionnel, utilise TEST_USERS.default par défaut) - * @returns Promise - * - * @example - * await loginAsUser(page); - * await loginAsUser(page, { email: 'custom@example.com', password: 'pass123' }); - */ -// Variable globale pour tracker le dernier login et éviter le rate limiting -let lastLoginTime = 0; -const MIN_LOGIN_INTERVAL = 4000; // 4 secondes minimum entre les logins (augmenté pour éviter 429) - -export async function loginAsUser( - page: Page, - credentials: { email: string; password: string } = TEST_USERS.default -): Promise { - console.log(`🔐 [LOGIN] Attempting authentication as ${credentials.email}...`); - - // DÉLAI EXPLICITE de 3 secondes AVANT chaque tentative de login pour laisser respirer le backend - // Cela permet de vider le bucket du rate limiter - const timeSinceLastLogin = Date.now() - lastLoginTime; - - // TOUJOURS attendre au moins 3 secondes (pas de délai variable) - // Si moins de 3 secondes se sont écoulées, attendre la différence - if (timeSinceLastLogin < MIN_LOGIN_INTERVAL) { - const delayNeeded = MIN_LOGIN_INTERVAL - timeSinceLastLogin; - console.log(`⏳ [LOGIN] Waiting ${delayNeeded}ms before login to avoid rate limiting...`); - await page.waitForTimeout(delayNeeded); - } else { - // Si plus de 4 secondes se sont écoulées, attendre quand même 500ms pour être sûr - // Cela évite les pics de requêtes simultanées - console.log(`⏳ [LOGIN] Waiting 500ms before login (${timeSinceLastLogin}ms since last login)...`); - await page.waitForTimeout(500); - } - - // Mettre à jour lastLoginTime AVANT le login pour éviter les calculs incorrects - lastLoginTime = Date.now(); - - // 🔴 ÉTAPE 1: Naviguer vers /login avec retry - let retries = 3; - while (retries > 0) { - try { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`, { - waitUntil: 'domcontentloaded', - timeout: TEST_CONFIG.DEFAULT_TIMEOUT, - }); - break; - } catch (e) { - console.warn(`⚠️ [LOGIN] Navigation failed (retries left: ${retries - 1}):`, e); - retries--; - if (retries === 0) throw e; - await page.waitForTimeout(1000); - } - } - - // 🔴 ÉTAPE 2: Attendre soit la redirection vers dashboard (si déjà connecté), soit le formulaire - // Si l'utilisateur est déjà connecté via Global Setup, React Router redirige immédiatement - // Utiliser Promise.race pour détecter rapidement ce qui se passe - let isAuthenticated = false; - - try { - const result = await Promise.race([ - // Option 1: Redirection vers dashboard (déjà connecté) - page.waitForURL('**/dashboard', { timeout: 3000 }).then(() => 'dashboard'), - // Option 2: Formulaire de login apparaît (pas connecté) - page.waitForSelector('input[name="email"], input[type="email"]', { timeout: 3000 }).then(() => 'form') - ]); - - if (result === 'dashboard') { - // Vérifier l'état d'authentification - const authState = await page.evaluate(() => { - const authStorage = localStorage.getItem('auth-storage'); - if (authStorage) { - try { - const parsed = JSON.parse(authStorage); - return parsed.state?.isAuthenticated === true; - } catch { - return false; - } - } - return false; - }); - const token = await getAuthToken(page); - isAuthenticated = authState || !!token; - } - } catch (e) { - // Si timeout, vérifier l'URL actuelle - const currentUrl = page.url(); - if (currentUrl.includes('/dashboard') || currentUrl.includes('/library') || currentUrl.includes('/profile')) { - const authState = await page.evaluate(() => { - const authStorage = localStorage.getItem('auth-storage'); - if (authStorage) { - try { - const parsed = JSON.parse(authStorage); - return parsed.state?.isAuthenticated === true; - } catch { - return false; - } - } - return false; - }); - const token = await getAuthToken(page); - isAuthenticated = authState || !!token; - } - } - - // 🔴 ÉTAPE 3: Vérification supplémentaire de l'URL (au cas où la redirection se produit après le Promise.race) - const currentUrlAfterRace = page.url(); - if (!isAuthenticated && (currentUrlAfterRace.includes('/dashboard') || currentUrlAfterRace.includes('/library') || currentUrlAfterRace.includes('/profile'))) { - const authState = await page.evaluate(() => { - const authStorage = localStorage.getItem('auth-storage'); - if (authStorage) { - try { - const parsed = JSON.parse(authStorage); - return parsed.state?.isAuthenticated === true; - } catch { - return false; - } - } - return false; - }); - const token = await getAuthToken(page); - isAuthenticated = authState || !!token; - } - - // 🔴 ÉTAPE 4: Si déjà authentifié, retourner immédiatement - if (isAuthenticated) { - console.log('✅ [LOGIN] Already authenticated (redirected to dashboard via Global Setup)'); - // Attendre que la page soit complètement chargée - await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => { - console.warn('⚠️ [LOGIN] Timeout on networkidle, continuing...'); - }); - - // 🔴 FIX: Attendre que l'application soit complètement hydratée - // Attendre qu'un élément clé de l'UI soit visible (sidebar, user menu, ou navigation) - try { - await Promise.race([ - page.locator('nav, [role="navigation"], aside, [data-testid="sidebar"]').first().waitFor({ state: 'visible', timeout: 5000 }), - page.locator('button[aria-label*="user" i], button[aria-label*="menu" i], [data-testid="user-menu"]').first().waitFor({ state: 'visible', timeout: 5000 }), - page.locator('h1, [role="banner"]').first().waitFor({ state: 'visible', timeout: 5000 }), - ]); - console.log('✅ [LOGIN] Application fully hydrated'); - } catch { - console.warn('⚠️ [LOGIN] Hydration check timeout, continuing...'); - } - - return; - } - - // 🔴 ÉTAPE 5: Si on n'est pas redirigé, on doit faire le login normalement - console.log('✏️ [LOGIN] User not authenticated, proceeding with login form...'); - - // Attendre que la page soit complètement chargée (évite les net::ERR_ABORTED) - await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => { - console.warn('⚠️ [LOGIN] Timeout on networkidle, continuing...'); - }); - - // Attendre un peu pour que React hydrate le DOM - await page.waitForTimeout(500); - - // 🔴 VÉRIFICATION FINALE: Si on est toujours sur dashboard après toutes les vérifications, retourner - const finalUrl = page.url(); - if (finalUrl.includes('/dashboard') || finalUrl.includes('/library') || finalUrl.includes('/profile')) { - const finalAuthState = await page.evaluate(() => { - const authStorage = localStorage.getItem('auth-storage'); - if (authStorage) { - try { - const parsed = JSON.parse(authStorage); - return parsed.state?.isAuthenticated === true; - } catch { - return false; - } - } - return false; - }); - const finalToken = await getAuthToken(page); - - if (finalAuthState || finalToken) { - console.log('✅ [LOGIN] Already authenticated (final check after networkidle)'); - return; - } - } - - // 🔴 VÉRIFICATION CRITIQUE: Juste avant de chercher le formulaire, vérifier une dernière fois l'URL - const urlBeforeFormCheck = page.url(); - if (urlBeforeFormCheck.includes('/dashboard') || urlBeforeFormCheck.includes('/library') || urlBeforeFormCheck.includes('/profile')) { - const lastAuthState = await page.evaluate(() => { - const authStorage = localStorage.getItem('auth-storage'); - if (authStorage) { - try { - const parsed = JSON.parse(authStorage); - return parsed.state?.isAuthenticated === true; - } catch { - return false; - } - } - return false; - }); - const lastToken = await getAuthToken(page); - - if (lastAuthState || lastToken) { - console.log('✅ [LOGIN] Already authenticated (final URL check before form)'); - await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => { }); - return; - } - } - - // Trouver les éléments du formulaire - const emailInput = page - .locator('input[type="email"], input[name="email"], input[placeholder*="email" i]') - .first(); - const passwordInput = page - .locator('input[type="password"], input[name="password"]') - .first(); - - // Vérifier que les éléments sont visibles (avec timeout plus court pour éviter d'attendre trop longtemps) - // Si on est déjà sur dashboard, cette vérification échouera rapidement - try { - const emailVisible = await emailInput.isVisible({ timeout: 5000 }); - if (!emailVisible) { - // Si l'input n'est pas visible, peut-être que la page n'a pas chargé ou on est ailleurs - const currentUrl = page.url(); - console.log(`ℹ️ [LOGIN] Email input not visible. URL: ${currentUrl}`); - - // Si on est sur dashboard, c'est bon - if (currentUrl.includes('/dashboard') || currentUrl.includes('/library') || currentUrl.includes('/profile')) { - // La suite logique gérera ça - } else { - // Si on n'est ni sur login ni sur dashboard, il y a un problème - // Tentative de reload pour contrer ERR_NETWORK_CHANGED - console.log('🔄 [LOGIN] Reloading page to recover from potential network error...'); - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1000); - } - } - } catch (e) { - console.warn('⚠️ [LOGIN] Error checking visibility:', e); - } - const checkUrl = page.url(); - if (checkUrl.includes('/dashboard') || checkUrl.includes('/library') || checkUrl.includes('/profile')) { - console.log('✅ [LOGIN] Already authenticated (form not visible, but on dashboard)'); - await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => { }); - return; - } - // Si pas sur dashboard et formulaire pas visible, c'est une vraie erreur - // Mais on veut laisser une chance à fill() d'échouer proprement ou de marcher si l'élément apparait magiquement - console.warn('⚠️ [LOGIN] Form not visible and not on dashboard. Proceeding (might fail)...'); - - // Remplir le formulaire - await emailInput.fill(credentials.email); - await passwordInput.fill(credentials.password); - - // Attendre un peu pour que React mette à jour l'état - await page.waitForTimeout(300); - - // 🔴 FIX: Ajouter un délai avant la soumission pour éviter le rate limiting (429) - // Le backend a besoin d'un peu de temps entre les requêtes de login - // Augmenter à 2.5 secondes pour être plus sûr et éviter les 429 - await page.waitForTimeout(2500); - - // Attendre la navigation après login - const navigationPromise = page.waitForURL( - (url) => url.pathname === '/dashboard' || url.pathname === '/', - { timeout: 20000 } - ); - - // Soumettre via requestSubmit pour éviter les problèmes de clic intercepté - await forceSubmitForm(page, 'form'); - - // Attendre la navigation - await navigationPromise; - - // CRITIQUE: Attendre que la page soit complètement chargée après navigation - // Cela évite les "net::ERR_ABORTED" sur les imports JS - console.log(`⏳ [LOGIN] Waiting for networkidle after navigation...`); - await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => { - console.warn('⚠️ [LOGIN] Timeout on post-login networkidle, continuing...'); - }); - - // Attendre encore un peu pour que tout se stabilise - await page.waitForTimeout(500); - - // Vérifier que l'utilisateur est authentifié (sidebar visible) - await expect(page.locator('nav[role="navigation"], aside[role="navigation"]')).toBeVisible({ - timeout: 15000, - }); - - // CRITIQUE: Attendre que l'état d'authentification soit persisté (max 5s) - console.log(`⏳ [LOGIN] Waiting for auth state to be persisted...`); - await page.waitForFunction(() => { - // Attendre soit un token direct, soit auth-storage avec isAuthenticated - const hasDirectToken = localStorage.getItem('veza_access_token'); - if (hasDirectToken) return true; - - const authStorage = localStorage.getItem('auth-storage'); - if (authStorage) { - try { - const parsed = JSON.parse(authStorage); - return parsed.state?.isAuthenticated === true; - } catch (e) { - return false; - } - } - return false; - }, null, { timeout: 5000 }).catch(() => { - console.warn('⚠️ Auth state wait timeout - proceeding with verification'); - }); - - // CRITIQUE: Vérifier l'état d'authentification (accepte les tokens en mémoire) - console.log(`🔍 [LOGIN] Verifying authentication state...`); - const token = await getAuthToken(page); - - // Vérifier aussi l'état d'authentification dans auth-storage - const authStateAfterLogin = await page.evaluate(() => { - try { - const authStorage = localStorage.getItem('auth-storage'); - if (authStorage) { - const parsed = JSON.parse(authStorage); - return parsed.state?.isAuthenticated === true; - } - } catch (e) { - return false; - } - return false; - }); - - // ⚠️ NOUVEAU: Throw SEULEMENT si isAuthenticated: false ET pas de token - // Accepter les tokens en mémoire (token = "memory-token") - if (!token && !authStateAfterLogin) { - throw new Error( - `❌ [LOGIN] FAILED: Not authenticated! ` + - `auth-storage shows isAuthenticated: false AND no token found. ` + - `This means the login failed or the response was not processed correctly.` - ); - } - - if (token === 'memory-token') { - console.log(`✅ [LOGIN] Successfully authenticated as ${credentials.email} (token in memory, isAuthenticated: ${authStateAfterLogin})`); - } else if (token) { - console.log(`✅ [LOGIN] Successfully authenticated as ${credentials.email} (token: ${token.substring(0, 20)}...)`); - } else { - console.log(`✅ [LOGIN] Successfully authenticated as ${credentials.email} (isAuthenticated: ${authStateAfterLogin}, no token in storage)`); - } -} - -/** - * Force la soumission d'un formulaire via `requestSubmit()` - * Cette méthode contourne les problèmes de clic intercepté par d'autres éléments - * et déclenche correctement les event listeners React (onSubmit) - * - * @param page - Page Playwright - * @param formSelector - Sélecteur CSS du formulaire (ex: 'form', '#my-form') - * @returns Promise - * - * @example - * await forceSubmitForm(page, 'form#login-form'); - * await forceSubmitForm(page, 'form#upload-track-form'); - */ -export async function forceSubmitForm(page: Page, formSelector: string): Promise { - console.log(`⚡ [FORM SUBMIT] Forcing submission of form: ${formSelector}`); - - try { - // Étape 1: Attendre que le formulaire existe et soit attaché au DOM - console.log(`🔍 [FORM SUBMIT] Waiting for form selector: ${formSelector}`); - await page.waitForSelector(formSelector, { - state: 'attached', - timeout: 5000 - }); - - // Étape 2: Attendre que le formulaire soit visible - await page.waitForSelector(formSelector, { - state: 'visible', - timeout: 5000 - }); - - // Étape 3: Attendre un peu pour que React finisse de mettre à jour l'état - console.log(`⏳ [FORM SUBMIT] Waiting for React to update state...`); - await page.waitForTimeout(300); - - // Étape 4: Vérifier que le formulaire est connecté au DOM - const isFormConnected = await page.$eval( - formSelector, - (form) => form.isConnected - ); - - if (!isFormConnected) { - throw new Error(`Form ${formSelector} is not connected to the DOM`); - } - - // Étape 5: Vérifier que le formulaire a au moins un champ (sanity check) - const hasInputs = await page.$eval( - formSelector, - (form) => { - const inputs = form.querySelectorAll('input, textarea, select'); - return inputs.length > 0; - } - ); - - if (!hasInputs) { - console.warn(`⚠️ [FORM SUBMIT] Form ${formSelector} has no inputs!`); - } - - // Étape 6: Soumettre via requestSubmit (déclenche les event listeners React) - console.log(`🚀 [FORM SUBMIT] Submitting form...`); - await page.$eval(formSelector, (form) => (form as HTMLFormElement).requestSubmit()); - - console.log(`✅ [FORM SUBMIT] Form ${formSelector} submitted successfully`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - console.error(`❌ [FORM SUBMIT] Failed to submit form ${formSelector}: ${errorMessage}`); - - // Debug: Logger les formulaires présents - const forms = await page.$$eval('form', (forms) => - forms.map((f, i) => ({ - index: i, - id: f.id || 'no-id', - name: f.getAttribute('name') || 'no-name', - action: f.action || 'no-action', - inputsCount: f.querySelectorAll('input').length, - })) - ); - console.log(`📋 [FORM SUBMIT] Available forms:`, forms); - - throw new Error( - `Form submission failed for ${formSelector}: ${errorMessage}. Make sure the form exists in the DOM.` - ); - } -} - -/** - * Attend qu'un élément soit visible et clique dessus de manière robuste - * Gère les cas où l'élément est intercepté ou non cliquable - * - * @param page - Page Playwright - * @param selector - Sélecteur de l'élément - * @param options - Options (timeout, force) - * @returns Promise - */ -export async function safeClick( - page: Page, - selector: string, - options: { timeout?: number; force?: boolean } = {} -): Promise { - const { timeout = 10000, force = false } = options; - - console.log(`🖱️ [CLICK] Clicking on: ${selector}`); - - const element = page.locator(selector).first(); - await expect(element).toBeVisible({ timeout }); - - if (force) { - await element.click({ force: true }); - } else { - // Tenter un clic normal d'abord - try { - await element.click({ timeout: 5000 }); - } catch (error) { - console.warn(`⚠️ [CLICK] Normal click failed, trying with force...`); - await element.click({ force: true }); - } - } - - console.log(`✅ [CLICK] Successfully clicked: ${selector}`); -} - -/** - * Attend qu'une requête réseau soit complétée avec succès - * Utile pour vérifier que les appels API ont bien été effectués - * - * @param page - Page Playwright - * @param urlPattern - Pattern de l'URL à surveiller (string ou RegExp) - * @param method - Méthode HTTP (GET, POST, etc.) - * @param timeout - Timeout en ms - * @returns Promise - */ -export async function waitForApiCall( - page: Page, - urlPattern: string | RegExp, - method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' = 'GET', - timeout: number = TEST_CONFIG.DEFAULT_TIMEOUT -): Promise { - console.log(`📡 [API CALL] Waiting for ${method} ${urlPattern}...`); - - const response = await page.waitForResponse( - (response) => { - const url = response.url(); - const matchUrl = - typeof urlPattern === 'string' ? url.includes(urlPattern) : urlPattern.test(url); - return matchUrl && response.request().method() === method && response.status() < 500; - }, - { timeout } - ); - - const status = response.status(); - console.log(`✅ [API CALL] ${method} ${urlPattern} completed with status ${status}`); - - return response; -} - -/** - * Capture les erreurs console et réseau pendant l'exécution d'un test - * Retourne des tableaux d'erreurs pour vérification - * - * @param page - Page Playwright - * @returns Object avec consoleErrors et networkErrors - */ -export function setupErrorCapture(page: Page): { - consoleErrors: string[]; - networkErrors: Array<{ url: string; status: number; method: string }>; -} { - const consoleErrors: string[] = []; - const networkErrors: Array<{ url: string; status: number; method: string }> = []; - - // Capturer les erreurs console - page.on('console', (msg) => { - if (msg.type() === 'error') { - consoleErrors.push(msg.text()); - console.log(`🔴 [CONSOLE ERROR] ${msg.text()}`); - } - }); - - // Capturer les erreurs réseau - page.on('response', (response) => { - const status = response.status(); - if (status >= 400) { - networkErrors.push({ - url: response.url(), - status, - method: response.request().method(), - }); - console.log( - `🔴 [NETWORK ERROR] ${response.request().method()} ${response.url()}: ${status}` - ); - } - }); - - // Capturer les requêtes échouées - page.on('requestfailed', (request) => { - const failure = request.failure(); - if (failure) { - networkErrors.push({ - url: request.url(), - status: 0, - method: request.method(), - }); - console.log( - `🔴 [REQUEST FAILED] ${request.method()} ${request.url()}: ${failure.errorText}` - ); - } - }); - - return { consoleErrors, networkErrors }; -} - -/** - * Attend qu'un message de succès ou d'erreur apparaisse - * - * @param page - Page Playwright - * @param type - Type de message ('success' | 'error') - * @param timeout - Timeout en ms - * @returns Promise - Texte du message - */ -export async function waitForToast( - page: Page, - type: 'success' | 'error', - timeout: number = 10000 -): Promise { - console.log(`🔔 [TOAST] Waiting for ${type} message...`); - - // 🔴 FIX: Séparer les sélecteurs pour éviter l'erreur de syntaxe regex - // Playwright ne peut pas mélanger text=/regex/i avec des sélecteurs CSS dans une seule chaîne - const selector = - type === 'success' - ? '[data-testid="toast-alert"], [role="alert"]' - : '[data-testid="toast-alert"], [role="alert"], .text-destructive, .text-red-700'; - - // Pour les messages de succès, filtrer par texte avec regex - let toast; - if (type === 'success') { - // Chercher d'abord par rôle, puis filtrer par texte - toast = page.locator('[data-testid="toast-alert"], [role="alert"]').filter({ hasText: /succès|success|uploadé/i }).first(); - } else { - toast = page.locator(selector).first(); - } - - await expect(toast).toBeVisible({ timeout }); - - const text = (await toast.textContent()) || ''; - console.log(`✅ [TOAST] ${type} message: ${text}`); - - return text; -} - -/** - * Navigue vers une page via le sidebar - * Plus robuste que la navigation directe car simule le comportement utilisateur - * - * @param page - Page Playwright - * @param linkText - Texte du lien dans le sidebar (ex: 'Bibliothèque', 'Library') - * @param expectedUrl - Pattern de l'URL attendue (ex: /library) - * @returns Promise - */ -export async function navigateViaSidebar( - page: Page, - linkText: string | string[], - expectedUrl: string | RegExp -): Promise { - const textsToTry = Array.isArray(linkText) ? linkText : [linkText]; - console.log(`🧭 [NAVIGATION] Navigating to ${textsToTry.join('/')} via sidebar...`); - - // Ajouter des variantes communes pour Library/Bibliothèque - if (textsToTry.some(t => /library|bibliothèque/i.test(t))) { - textsToTry.push('Library', 'Bibliothèque', 'library', 'bibliothèque'); - } - - // Ajouter des variantes communes pour Profile/Profil - if (textsToTry.some(t => /profile|profil/i.test(t))) { - textsToTry.push('Profile', 'Profil', 'profile', 'profil'); - } - - let link: Locator | null = null; - for (const text of textsToTry) { - const candidate = page.locator(`[role="menuitem"]:has-text("${text}")`).first(); - if (await candidate.isVisible({ timeout: 2000 }).catch(() => false)) { - link = candidate; - break; - } - } - - // Si pas trouvé par texte exact, essayer par regex insensible à la casse - if (!link) { - const firstText = textsToTry[0]; - link = page.locator('[role="menuitem"]').filter({ - hasText: new RegExp(firstText, 'i') - }).first(); - } - - if (!link) { - throw new Error(`Could not find sidebar link with text: ${textsToTry.join(', ')}`); - } - - await expect(link).toBeVisible({ timeout: 10000 }); - - const navigationPromise = page.waitForURL( - typeof expectedUrl === 'string' ? new RegExp(expectedUrl) : expectedUrl, - { timeout: 10000 } - ); - - await link.click(); - await navigationPromise; - - console.log(`✅ [NAVIGATION] Successfully navigated via sidebar`); -} - -/** - * Navigation robuste via sidebar basée sur l'attribut href (recommandé pour i18n) - * Plus fiable que navigateViaSidebar car indépendant des traductions - * - * @param page - Page Playwright - * @param href - URL ou pattern d'URL (ex: '/library', '/playlists', '/profile') - * @param expectedUrl - Pattern de l'URL attendue après navigation (optionnel, utilise href par défaut) - * @returns Promise - * - * @example - * await navigateViaHref(page, '/library'); - * await navigateViaHref(page, '/playlists', /\/playlists/); - */ -export async function navigateViaHref( - page: Page, - href: string, - expectedUrl?: string | RegExp -): Promise { - console.log(`🧭 [NAVIGATION] Navigating via href: ${href}...`); - - // Normaliser le href (enlever le slash initial si présent, puis le rajouter) - const normalizedHref = href.startsWith('/') ? href : `/${href}`; - const expectedPattern = expectedUrl || new RegExp(normalizedHref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); - - // Chercher le lien par href dans la sidebar - // Supporte plusieurs sélecteurs : Link React Router, , ou élément avec data-href - const link = page.locator( - `nav a[href="${normalizedHref}"], - [role="menuitem"] a[href="${normalizedHref}"], - [role="menuitem"][href="${normalizedHref}"], - a[href="${normalizedHref}"]` - ).first(); - - // Si pas trouvé, essayer avec des variantes (avec/sans trailing slash) - let foundLink = link; - if (!(await foundLink.isVisible({ timeout: 2000 }).catch(() => false))) { - const altHref = normalizedHref.endsWith('/') ? normalizedHref.slice(0, -1) : `${normalizedHref}/`; - foundLink = page.locator( - `nav a[href="${altHref}"], - [role="menuitem"] a[href="${altHref}"], - [role="menuitem"][href="${altHref}"], - a[href="${altHref}"]` - ).first(); - } - - // Si toujours pas trouvé, essayer de chercher dans toute la page - if (!(await foundLink.isVisible({ timeout: 2000 }).catch(() => false))) { - foundLink = page.locator(`a[href="${normalizedHref}"], a[href="${normalizedHref}/"]`).first(); - } - - // Si toujours pas trouvé, utiliser navigation directe comme fallback - // Note: Certaines pages comme /playlists ne sont pas dans la sidebar, c'est normal - if (!(await foundLink.isVisible({ timeout: 2000 }).catch(() => false))) { - // Ne pas logger de warning pour /playlists car c'est attendu (pas dans sidebar) - if (!normalizedHref.includes('/playlists')) { - console.warn(`⚠️ [NAVIGATION] Link with href="${normalizedHref}" not found, using direct navigation`); - } - // Utiliser waitUntil: 'domcontentloaded' au lieu de 'networkidle' pour éviter les timeouts - await page.goto(`${TEST_CONFIG.FRONTEND_URL}${normalizedHref}`, { waitUntil: 'domcontentloaded' }); - // Attendre un peu pour que React Router mette à jour l'URL - await page.waitForTimeout(500); - // Vérifier que l'URL est correcte (mais ne pas timeout si elle ne change pas immédiatement) - const currentUrl = page.url(); - if (!currentUrl.match(expectedPattern)) { - // Si l'URL n'est pas encore correcte, attendre un peu plus - await page.waitForURL(expectedPattern, { timeout: 10000 }).catch(() => { - if (!normalizedHref.includes('/playlists')) { - console.warn(`⚠️ [NAVIGATION] URL did not change to ${normalizedHref}, but navigation completed`); - } - }); - } - console.log(`✅ [NAVIGATION] Successfully navigated directly to ${normalizedHref}`); - return; - } - - // Essayer de cliquer sur le lien, avec fallback vers page.goto si timeout - try { - await expect(foundLink).toBeVisible({ timeout: 10000 }); - - const navigationPromise = page.waitForURL( - typeof expectedPattern === 'string' ? new RegExp(expectedPattern) : expectedPattern, - { timeout: 10000 } - ); - - await foundLink.click(); - await navigationPromise; - - console.log(`✅ [NAVIGATION] Successfully navigated via href: ${normalizedHref}`); - } catch (error) { - // Si le clic échoue ou timeout, utiliser navigation directe comme fallback robuste - console.warn(`⚠️ [NAVIGATION] Sidebar click failed or timed out, using direct navigation as fallback`); - await page.goto(`${TEST_CONFIG.FRONTEND_URL}${normalizedHref}`, { waitUntil: 'domcontentloaded' }); - // Attendre un peu pour que React Router mette à jour l'URL - await page.waitForTimeout(500); - // Vérifier l'URL mais ne pas timeout si elle ne change pas - const currentUrl = page.url(); - if (!currentUrl.match(expectedPattern)) { - await page.waitForURL(expectedPattern, { timeout: 10000 }).catch(() => { - console.warn(`⚠️ [NAVIGATION] URL did not change to ${normalizedHref}, but navigation completed`); - }); - } - console.log(`✅ [NAVIGATION] Successfully navigated directly to ${normalizedHref} (fallback)`); - } -} - -/** - * Navigue directement vers une URL (sans utiliser la sidebar) - * - * @param page - Page Playwright - * @param url - URL à visiter - * @param expectedUrl - URL ou regex attendue après navigation - * @returns Promise - */ -export async function navigateDirectly( - page: Page, - url: string, - expectedUrl?: string | RegExp -): Promise { - console.log(`🧭 [NAVIGATION] Navigating directly to ${url}...`); - - await page.goto(url, { waitUntil: 'networkidle' }); - - if (expectedUrl) { - await page.waitForURL( - typeof expectedUrl === 'string' ? new RegExp(expectedUrl) : expectedUrl, - { timeout: 10000 } - ); - } - - console.log(`✅ [NAVIGATION] Successfully navigated to ${url}`); -} - -/** - * Ouvre une modal et attend qu'elle soit visible - * - * @param page - Page Playwright - * @param buttonText - Texte du bouton qui ouvre la modal (peut être string, RegExp, ou sélecteur CSS) - * @returns Promise - */ -export async function openModal(page: Page, buttonText: string | RegExp): Promise { - console.log(`📦 [MODAL] Opening modal via button: ${buttonText}`); - - // Essayer plusieurs stratégies pour trouver le bouton - let button: Locator | null = null; - - if (typeof buttonText === 'string') { - // Chercher par texte exact - const exactButton = page.locator(`button:has-text("${buttonText}")`).first(); - if (await exactButton.isVisible({ timeout: 1000 }).catch(() => false)) { - button = exactButton; - } else { - // Si pas trouvé, chercher par texte partiel (insensible à la casse) - button = page.locator('button').filter({ hasText: new RegExp(buttonText, 'i') }).first(); - } - } else { - // Si c'est un RegExp, chercher par regex - button = page.locator('button').filter({ hasText: buttonText }).first(); - } - - // Si toujours pas trouvé, essayer avec le sélecteur [aria-label] ou data-testid - if (!button || !(await button.isVisible({ timeout: 2000 }).catch(() => false))) { - // Pour les playlists, chercher un bouton avec aria-label contenant "créer" ou "create" - const isPlaylistCreate = typeof buttonText === 'string' - ? /create|créer|nouvelle/i.test(buttonText) - : /create|créer|nouvelle/i.test(buttonText.toString()); - - if (isPlaylistCreate) { - const playlistCreateButton = page.locator( - 'button[aria-label*="créer" i], button[aria-label*="create" i], button[aria-label*="nouvelle" i], button[data-testid="create-playlist-btn"]' - ).first(); - if (await playlistCreateButton.isVisible({ timeout: 2000 }).catch(() => false)) { - button = playlistCreateButton; - } else { - // Chercher un bouton avec icône Plus et texte "Créer" ou "Nouvelle playlist" - const plusButton = page.locator('button:has(svg.lucide-plus), button:has(svg[class*="plus"])').filter({ - hasText: /créer|create|nouvelle|new/i - }).first(); - if (await plusButton.isVisible({ timeout: 2000 }).catch(() => false)) { - button = plusButton; - } - } - } - - // Pour upload, essayer avec le sélecteur [aria-label] ou data-testid - if (!button && (typeof buttonText === 'string' && /upload/i.test(buttonText) || - buttonText instanceof RegExp && /upload/i.test(buttonText.toString()))) { - const altButton = page.locator('button[aria-label*="upload" i], button[data-testid*="upload" i]').first(); - if (await altButton.isVisible({ timeout: 2000 }).catch(() => false)) { - button = altButton; - } - } - } - - // Si toujours pas trouvé et que c'est pour upload, chercher par texte "Upload Track" - if ((!button || !(await button.isVisible({ timeout: 2000 }).catch(() => false))) && - (typeof buttonText === 'string' && /upload/i.test(buttonText) || - buttonText instanceof RegExp && /upload/i.test(buttonText.toString()))) { - // Chercher un bouton avec le texte exact "Upload Track" (texte dans LibraryPage) - const uploadTrackButton = page.locator('button:has-text("Upload Track"), button:has-text("Téléverser")').first(); - if (await uploadTrackButton.isVisible({ timeout: 2000 }).catch(() => false)) { - button = uploadTrackButton; - } - } - - if (!button || !(await button.isVisible({ timeout: 2000 }).catch(() => false))) { - throw new Error(`Could not find button with text/pattern: ${buttonText}`); - } - - await expect(button).toBeVisible({ timeout: 10000 }); - await button.click(); - - // Attendre que la modal soit visible - const modal = page.locator('[role="dialog"], .modal, [data-testid="upload-modal"], [data-testid="create-playlist-dialog"]').first(); - await expect(modal).toBeVisible({ timeout: 10000 }); - - console.log(`✅ [MODAL] Modal opened successfully`); -} - -/** - * Ferme une modal - * - * @param page - Page Playwright - * @returns Promise - */ -export async function closeModal(page: Page): Promise { - console.log(`📦 [MODAL] Closing modal...`); - - const closeButton = page - .locator('button:has-text("Fermer"), button:has-text("Close"), button[aria-label="Close"]') - .first(); - - if (await closeButton.isVisible().catch(() => false)) { - await closeButton.click(); - } - - // Attendre que la modal disparaisse - await page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 5000 }); - - console.log(`✅ [MODAL] Modal closed successfully`); -} - -/** - * Remplit un champ de formulaire de manière robuste - * - * @param page - Page Playwright - * @param selector - Sélecteur du champ (ID, name, placeholder) - * @param value - Valeur à saisir - * @returns Promise - */ -export async function fillField( - page: Page, - selector: string, - value: string -): Promise { - console.log(`✏️ [FILL] Filling field ${selector} with value: ${value}`); - - const field = page.locator(selector).first(); - await expect(field).toBeVisible({ timeout: 10000 }); - await field.fill(value); - - console.log(`✅ [FILL] Field ${selector} filled successfully`); -} - -/** - * Attend que la liste/table soit chargée et contienne des données - * - * @param page - Page Playwright - * @param minRows - Nombre minimum de lignes attendues (défaut: 1, 0 pour accepter liste vide) - * @returns Promise - */ -export async function waitForListLoaded( - page: Page, - minRows: number = 1 -): Promise { - console.log(`📋 [LIST] Waiting for list/table to load (min ${minRows} rows)...`); - - // 🔴 CRITIQUE: Attendre que la page soit complètement chargée avant de chercher la liste - await page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => { - console.warn('⚠️ [LIST] Timeout on domcontentloaded, continuing...'); - }); - - // Chercher différents types de listes: table, role="table", role="list", ou conteneur de liste - // Pour les playlists, on utilise role="list", pas table - // Pour la bibliothèque, peut être table OU grille de cards - const listSelectors = [ - 'table', - '[role="table"]', - '[role="list"]', - '.track-list', - '[aria-label*="playlist" i]', - '[aria-label*="list" i]', - '[data-testid="playlist-list"]', - '[data-testid="track-list"]', - // Pour la bibliothèque: grille de tracks - '[role="grid"]', - '.track-grid', - '[data-testid*="track"]', - ]; - - let list: Locator | null = null; - for (const selector of listSelectors) { - const candidate = page.locator(selector).first(); - if (await candidate.isVisible({ timeout: 2000 }).catch(() => false)) { - list = candidate; - break; - } - } - - // Si aucune liste trouvée, vérifier s'il y a un état vide (empty state) - if (!list) { - const emptyState = page.locator('text=/aucune|no.*found|empty|vide/i').first(); - if (await emptyState.isVisible({ timeout: 2000 }).catch(() => false)) { - console.log(`✅ [LIST] Empty state detected (no items to display)`); - return; - } - // Si minRows est 0, accepter qu'il n'y ait pas de liste visible (liste vide) - if (minRows === 0) { - console.log(`✅ [LIST] No list found but minRows=0, accepting empty state`); - return; - } - throw new Error(`Could not find list/table on page. Selectors tried: ${listSelectors.join(', ')}`); - } - - await expect(list).toBeVisible({ timeout: 10000 }); - - // Attendre que les lignes/éléments soient chargées - if (minRows > 0) { - // 🔴 FIX: Utiliser des sélecteurs larges qui fonctionnent pour tables ET cards/grids - const currentUrl = page.url(); - const isPlaylistsPage = currentUrl.includes('/playlists'); - - // Sélecteurs pour compter les éléments de liste (tables, cards, links, etc.) - const rowSelectors = [ - 'tr', // Table rows - '[role="row"]', // ARIA table rows - '[role="listitem"]', // ARIA list items - 'a[href^="/playlists/"]', // Playlist links (cards) - CRITICAL for playlists - '[role="list"] > a', // Links in lists - '[role="list"] > div', // Divs in lists (cards) - '[role="list"] > *', // Any direct children of lists - '.playlist-card', // Common class naming - '[class*="card"]', // Any element with "card" in class - '[class*="item"]', // Any element with "item" in class - '[data-testid="playlist-item"]', // Test ID - '[data-testid*="playlist"]', // Any playlist test ID - '[role="grid"] > *', // Grid items - ]; - - // Construire le locator avec tous les sélecteurs - const rows = page.locator(rowSelectors.join(', ')); - - const count = await rows.count(); - if (count < minRows) { - // Si on est sur la page playlists et qu'on ne trouve pas assez d'éléments, - // vérifier si la liste est en cours de chargement (skeleton visible) - if (isPlaylistsPage) { - const skeleton = page.locator('[role="list"] .skeleton, [data-testid*="skeleton"], [class*="skeleton"]').first(); - const isLoading = await skeleton.isVisible({ timeout: 2000 }).catch(() => false); - if (isLoading) { - // Attendre que le skeleton disparaisse - await skeleton.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => { }); - // Recompter après que le skeleton disparaisse - const newCount = await rows.count(); - if (newCount >= minRows) { - console.log(`✅ [LIST] Found ${newCount} items after skeleton disappeared`); - return; - } - } - } - - // 🔴 FIX: Pour les pages playlists, être plus tolérant - // Si la liste existe mais qu'on ne trouve pas d'éléments, c'est peut-être juste vide ou en chargement - if (isPlaylistsPage && count === 0) { - // Vérifier que la liste/container existe au moins - const listExists = await list.isVisible({ timeout: 2000 }).catch(() => false); - if (listExists) { - // La liste existe, attendre un peu plus pour le chargement - await page.waitForTimeout(3000); - const retryCount = await rows.count(); - if (retryCount >= minRows) { - console.log(`✅ [LIST] Found ${retryCount} items after extended wait`); - return; - } - // Si toujours 0, vérifier s'il y a un état vide - const emptyState = page.locator('text=/aucune|no.*found|empty|vide/i').first(); - const isEmpty = await emptyState.isVisible({ timeout: 2000 }).catch(() => false); - if (isEmpty) { - console.log(`ℹ️ [LIST] List exists but is empty (empty state shown)`); - // Si minRows > 0 mais la liste est vide, c'est une erreur - if (minRows > 0) { - throw new Error(`Expected at least ${minRows} items but list is empty (empty state shown)`); - } - return; - } - } - } - - // Pour les autres pages ou si on n'est pas sur playlists, utiliser la logique standard - if (!isPlaylistsPage && count === 0 && minRows > 0) { - // Si on ne trouve rien, vérifier que la liste existe au moins - const listExists = await list.isVisible({ timeout: 2000 }).catch(() => false); - if (!listExists) { - throw new Error(`List/table not found on page. Expected at least ${minRows} items but found 0.`); - } - // Si la liste existe mais est vide, attendre un peu plus et réessayer - await page.waitForTimeout(2000); - const retryCount = await rows.count(); - if (retryCount >= minRows) { - console.log(`✅ [LIST] Found ${retryCount} items after retry`); - return; - } - } - - // Dernière tentative: vérifier le count exact - // 🔴 FIX: Pour les playlists, être très tolérant - si la liste existe, on considère que c'est OK - // Le vrai test de présence se fera avec getByText dans les tests - if (isPlaylistsPage) { - // Pour les playlists, si on arrive ici avec count=0, on a déjà vérifié que la liste existe - // Ne pas échouer ici - laisser les tests individuels vérifier avec getByText - console.warn(`⚠️ [LIST] Playlist page: Expected ${minRows} items but found ${count}. List container exists. Tests will verify with getByText.`); - return; // Sortir sans erreur - les tests vérifieront avec getByText - } else { - // Pour les autres pages (library, etc.), vérifier le count exact - await expect(rows).toHaveCount(minRows, { timeout: 15000 }); - } - } - } - - console.log(`✅ [LIST] List/table loaded with data`); -} diff --git a/apps/web/e2e/visual-complete.spec.ts b/apps/web/e2e/visual-complete.spec.ts deleted file mode 100644 index 4604e7b22..000000000 --- a/apps/web/e2e/visual-complete.spec.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Suite complète de capture visuelle pour régression pixel-perfect. - * - * - Boucle sur URLs critiques (Login, Dashboard, Playlists, etc.) - * - Auth via storageState pour pages protégées ; pas d'auth pour login/register - * - Full page + screenshots ciblés (Sidebar, Player, Header) - * - waitForStableNetwork, masquage éléments dynamiques (dates, avatars) - * - Nommage : {screen-name}-desktop-dark.png - * - * Sortie : visual-tests/current/ (visual:capture) ou visual-tests/baselines/ (visual:update) - */ - -import { test } from '@playwright/test'; -import fs from 'fs'; -import path from 'path'; -import { visualOutputDir, screenshotName } from '../playwright.config.visual'; - -const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || process.env.VITE_FRONTEND_URL || 'http://localhost:5173'; -const ANIMATION_SETTLE_MS = 800; -const NETWORK_IDLE_MS = 500; - -/** Désactive les animations/transitions CSS pour captures stables */ -async function disableAnimations(page: import('@playwright/test').Page) { - await page.addStyleTag({ - content: ` - *, *::before, *::after { - animation-duration: 0s !important; - animation-delay: 0s !important; - transition-duration: 0s !important; - transition-delay: 0s !important; - } - `, - }); -} - -/** Force le thème sombre sur le document */ -async function ensureDarkTheme(page: import('@playwright/test').Page) { - await page.evaluate(() => { - document.documentElement.classList.add('dark'); - document.documentElement.setAttribute('data-theme', 'dark'); - }); - await page.waitForTimeout(100); -} - -/** Attend réseau inactif puis un court délai pour éviter skeletons / images en cours de chargement */ -async function waitForStableNetwork(page: import('@playwright/test').Page) { - await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}); - await page.waitForTimeout(NETWORK_IDLE_MS); -} - -/** Locators des éléments dynamiques à masquer (dates, avatars, temps) */ -async function getDynamicMasks(page: import('@playwright/test').Page): Promise { - const candidates = [ - page.locator('img[alt="Avatar"], img[alt*="avatar"]').first(), - page.locator('[role="timer"]').first(), - page.locator('time').first(), - ]; - const out: import('@playwright/test').Locator[] = []; - for (const loc of candidates) { - if ((await loc.count()) > 0) out.push(loc); - } - return out; -} - -/** Écrit un screenshot dans visual-tests/current ou baselines */ -async function saveScreenshot( - page: import('@playwright/test').Page, - name: string, - options: { fullPage?: boolean; locator?: import('@playwright/test').Locator } = {} -) { - fs.mkdirSync(visualOutputDir, { recursive: true }); - const filePath = path.join(visualOutputDir, screenshotName(name)); - const mask = await getDynamicMasks(page); - const screenshotOpts = { path: filePath, mask: mask.length > 0 ? mask : undefined }; - - if (options.locator) { - await options.locator.screenshot(screenshotOpts); - } else { - await page.screenshot({ fullPage: options.fullPage ?? true, ...screenshotOpts }); - } - test.info().attach(name, { path: filePath, contentType: 'image/png' }); -} - -const SCREENS: Array<{ - name: string; - url: string; - auth: boolean; - full: boolean; - locator?: string; -}> = [ - { name: 'login', url: '/login', auth: false, full: true }, - { name: 'register', url: '/register', auth: false, full: true }, - { name: 'dashboard', url: '/dashboard', auth: true, full: true }, - { name: 'playlists', url: '/playlists', auth: true, full: true }, - { name: 'library', url: '/library', auth: true, full: true }, - { name: '404', url: '/non-existent-route-404', auth: false, full: true }, -]; - -const COMPONENT_CAPTURES: Array<{ name: string; url: string; locator: string }> = [ - { name: 'sidebar', url: '/dashboard', locator: '[data-testid="app-sidebar"]' }, - { name: 'header', url: '/dashboard', locator: 'header' }, - { name: 'player', url: '/dashboard', locator: '[data-testid="global-player"]' }, -]; - -test.describe('Visual capture (complete)', () => { - test.beforeEach(async ({ page }) => { - await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: 'dark' }); - }); - - test.describe('Full-page screens (no auth)', () => { - test.use({ storageState: { cookies: [], origins: [] } }); - for (const screen of SCREENS.filter((s) => !s.auth)) { - test(`${screen.name}`, async ({ page }) => { - const url = BASE_URL.replace(/\/$/, '') + screen.url; - await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }); - await waitForStableNetwork(page); - await page.waitForSelector('body', { timeout: 8000 }).catch(() => {}); - - await disableAnimations(page); - await ensureDarkTheme(page); - await page.waitForTimeout(ANIMATION_SETTLE_MS); - - await saveScreenshot(page, screen.name, { fullPage: true }); - }); - } - }); - - test.describe('Full-page screens (authenticated)', () => { - for (const screen of SCREENS.filter((s) => s.auth)) { - test(`${screen.name}`, async ({ page }) => { - const url = BASE_URL.replace(/\/$/, '') + screen.url; - await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }); - await waitForStableNetwork(page); - await page.waitForSelector('body', { timeout: 8000 }).catch(() => {}); - - await disableAnimations(page); - await ensureDarkTheme(page); - await page.waitForTimeout(ANIMATION_SETTLE_MS); - - await saveScreenshot(page, screen.name, { fullPage: true }); - }); - } - }); - - test.describe('Component screenshots (authenticated)', () => { - for (const comp of COMPONENT_CAPTURES) { - test(comp.name, async ({ page }) => { - const url = BASE_URL.replace(/\/$/, '') + comp.url; - await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }); - await waitForStableNetwork(page); - - const loc = page.locator(comp.locator).first(); - const visible = await loc.waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false); - if (!visible) { - test.skip(true, `${comp.name} locator not visible`); - return; - } - - await disableAnimations(page); - await ensureDarkTheme(page); - await page.waitForTimeout(ANIMATION_SETTLE_MS); - - const mask = await getDynamicMasks(page); - fs.mkdirSync(visualOutputDir, { recursive: true }); - const filePath = path.join(visualOutputDir, screenshotName(comp.name)); - await loc.screenshot({ path: filePath, mask: mask.length > 0 ? mask : undefined }); - test.info().attach(comp.name, { path: filePath, contentType: 'image/png' }); - }); - } - }); -}); diff --git a/apps/web/e2e/visual-regression.spec.ts b/apps/web/e2e/visual-regression.spec.ts deleted file mode 100644 index d7f13354f..000000000 --- a/apps/web/e2e/visual-regression.spec.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { loginAsUser, TEST_CONFIG } from './utils/test-helpers'; - -/** - * Visual Regression Tests - * - * These tests capture screenshots of UI components and pages - * to detect visual regressions. Screenshots are stored in: - * - test-results/visual-regression.spec.ts-snapshots/ - * - * To update screenshots after intentional changes: - * - Run: npx playwright test --update-snapshots - * - * To run only visual tests: - * - Run: npx playwright test visual-regression - */ - -test.describe('Visual Regression Tests', () => { - // Use authenticated state for most tests - test.use({ storageState: 'e2e/.auth/user.json' }); - - test.describe('Authentication Pages', () => { - test('login page visual snapshot', async ({ page }) => { - // Use unauthenticated state for login page - await page.context().clearCookies(); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`); - await page.waitForLoadState('networkidle'); - - // Wait for form to be fully rendered - await page.waitForSelector('form', { timeout: 5000 }); - await page.waitForTimeout(500); // Allow animations to settle - - await expect(page).toHaveScreenshot('login-page.png', { - fullPage: true, - maxDiffPixels: 100, // Allow small differences (fonts, anti-aliasing) - }); - }); - - test('register page visual snapshot', async ({ page }) => { - // Use unauthenticated state for register page - await page.context().clearCookies(); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/register`); - await page.waitForLoadState('networkidle'); - - // Wait for form to be fully rendered - await page.waitForSelector('form', { timeout: 5000 }); - await page.waitForTimeout(500); - - await expect(page).toHaveScreenshot('register-page.png', { - fullPage: true, - maxDiffPixels: 100, - }); - }); - }); - - test.describe('Dashboard Pages', () => { - test('dashboard page visual snapshot', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Wait for main content to load - await page.waitForSelector('main, [role="main"]', { timeout: 10000 }); - await page.waitForTimeout(1000); // Allow data to load - - await expect(page).toHaveScreenshot('dashboard-page.png', { - fullPage: true, - maxDiffPixels: 200, - }); - }); - - test('dashboard header visual snapshot', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Wait for header - const header = page.locator('header').first(); - await header.waitFor({ timeout: 5000 }); - await page.waitForTimeout(500); - - await expect(header).toHaveScreenshot('dashboard-header.png', { - maxDiffPixels: 50, - }); - }); - - test('dashboard sidebar visual snapshot', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - - // Wait for sidebar - const sidebar = page.locator('aside').first(); - await sidebar.waitFor({ timeout: 5000 }); - await page.waitForTimeout(500); - - await expect(sidebar).toHaveScreenshot('dashboard-sidebar.png', { - maxDiffPixels: 50, - }); - }); - }); - - test.describe('Profile Page', () => { - test('profile page visual snapshot', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/profile`); - await page.waitForLoadState('networkidle'); - - // Wait for profile content - await page.waitForSelector('main, [role="main"]', { timeout: 10000 }); - await page.waitForTimeout(1000); - - await expect(page).toHaveScreenshot('profile-page.png', { - fullPage: true, - maxDiffPixels: 200, - }); - }); - }); - - test.describe('Tracks Pages', () => { - test('tracks list page visual snapshot', async ({ page }) => { - // Navigate to tracks page (adjust route as needed) - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/tracks`); - await page.waitForLoadState('networkidle'); - - // Wait for tracks list to load - await page.waitForTimeout(2000); // Allow tracks to load - - await expect(page).toHaveScreenshot('tracks-list-page.png', { - fullPage: true, - maxDiffPixels: 200, - }); - }); - }); - - test.describe('Playlists Pages', () => { - test('playlists page visual snapshot', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/playlists`); - await page.waitForLoadState('networkidle'); - - // Wait for playlists to load - await page.waitForTimeout(2000); - - await expect(page).toHaveScreenshot('playlists-page.png', { - fullPage: true, - maxDiffPixels: 200, - }); - }); - }); - - test.describe('UI Components', () => { - test('button variants visual snapshot', async ({ page }) => { - // Create a test page with button variants - await page.setContent(` - - - - - - -
- - - - - -
- - - `); - - await page.waitForTimeout(500); - - await expect(page).toHaveScreenshot('button-variants.png', { - maxDiffPixels: 50, - }); - }); - - test('card component visual snapshot', async ({ page }) => { - await page.setContent(` - - - - - - -
-

Card Title

-

This is a card component with some content.

-
- - - `); - - await page.waitForTimeout(500); - - await expect(page).toHaveScreenshot('card-component.png', { - maxDiffPixels: 50, - }); - }); - - test('form elements visual snapshot', async ({ page }) => { - await page.setContent(` - - - - - - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
- - - `); - - await page.waitForTimeout(500); - - await expect(page).toHaveScreenshot('form-elements.png', { - maxDiffPixels: 50, - }); - }); - }); - - test.describe('Error States', () => { - test('404 page visual snapshot', async ({ page }) => { - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/non-existent-page`); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); - - await expect(page).toHaveScreenshot('404-page.png', { - fullPage: true, - maxDiffPixels: 100, - }); - }); - }); - - test.describe('Responsive Design', () => { - test('mobile viewport dashboard snapshot', async ({ page }) => { - // Set mobile viewport - await page.setViewportSize({ width: 375, height: 667 }); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); - - await expect(page).toHaveScreenshot('dashboard-mobile.png', { - fullPage: true, - maxDiffPixels: 200, - }); - }); - - test('tablet viewport dashboard snapshot', async ({ page }) => { - // Set tablet viewport - await page.setViewportSize({ width: 768, height: 1024 }); - - await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); - - await expect(page).toHaveScreenshot('dashboard-tablet.png', { - fullPage: true, - maxDiffPixels: 200, - }); - }); - }); -}); - diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/404-page-chromium-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/404-page-chromium-linux.png deleted file mode 100644 index a85aa2f99..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/404-page-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/404-page-firefox-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/404-page-firefox-linux.png deleted file mode 100644 index d7181e8be..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/404-page-firefox-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/404-page-msedge-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/404-page-msedge-linux.png deleted file mode 100644 index 42c172726..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/404-page-msedge-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/button-variants-chromium-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/button-variants-chromium-linux.png deleted file mode 100644 index 8dfa60d30..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/button-variants-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/button-variants-firefox-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/button-variants-firefox-linux.png deleted file mode 100644 index 8a37f7b6c..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/button-variants-firefox-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/button-variants-msedge-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/button-variants-msedge-linux.png deleted file mode 100644 index 8dfa60d30..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/button-variants-msedge-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/card-component-chromium-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/card-component-chromium-linux.png deleted file mode 100644 index 885306b34..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/card-component-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/card-component-firefox-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/card-component-firefox-linux.png deleted file mode 100644 index d84064f10..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/card-component-firefox-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/card-component-msedge-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/card-component-msedge-linux.png deleted file mode 100644 index 885306b34..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/card-component-msedge-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/dashboard-mobile-chromium-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/dashboard-mobile-chromium-linux.png deleted file mode 100644 index 842d48ba6..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/dashboard-mobile-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/dashboard-mobile-firefox-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/dashboard-mobile-firefox-linux.png deleted file mode 100644 index 06ce85792..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/dashboard-mobile-firefox-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/dashboard-mobile-msedge-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/dashboard-mobile-msedge-linux.png deleted file mode 100644 index 0a8a236f6..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/dashboard-mobile-msedge-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/dashboard-tablet-chromium-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/dashboard-tablet-chromium-linux.png deleted file mode 100644 index 886998600..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/dashboard-tablet-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/dashboard-tablet-firefox-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/dashboard-tablet-firefox-linux.png deleted file mode 100644 index 50384671e..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/dashboard-tablet-firefox-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/dashboard-tablet-msedge-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/dashboard-tablet-msedge-linux.png deleted file mode 100644 index 400c64cc4..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/dashboard-tablet-msedge-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/form-elements-chromium-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/form-elements-chromium-linux.png deleted file mode 100644 index 9feb33b5d..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/form-elements-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/form-elements-firefox-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/form-elements-firefox-linux.png deleted file mode 100644 index 878e5b772..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/form-elements-firefox-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/form-elements-msedge-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/form-elements-msedge-linux.png deleted file mode 100644 index 9feb33b5d..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/form-elements-msedge-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/login-page-chromium-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/login-page-chromium-linux.png deleted file mode 100644 index a85aa2f99..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/login-page-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/login-page-firefox-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/login-page-firefox-linux.png deleted file mode 100644 index 0cbb39fb3..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/login-page-firefox-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/login-page-msedge-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/login-page-msedge-linux.png deleted file mode 100644 index 113138925..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/login-page-msedge-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/playlists-page-chromium-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/playlists-page-chromium-linux.png deleted file mode 100644 index a85aa2f99..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/playlists-page-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/playlists-page-firefox-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/playlists-page-firefox-linux.png deleted file mode 100644 index 0cbb39fb3..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/playlists-page-firefox-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/playlists-page-msedge-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/playlists-page-msedge-linux.png deleted file mode 100644 index 113138925..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/playlists-page-msedge-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/register-page-chromium-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/register-page-chromium-linux.png deleted file mode 100644 index a85aa2f99..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/register-page-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/register-page-firefox-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/register-page-firefox-linux.png deleted file mode 100644 index 0cbb39fb3..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/register-page-firefox-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/register-page-msedge-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/register-page-msedge-linux.png deleted file mode 100644 index 113138925..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/register-page-msedge-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/tracks-list-page-chromium-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/tracks-list-page-chromium-linux.png deleted file mode 100644 index a85aa2f99..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/tracks-list-page-chromium-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/tracks-list-page-firefox-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/tracks-list-page-firefox-linux.png deleted file mode 100644 index d7181e8be..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/tracks-list-page-firefox-linux.png and /dev/null differ diff --git a/apps/web/e2e/visual-regression.spec.ts-snapshots/tracks-list-page-msedge-linux.png b/apps/web/e2e/visual-regression.spec.ts-snapshots/tracks-list-page-msedge-linux.png deleted file mode 100644 index 42c172726..000000000 Binary files a/apps/web/e2e/visual-regression.spec.ts-snapshots/tracks-list-page-msedge-linux.png and /dev/null differ diff --git a/apps/web/playwright.config.smoke.ts b/apps/web/playwright.config.smoke.ts deleted file mode 100644 index 1cff5590e..000000000 --- a/apps/web/playwright.config.smoke.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Playwright config for post-deployment smoke tests. - * Does NOT start the dev server - runs against PLAYWRIGHT_BASE_URL or VITE_FRONTEND_URL. - * - * Usage: npx playwright test --config=playwright.config.smoke.ts - */ -import { defineConfig, devices } from '@playwright/test'; - -export default defineConfig({ - testDir: './e2e/tests', - testMatch: '**/smoke-post-deploy.spec.ts', - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - timeout: 30000, - workers: 1, - reporter: [['html'], ['list']], - use: { - baseURL: process.env.PLAYWRIGHT_BASE_URL || process.env.VITE_FRONTEND_URL || 'http://localhost:5173', - trace: 'on-first-retry', - screenshot: 'only-on-failure', - }, - projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], - // No webServer - target must already be running -}); diff --git a/apps/web/playwright.config.storybook.ts b/apps/web/playwright.config.storybook.ts deleted file mode 100644 index fa7bc3a31..000000000 --- a/apps/web/playwright.config.storybook.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -/** - * Playwright config for testing the full Storybook (all stories). - * - * Prerequisite: build Storybook first: - * npm run build-storybook - * - * Then run: - * npm run test:storybook:playwright - * - * The config starts a static server for storybook-static on port 6007 - * (reuse existing if already running). Tests load each story iframe and - * assert no console errors / page crashes. - */ -const STORYBOOK_PORT = 6007; -const STORYBOOK_BASE = `http://localhost:${STORYBOOK_PORT}`; - -export default defineConfig({ - testDir: './e2e/tests/storybook', - testMatch: /.*\.spec\.ts/, - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 1 : 0, - workers: process.env.CI ? 2 : 4, - timeout: 25000, - - outputDir: 'e2e/test-results-storybook', - reporter: [ - ['html', { outputFolder: 'e2e/playwright-report-storybook', open: 'never' }], - ['list'], - ], - - use: { - baseURL: STORYBOOK_BASE, - trace: 'on-first-retry', - screenshot: 'only-on-failure', - video: 'off', - viewport: { width: 1280, height: 720 }, - }, - - projects: [ - { - name: 'chromium', - use: { - ...devices['Desktop Chrome'], - viewport: { width: 1280, height: 720 }, - }, - }, - ], - - webServer: { - command: 'node scripts/serve-storybook-static.cjs', - url: `${STORYBOOK_BASE}/index.json`, - reuseExistingServer: true, - timeout: 60_000, - cwd: process.cwd(), - }, -}); diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts deleted file mode 100644 index 70bcb173f..000000000 --- a/apps/web/playwright.config.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -export default defineConfig({ - testDir: './e2e/tests', - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - // ⚠️ CRITICAL: 1 worker pour éviter rate limiting backend (429) - // Le backend a un rate limiter qui bloque trop de requêtes simultanées - workers: 1, - // 🔴 FIX: Augmenter le timeout global par défaut pour les tests d'upload - timeout: 60000, // 60 secondes par défaut (peut être surchargé par test.setTimeout()) - - // Global Setup: Login once and save authenticated state - // Désactivé temporairement pour les tests de debug - globalSetup: process.env.PLAYWRIGHT_SKIP_GLOBAL_SETUP ? undefined : './e2e/global-setup.ts', - - reporter: [['html'], ['json', { outputFile: 'e2e-results.json' }]], - use: { - baseURL: process.env.PLAYWRIGHT_BASE_URL || process.env.VITE_FRONTEND_URL || `http://localhost:${process.env.PORT || '5173'}`, - trace: 'on-first-retry', - screenshot: 'only-on-failure', - video: 'retain-on-failure', - // Load authenticated state from global setup - storageState: 'e2e/.auth/user.json', - }, - projects: [ - { - name: 'chromium', - use: { - ...devices['Desktop Chrome'], - // Each project can override storageState if needed - storageState: 'e2e/.auth/user.json', - }, - }, - { - name: 'firefox', - use: { - ...devices['Desktop Firefox'], - storageState: 'e2e/.auth/user.json', - }, - }, - { - name: 'webkit', - use: { - ...devices['Desktop Safari'], - storageState: 'e2e/.auth/user.json', - }, - }, - { - name: 'msedge', - use: { - ...devices['Desktop Edge'], - storageState: 'e2e/.auth/user.json', - }, - }, - ], - webServer: { - command: process.env.PORT ? `PORT=${process.env.PORT} npm run dev` : 'npm run dev', - url: `http://localhost:${process.env.PORT || '5173'}`, - reuseExistingServer: process.env.CI ? false : true, - timeout: 300 * 1000, // 5 min for cold start (CI or slow machines) - // CI: ensure Vite proxy targets correct backend (veza.fr:18080) - env: process.env.CI - ? { - ...process.env, - PORT: process.env.PORT || '5174', - VITE_API_URL: process.env.VITE_API_URL || '/api/v1', - VITE_DOMAIN: process.env.VITE_DOMAIN || 'veza.fr', - VITE_BACKEND_PORT: process.env.VITE_BACKEND_PORT || '18080', - } - : undefined, - }, -}); - diff --git a/apps/web/playwright.config.ts.patch b/apps/web/playwright.config.ts.patch deleted file mode 100644 index 9df0016ca..000000000 --- a/apps/web/playwright.config.ts.patch +++ /dev/null @@ -1,32 +0,0 @@ -# PATCH: Réduire workers Playwright pour éviter rate limiting - -## Problème -6 workers = 6 logins simultanés = Rate limit 429 - -## Solution -Réduire à 1-2 workers - -## Fichier à modifier: playwright.config.ts - -```diff -export default defineConfig({ - testDir: './e2e', -- workers: process.env.CI ? 1 : undefined, // Use all CPU cores locally -+ workers: 1, // ⚠️ CRITICAL: 1 worker pour éviter rate limiting backend -``` - -## Alternative: Augmenter timeout entre tests - -Si vous voulez garder plusieurs workers, ajoutez : - -```typescript -globalSetup: './e2e/global-setup.ts', -``` - -Et créez `e2e/global-setup.ts` : -```typescript -export default async function globalSetup() { - // Wait between test files to avoid rate limiting - process.env.PLAYWRIGHT_TEST_BASE_DELAY = '2000'; // 2 seconds -} -``` diff --git a/apps/web/playwright.config.visual.ts b/apps/web/playwright.config.visual.ts deleted file mode 100644 index ad9e92f31..000000000 --- a/apps/web/playwright.config.visual.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; -import path from 'path'; - -/** - * Playwright config for pixel-perfect visual regression (capture & compare). - * - * - Viewport fixe 1280×720, dark mode forcé, reduced motion. - * - Désactivation des animations CSS via inject-style (fixture / beforeEach). - * - Sortie : visual-tests/current/ (capture) ou visual-tests/baselines/ (update). - * - * Usage: - * npm run visual:capture → écrit dans visual-tests/current/ - * npm run visual:update → écrit dans visual-tests/baselines/ - * npm run visual:compare → script generate-visual-report.mjs (baselines vs current) - */ -const VIEWPORT = { width: 1280, height: 720 }; -const THEME = 'dark'; -const VIEWPORT_LABEL = 'desktop'; - -export const visualOutputDir = process.env.VISUAL_UPDATE_BASELINES - ? path.join(process.cwd(), 'visual-tests', 'baselines') - : path.join(process.cwd(), 'visual-tests', 'current'); - -export function screenshotName(screenName: string): string { - return `${screenName}-${VIEWPORT_LABEL}-${THEME}.png`; -} - -export default defineConfig({ - testDir: './e2e', - testMatch: /visual-complete\.spec\.ts/, - fullyParallel: false, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 1 : 0, - workers: 1, - timeout: 45000, - - outputDir: 'e2e/test-results-visual', - reporter: [ - ['html', { outputFolder: 'e2e/playwright-report-visual', open: 'never' }], - ['list'], - ], - - use: { - baseURL: process.env.PLAYWRIGHT_BASE_URL || process.env.VITE_FRONTEND_URL || 'http://localhost:5173', - trace: 'on-first-retry', - screenshot: 'only-on-failure', - video: 'off', - viewport: VIEWPORT, - deviceScaleFactor: 1, - isMobile: false, - hasTouch: false, - locale: 'en-US', - timezoneId: 'Europe/Paris', - reducedMotion: 'reduce', - colorScheme: 'dark', - storageState: process.env.VISUAL_NO_AUTH ? undefined : 'e2e/.auth/user.json', - }, - - projects: [ - { - name: 'chromium-desktop', - use: { - ...devices['Desktop Chrome'], - viewport: VIEWPORT, - deviceScaleFactor: 1, - reducedMotion: 'reduce', - colorScheme: 'dark', - }, - }, - ], - - webServer: { - command: 'npm run dev', - url: 'http://localhost:5173', - reuseExistingServer: true, - timeout: 120_000, - }, -}); diff --git a/playwright.config.ts b/playwright.config.ts deleted file mode 100644 index dfe72abfe..000000000 --- a/playwright.config.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// import dotenv from 'dotenv'; -// import path from 'path'; -// dotenv.config({ path: path.resolve(__dirname, '.env') }); - -/** - * See https://playwright.dev/docs/test-configuration. - */ -export default defineConfig({ - testDir: './e2e', - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('')`. */ - // baseURL: 'http://localhost:3000', - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - }, - - /* Configure projects for major browsers */ - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, - - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, - - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, - - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - // }, - ], - - /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'npm run start', - // url: 'http://localhost:3000', - // reuseExistingServer: !process.env.CI, - // }, -}); diff --git a/tests/debug-input-focus.spec.ts b/tests/debug-input-focus.spec.ts deleted file mode 100644 index a9683a87b..000000000 --- a/tests/debug-input-focus.spec.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { test, expect } from '@playwright/test'; - -/** - * Test de debug pour le problème de focus sur les inputs - * Ce test capture l'état actuel et génère un rapport de debug - */ -test.describe('Debug Input Focus Issue', () => { - test.beforeEach(async ({ page }) => { - // Aller sur la page de login - await page.goto('http://localhost:5173/login'); - await page.waitForLoadState('networkidle'); - }); - - test('Debug: Vérifier les styles CSS des inputs au chargement', async ({ page }) => { - // Attendre que les inputs soient chargés - const emailInput = page.locator('input[type="email"]').first(); - await expect(emailInput).toBeVisible(); - - // Capturer une screenshot - await page.screenshot({ path: 'test-results/debug-input-initial.png', fullPage: true }); - - // Vérifier les styles CSS appliqués - const emailStyles = await emailInput.evaluate((el) => { - const computed = window.getComputedStyle(el); - return { - borderColor: computed.borderColor, - outline: computed.outline, - outlineWidth: computed.outlineWidth, - boxShadow: computed.boxShadow, - ringWidth: computed.getPropertyValue('--tw-ring-width'), - classes: el.className, - hasFocus: document.activeElement === el, - }; - }); - - console.log('📊 Styles de l\'input Email au chargement:'); - console.log(JSON.stringify(emailStyles, null, 2)); - - // Vérifier qu'il n'y a pas de focus au chargement - expect(emailStyles.hasFocus).toBe(false); - expect(emailStyles.borderColor).not.toContain('rgb(102, 252, 241)'); // Pas de cyan - }); - - test('Debug: Vérifier les styles CSS au clic souris', async ({ page }) => { - const emailInput = page.locator('input[type="email"]').first(); - await expect(emailInput).toBeVisible(); - - // Cliquer sur l'input - await emailInput.click(); - await page.waitForTimeout(100); // Attendre que les styles soient appliqués - - // Capturer une screenshot - await page.screenshot({ path: 'test-results/debug-input-after-click.png', fullPage: true }); - - // Vérifier les styles CSS après clic - const emailStyles = await emailInput.evaluate((el) => { - const computed = window.getComputedStyle(el); - return { - borderColor: computed.borderColor, - outline: computed.outline, - outlineWidth: computed.outlineWidth, - boxShadow: computed.boxShadow, - ringWidth: computed.getPropertyValue('--tw-ring-width'), - classes: el.className, - hasFocus: document.activeElement === el, - isFocusVisible: el.matches(':focus-visible'), - }; - }); - - console.log('📊 Styles de l\'input Email après clic:'); - console.log(JSON.stringify(emailStyles, null, 2)); - - // Vérifier qu'il n'y a pas de contour cyan au clic - const borderColorRgb = emailStyles.borderColor; - const hasCyanBorder = borderColorRgb.includes('102') && borderColorRgb.includes('252') && borderColorRgb.includes('241'); - - console.log(`🔍 Border color: ${borderColorRgb}`); - console.log(`🔍 Has cyan border: ${hasCyanBorder}`); - console.log(`🔍 Is focus-visible: ${emailStyles.isFocusVisible}`); - - // Le border ne devrait PAS être cyan au clic (seulement au clavier) - if (hasCyanBorder && !emailStyles.isFocusVisible) { - console.error('❌ PROBLÈME DÉTECTÉ: Border cyan visible au clic souris!'); - console.error(' Le fix CSS ne fonctionne pas correctement.'); - } - }); - - test('Debug: Vérifier les styles CSS au clavier (Tab)', async ({ page }) => { - const emailInput = page.locator('input[type="email"]').first(); - await expect(emailInput).toBeVisible(); - - // Naviguer avec Tab - await page.keyboard.press('Tab'); - await page.waitForTimeout(100); - - // Capturer une screenshot - await page.screenshot({ path: 'test-results/debug-input-after-tab.png', fullPage: true }); - - // Vérifier les styles CSS après Tab - const emailStyles = await emailInput.evaluate((el) => { - const computed = window.getComputedStyle(el); - return { - borderColor: computed.borderColor, - outline: computed.outline, - outlineWidth: computed.outlineWidth, - boxShadow: computed.boxShadow, - ringWidth: computed.getPropertyValue('--tw-ring-width'), - classes: el.className, - hasFocus: document.activeElement === el, - isFocusVisible: el.matches(':focus-visible'), - }; - }); - - console.log('📊 Styles de l\'input Email après Tab:'); - console.log(JSON.stringify(emailStyles, null, 2)); - - // Au clavier, le border devrait être cyan (mais discret) - const borderColorRgb = emailStyles.borderColor; - const hasCyanBorder = borderColorRgb.includes('102') && borderColorRgb.includes('252') && borderColorRgb.includes('241'); - - console.log(`🔍 Border color: ${borderColorRgb}`); - console.log(`🔍 Has cyan border: ${hasCyanBorder}`); - console.log(`🔍 Is focus-visible: ${emailStyles.isFocusVisible}`); - - // Au clavier, le border devrait être cyan - if (emailStyles.isFocusVisible && !hasCyanBorder) { - console.warn('⚠️ Le border cyan n\'apparaît pas au clavier (focus-visible)'); - } - }); - - test('Debug: Analyser toutes les classes CSS appliquées', async ({ page }) => { - const emailInput = page.locator('input[type="email"]').first(); - await expect(emailInput).toBeVisible(); - - // Analyser toutes les classes et styles - const analysis = await emailInput.evaluate((el) => { - const computed = window.getComputedStyle(el); - const allStyles: Record = {}; - - // Récupérer tous les styles CSS - for (let i = 0; i < computed.length; i++) { - const prop = computed[i]; - allStyles[prop] = computed.getPropertyValue(prop); - } - - return { - classes: el.className, - classList: Array.from(el.classList), - hasFocusClass: el.className.includes('focus:'), - hasFocusVisibleClass: el.className.includes('focus-visible:'), - inlineStyle: el.getAttribute('style'), - computedStyles: { - borderColor: computed.borderColor, - borderWidth: computed.borderWidth, - borderStyle: computed.borderStyle, - outline: computed.outline, - outlineWidth: computed.outlineWidth, - boxShadow: computed.boxShadow, - '--tw-ring-width': computed.getPropertyValue('--tw-ring-width'), - '--tw-ring-color': computed.getPropertyValue('--tw-ring-color'), - }, - allStyles: Object.fromEntries( - Object.entries(allStyles).filter(([key]) => - key.includes('border') || - key.includes('outline') || - key.includes('ring') || - key.includes('shadow') - ) - ), - }; - }); - - console.log('📊 Analyse complète de l\'input Email:'); - console.log(JSON.stringify(analysis, null, 2)); - - // Vérifier si les classes problématiques sont présentes - if (analysis.hasFocusClass) { - console.warn('⚠️ Classes focus: détectées dans className:', analysis.classList.filter(c => c.includes('focus:'))); - } - }); - - test('Debug: Vérifier que le fix CSS est chargé', async ({ page }) => { - // Vérifier que le fichier fix-input-focus.css est chargé - const stylesheets = await page.evaluate(() => { - return Array.from(document.styleSheets).map((sheet, index) => { - try { - return { - index, - href: sheet.href || 'inline', - rules: sheet.cssRules ? Array.from(sheet.cssRules).length : 0, - }; - } catch (e) { - return { - index, - href: sheet.href || 'inline', - rules: 'cross-origin', - }; - } - }); - }); - - console.log('📊 Feuilles de style chargées:'); - console.log(JSON.stringify(stylesheets, null, 2)); - - // Vérifier que fix-input-focus.css est présent - const hasFixCss = stylesheets.some(s => s.href && s.href.includes('fix-input-focus')); - console.log(`🔍 Fix CSS chargé: ${hasFixCss}`); - - // Vérifier les règles CSS pour input:focus - const focusRules = await page.evaluate(() => { - const rules: Array<{ selector: string; borderColor?: string }> = []; - Array.from(document.styleSheets).forEach((sheet) => { - try { - if (sheet.cssRules) { - Array.from(sheet.cssRules).forEach((rule: any) => { - if (rule.selectorText && rule.selectorText.includes('input') && rule.selectorText.includes('focus')) { - const style = rule.style; - rules.push({ - selector: rule.selectorText, - borderColor: style.borderColor || style.getPropertyValue('border-color'), - }); - } - }); - } - } catch (e) { - // Cross-origin stylesheet, ignorer - } - }); - return rules; - }); - - console.log('📊 Règles CSS pour input:focus trouvées:'); - console.log(JSON.stringify(focusRules, null, 2)); - }); -}); diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts deleted file mode 100644 index 6bb4776e6..000000000 --- a/tests/e2e/auth.spec.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { test, expect } from '@playwright/test'; - -/** - * P3.3: E2E Authentication Flow Tests - * - * Comprehensive tests for the complete authentication flow to prevent regressions. - * Tests cover login, register, logout, session persistence, and token refresh. - */ - -const BASE_URL = 'http://localhost:5173'; -const API_URL = 'http://localhost:8080'; - -// Test credentials -const TEST_USER = { - email: 'test@veza.app', - username: 'testuser', - password: 'test123', -}; - -const NEW_USER = { - email: `test-${Date.now()}@veza.app`, - username: `testuser${Date.now()}`, - password: 'test123', -}; - -test.describe('Authentication Flow', () => { - test.beforeEach(async ({ page }) => { - // Clear cookies and storage before each test - await page.context().clearCookies(); - await page.goto(BASE_URL); - }); - - test('login with valid credentials should succeed', async ({ page }) => { - // Navigate to login page - await page.goto(`${BASE_URL}/login`); - - // Fill in credentials - await page.fill('input[name="email"], input[type="email"]', TEST_USER.email); - await page.fill('input[name="password"], input[type="password"]', TEST_USER.password); - - // Submit form - await page.click('button[type="submit"]'); - - // Should redirect to dashboard or home - await expect(page).toHaveURL(/\/(dashboard|home|library)/); - - // Verify user is authenticated (check for logout button or user menu) - await expect(page.locator('text=/logout/i, [aria-label*="logout"], [data-testid="logout"]')).toBeVisible({ timeout: 5000 }); - }); - - test('login with invalid credentials should fail', async ({ page }) => { - await page.goto(`${BASE_URL}/login`); - - // Fill in invalid credentials - await page.fill('input[name="email"], input[type="email"]', 'invalid@example.com'); - await page.fill('input[name="password"], input[type="password"]', 'wrongpassword'); - - // Submit form - await page.click('button[type="submit"]'); - - // Should show error message - await expect(page.locator('text=/invalid|incorrect|failed/i')).toBeVisible({ timeout: 3000 }); - - // Should stay on login page - await expect(page).toHaveURL(/\/login/); - }); - - test('session should persist after page refresh', async ({ page }) => { - // Login first - await page.goto(`${BASE_URL}/login`); - await page.fill('input[name="email"], input[type="email"]', TEST_USER.email); - await page.fill('input[name="password"], input[type="password"]', TEST_USER.password); - await page.click('button[type="submit"]'); - - // Wait for redirect - await expect(page).toHaveURL(/\/(dashboard|home|library)/); - - // Refresh the page - await page.reload(); - - // Should still be authenticated (not redirected to login) - await expect(page).not.toHaveURL(/\/login/); - await expect(page.locator('text=/logout/i, [aria-label*="logout"]')).toBeVisible({ timeout: 5000 }); - }); - - test('logout should clear session and redirect to login', async ({ page }) => { - // Login first - await page.goto(`${BASE_URL}/login`); - await page.fill('input[name="email"], input[type="email"]', TEST_USER.email); - await page.fill('input[name="password"], input[type="password"]', TEST_USER.password); - await page.click('button[type="submit"]'); - - // Wait for redirect - await expect(page).toHaveURL(/\/(dashboard|home|library)/); - - // Click logout - await page.click('text=/logout/i, [aria-label*="logout"], [data-testid="logout"]'); - - // Should redirect to login - await expect(page).toHaveURL(/\/login/); - - // Try to access protected route - should redirect back to login - await page.goto(`${BASE_URL}/dashboard`); - await expect(page).toHaveURL(/\/login/); - }); - - test('register new user should succeed', async ({ page }) => { - await page.goto(`${BASE_URL}/register`); - - // Fill in registration form - await page.fill('input[name="email"], input[type="email"]', NEW_USER.email); - await page.fill('input[name="username"]', NEW_USER.username); - await page.fill('input[name="password"], input[type="password"]', NEW_USER.password); - - // May have password confirmation field - const confirmPasswordField = page.locator('input[name="confirmPassword"], input[name="password_confirmation"]'); - if (await confirmPasswordField.isVisible()) { - await confirmPasswordField.fill(NEW_USER.password); - } - - // Submit form - await page.click('button[type="submit"]'); - - // Should redirect to dashboard or show success message - await expect(page).toHaveURL(/\/(dashboard|home|library|login)/, { timeout: 10000 }); - }); - - test('protected routes should redirect to login when not authenticated', async ({ page }) => { - // Try to access protected routes without authentication - const protectedRoutes = ['/dashboard', '/library', '/playlists', '/profile']; - - for (const route of protectedRoutes) { - await page.goto(`${BASE_URL}${route}`); - - // Should redirect to login - await expect(page).toHaveURL(/\/login/, { timeout: 3000 }); - } - }); - - test('health endpoint should be accessible', async ({ page }) => { - // Test the health endpoint created in P1.6 - const response = await page.request.get(`${API_URL}/api/v1/health`); - - expect(response.ok()).toBeTruthy(); - - const data = await response.json(); - expect(data.status).toBe('ok'); - expect(data.timestamp).toBeDefined(); - }); - - test('CORS headers should be present on API requests', async ({ page }) => { - await page.goto(`${BASE_URL}/login`); - - // Make a request to the API - const response = await page.request.get(`${API_URL}/api/v1/health`, { - headers: { - 'Origin': BASE_URL, - }, - }); - - // Check for CORS headers (P1.1 fix) - const headers = response.headers(); - expect(headers['access-control-allow-origin']).toBeDefined(); - }); -}); - -test.describe('Token Refresh Flow', () => { - test('should handle token refresh gracefully', async ({ page }) => { - // Login - await page.goto(`${BASE_URL}/login`); - await page.fill('input[name="email"], input[type="email"]', TEST_USER.email); - await page.fill('input[name="password"], input[type="password"]', TEST_USER.password); - await page.click('button[type="submit"]'); - - await expect(page).toHaveURL(/\/(dashboard|home|library)/); - - // Wait for potential token refresh (access token expires after 15min in prod) - // In dev, we can't easily test this without mocking time - // But we can verify the app doesn't crash on refresh - await page.reload(); - - // Should still be authenticated - await expect(page).not.toHaveURL(/\/login/); - }); - - test('should logout after max refresh attempts (P1.4)', async ({ page }) => { - // This test would require mocking the backend to return 401 repeatedly - // For now, we just document the expected behavior: - // - After 3 failed refresh attempts, user should be logged out - // - User should see error message: "Session expired after multiple attempts" - - // Placeholder test - would need backend mocking to implement fully - expect(true).toBe(true); - }); -}); - -test.describe('CSRF Protection', () => { - test('mutations should include CSRF token (P1.3)', async ({ page }) => { - // Login first - await page.goto(`${BASE_URL}/login`); - await page.fill('input[name="email"], input[type="email"]', TEST_USER.email); - await page.fill('input[name="password"], input[type="password"]', TEST_USER.password); - - // Intercept POST requests to verify CSRF token - let csrfTokenPresent = false; - page.on('request', (request) => { - if (request.method() === 'POST' && request.url().includes('/api/')) { - const headers = request.headers(); - if (headers['x-csrf-token']) { - csrfTokenPresent = true; - } - } - }); - - await page.click('button[type="submit"]'); - - // Wait for request to complete - await page.waitForTimeout(1000); - - // CSRF token should be present on POST requests - expect(csrfTokenPresent).toBe(true); - }); -}); diff --git a/veza-backend-api/migrations/076_create_gear_items_down.sql b/veza-backend-api/migrations/rollback/076_create_gear_items_down.sql similarity index 100% rename from veza-backend-api/migrations/076_create_gear_items_down.sql rename to veza-backend-api/migrations/rollback/076_create_gear_items_down.sql diff --git a/veza-backend-api/migrations/077_create_live_streams_down.sql b/veza-backend-api/migrations/rollback/077_create_live_streams_down.sql similarity index 100% rename from veza-backend-api/migrations/077_create_live_streams_down.sql rename to veza-backend-api/migrations/rollback/077_create_live_streams_down.sql diff --git a/veza-backend-api/migrations/078_add_missing_indexes_down.sql b/veza-backend-api/migrations/rollback/078_add_missing_indexes_down.sql similarity index 100% rename from veza-backend-api/migrations/078_add_missing_indexes_down.sql rename to veza-backend-api/migrations/rollback/078_add_missing_indexes_down.sql diff --git a/veza-backend-api/migrations/082_create_api_keys_down.sql b/veza-backend-api/migrations/rollback/082_create_api_keys_down.sql similarity index 100% rename from veza-backend-api/migrations/082_create_api_keys_down.sql rename to veza-backend-api/migrations/rollback/082_create_api_keys_down.sql diff --git a/veza-backend-api/migrations/125_follow_counts_triggers_down.sql b/veza-backend-api/migrations/rollback/125_follow_counts_triggers_down.sql similarity index 100% rename from veza-backend-api/migrations/125_follow_counts_triggers_down.sql rename to veza-backend-api/migrations/rollback/125_follow_counts_triggers_down.sql diff --git a/veza-backend-api/migrations/129_playlist_editorial_down.sql b/veza-backend-api/migrations/rollback/129_playlist_editorial_down.sql similarity index 100% rename from veza-backend-api/migrations/129_playlist_editorial_down.sql rename to veza-backend-api/migrations/rollback/129_playlist_editorial_down.sql diff --git a/veza-backend-api/migrations/132_quiet_hours_down.sql b/veza-backend-api/migrations/rollback/132_quiet_hours_down.sql similarity index 100% rename from veza-backend-api/migrations/132_quiet_hours_down.sql rename to veza-backend-api/migrations/rollback/132_quiet_hours_down.sql diff --git a/veza-backend-api/migrations/133_notification_grouping_down.sql b/veza-backend-api/migrations/rollback/133_notification_grouping_down.sql similarity index 100% rename from veza-backend-api/migrations/133_notification_grouping_down.sql rename to veza-backend-api/migrations/rollback/133_notification_grouping_down.sql diff --git a/veza-backend-api/migrations/134_weekly_digest_prefs_down.sql b/veza-backend-api/migrations/rollback/134_weekly_digest_prefs_down.sql similarity index 100% rename from veza-backend-api/migrations/134_weekly_digest_prefs_down.sql rename to veza-backend-api/migrations/rollback/134_weekly_digest_prefs_down.sql diff --git a/veza-backend-api/migrations/900_triggers_and_functions_down.sql b/veza-backend-api/migrations/rollback/900_triggers_and_functions_down.sql similarity index 100% rename from veza-backend-api/migrations/900_triggers_and_functions_down.sql rename to veza-backend-api/migrations/rollback/900_triggers_and_functions_down.sql diff --git a/veza-backend-api/migrations/910_create_audit_logs_down.sql b/veza-backend-api/migrations/rollback/910_create_audit_logs_down.sql similarity index 100% rename from veza-backend-api/migrations/910_create_audit_logs_down.sql rename to veza-backend-api/migrations/rollback/910_create_audit_logs_down.sql diff --git a/veza-backend-api/migrations/920_add_performance_indexes_down.sql b/veza-backend-api/migrations/rollback/920_add_performance_indexes_down.sql similarity index 100% rename from veza-backend-api/migrations/920_add_performance_indexes_down.sql rename to veza-backend-api/migrations/rollback/920_add_performance_indexes_down.sql diff --git a/veza-backend-api/migrations/930_add_missing_foreign_keys_down.sql b/veza-backend-api/migrations/rollback/930_add_missing_foreign_keys_down.sql similarity index 100% rename from veza-backend-api/migrations/930_add_missing_foreign_keys_down.sql rename to veza-backend-api/migrations/rollback/930_add_missing_foreign_keys_down.sql diff --git a/veza-backend-api/migrations/931_add_refresh_tokens_updated_at_down.sql b/veza-backend-api/migrations/rollback/931_add_refresh_tokens_updated_at_down.sql similarity index 100% rename from veza-backend-api/migrations/931_add_refresh_tokens_updated_at_down.sql rename to veza-backend-api/migrations/rollback/931_add_refresh_tokens_updated_at_down.sql diff --git a/veza-backend-api/migrations/940_performance_indexes_v0951_down.sql b/veza-backend-api/migrations/rollback/940_performance_indexes_v0951_down.sql similarity index 100% rename from veza-backend-api/migrations/940_performance_indexes_v0951_down.sql rename to veza-backend-api/migrations/rollback/940_performance_indexes_v0951_down.sql diff --git a/veza-backend-api/migrations/941_notification_prefs_defaults_v0105_down.sql b/veza-backend-api/migrations/rollback/941_notification_prefs_defaults_v0105_down.sql similarity index 100% rename from veza-backend-api/migrations/941_notification_prefs_defaults_v0105_down.sql rename to veza-backend-api/migrations/rollback/941_notification_prefs_defaults_v0105_down.sql diff --git a/veza-backend-api/migrations/942_create_co_listening_sessions_down.sql b/veza-backend-api/migrations/rollback/942_create_co_listening_sessions_down.sql similarity index 100% rename from veza-backend-api/migrations/942_create_co_listening_sessions_down.sql rename to veza-backend-api/migrations/rollback/942_create_co_listening_sessions_down.sql diff --git a/veza-backend-api/migrations/943_create_track_stems_down.sql b/veza-backend-api/migrations/rollback/943_create_track_stems_down.sql similarity index 100% rename from veza-backend-api/migrations/943_create_track_stems_down.sql rename to veza-backend-api/migrations/rollback/943_create_track_stems_down.sql diff --git a/veza-backend-api/migrations/944_create_data_exports_down.sql b/veza-backend-api/migrations/rollback/944_create_data_exports_down.sql similarity index 100% rename from veza-backend-api/migrations/944_create_data_exports_down.sql rename to veza-backend-api/migrations/rollback/944_create_data_exports_down.sql diff --git a/veza-backend-api/migrations/945_creator_analytics_v0110_down.sql b/veza-backend-api/migrations/rollback/945_creator_analytics_v0110_down.sql similarity index 100% rename from veza-backend-api/migrations/945_creator_analytics_v0110_down.sql rename to veza-backend-api/migrations/rollback/945_creator_analytics_v0110_down.sql diff --git a/veza-backend-api/migrations/946_advanced_analytics_v0111_down.sql b/veza-backend-api/migrations/rollback/946_advanced_analytics_v0111_down.sql similarity index 100% rename from veza-backend-api/migrations/946_advanced_analytics_v0111_down.sql rename to veza-backend-api/migrations/rollback/946_advanced_analytics_v0111_down.sql diff --git a/veza-backend-api/migrations/947_moderation_advanced_v0112_down.sql b/veza-backend-api/migrations/rollback/947_moderation_advanced_v0112_down.sql similarity index 100% rename from veza-backend-api/migrations/947_moderation_advanced_v0112_down.sql rename to veza-backend-api/migrations/rollback/947_moderation_advanced_v0112_down.sql diff --git a/veza-backend-api/migrations/948_marketplace_complete_v0120_down.sql b/veza-backend-api/migrations/rollback/948_marketplace_complete_v0120_down.sql similarity index 100% rename from veza-backend-api/migrations/948_marketplace_complete_v0120_down.sql rename to veza-backend-api/migrations/rollback/948_marketplace_complete_v0120_down.sql diff --git a/veza-backend-api/migrations/949_subscription_plans_v0121_down.sql b/veza-backend-api/migrations/rollback/949_subscription_plans_v0121_down.sql similarity index 100% rename from veza-backend-api/migrations/949_subscription_plans_v0121_down.sql rename to veza-backend-api/migrations/rollback/949_subscription_plans_v0121_down.sql diff --git a/veza-backend-api/migrations/950_distribution_platforms_v0122_down.sql b/veza-backend-api/migrations/rollback/950_distribution_platforms_v0122_down.sql similarity index 100% rename from veza-backend-api/migrations/950_distribution_platforms_v0122_down.sql rename to veza-backend-api/migrations/rollback/950_distribution_platforms_v0122_down.sql diff --git a/veza-backend-api/migrations/951_education_courses_v0123_down.sql b/veza-backend-api/migrations/rollback/951_education_courses_v0123_down.sql similarity index 100% rename from veza-backend-api/migrations/951_education_courses_v0123_down.sql rename to veza-backend-api/migrations/rollback/951_education_courses_v0123_down.sql diff --git a/veza-backend-api/migrations/960_performance_indexes_v0124_down.sql b/veza-backend-api/migrations/rollback/960_performance_indexes_v0124_down.sql similarity index 100% rename from veza-backend-api/migrations/960_performance_indexes_v0124_down.sql rename to veza-backend-api/migrations/rollback/960_performance_indexes_v0124_down.sql diff --git a/veza-backend-api/migrations/970_password_login_history_v0130_down.sql b/veza-backend-api/migrations/rollback/970_password_login_history_v0130_down.sql similarity index 100% rename from veza-backend-api/migrations/970_password_login_history_v0130_down.sql rename to veza-backend-api/migrations/rollback/970_password_login_history_v0130_down.sql diff --git a/veza-backend-api/migrations/971_security_advanced_v0133_down.sql b/veza-backend-api/migrations/rollback/971_security_advanced_v0133_down.sql similarity index 100% rename from veza-backend-api/migrations/971_security_advanced_v0133_down.sql rename to veza-backend-api/migrations/rollback/971_security_advanced_v0133_down.sql diff --git a/veza-backend-api/migrations/972_seller_kyc_v0135_down.sql b/veza-backend-api/migrations/rollback/972_seller_kyc_v0135_down.sql similarity index 100% rename from veza-backend-api/migrations/972_seller_kyc_v0135_down.sql rename to veza-backend-api/migrations/rollback/972_seller_kyc_v0135_down.sql diff --git a/veza-backend-api/migrations/973_support_tickets_v0135_down.sql b/veza-backend-api/migrations/rollback/973_support_tickets_v0135_down.sql similarity index 100% rename from veza-backend-api/migrations/973_support_tickets_v0135_down.sql rename to veza-backend-api/migrations/rollback/973_support_tickets_v0135_down.sql