chore: cleanup old e2e tests, playwright configs, reorganize down migrations
- Remove old apps/web/e2e/ test suite (replaced by tests/e2e/) - Remove old playwright configs (smoke, storybook, visual, root) - Move down migrations to veza-backend-api/migrations/rollback/ - Remove stale test results and playwright report artifacts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"cookies": [],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "http://localhost:5173",
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "i18nextLng",
|
||||
"value": "en-US"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
```
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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<string, string> = {};
|
||||
|
||||
// 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));
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 18 KiB |
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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)?`));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 23 KiB |
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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<PerformanceMetrics> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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" <<EOF
|
||||
-- Ajouter le rôle artist s'il n'existe pas
|
||||
-- NOTE: display_name est NOT NULL, il faut le fournir
|
||||
INSERT INTO roles (name, display_name, description)
|
||||
VALUES ('artist', 'Artist', 'Artist role for content creation')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Ajouter le rôle à l'utilisateur
|
||||
-- Ajouter le rôle à l'utilisateur
|
||||
-- NOTE: La colonne 'role' est aussi requise dans user_roles (pour compatibilité)
|
||||
INSERT INTO user_roles (user_id, role_id, role)
|
||||
SELECT
|
||||
u.id,
|
||||
r.id,
|
||||
'artist'
|
||||
FROM users u
|
||||
CROSS JOIN roles r
|
||||
WHERE u.email = '$TEST_USER_EMAIL'
|
||||
AND r.name = 'artist'
|
||||
ON CONFLICT (user_id, role) DO NOTHING;
|
||||
|
||||
-- Vérifier l'email de l'utilisateur (nécessaire pour certains endpoints)
|
||||
UPDATE users
|
||||
SET is_verified = true
|
||||
WHERE email = '$TEST_USER_EMAIL';
|
||||
|
||||
-- Vérification
|
||||
SELECT
|
||||
u.email,
|
||||
r.name as role_name,
|
||||
u.is_verified
|
||||
FROM users u
|
||||
LEFT JOIN user_roles ur ON u.id = ur.user_id
|
||||
LEFT JOIN roles r ON ur.role_id = r.id
|
||||
WHERE u.email = '$TEST_USER_EMAIL';
|
||||
EOF
|
||||
echo "✅ [SETUP] Test user role updated successfully!"
|
||||
|
||||
# Option 2: Utiliser Docker exec si la DB est dans Docker
|
||||
elif command -v docker &> /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" <<EOF
|
||||
-- Ajouter le rôle artist s'il n'existe pas
|
||||
-- NOTE: display_name est NOT NULL, il faut le fournir
|
||||
INSERT INTO roles (name, display_name, description)
|
||||
VALUES ('artist', 'Artist', 'Artist role for content creation')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Ajouter le rôle à l'utilisateur
|
||||
-- NOTE: La colonne 'role' est aussi requise dans user_roles (pour compatibilité)
|
||||
INSERT INTO user_roles (user_id, role_id, role)
|
||||
SELECT
|
||||
u.id,
|
||||
r.id,
|
||||
'artist'
|
||||
FROM users u
|
||||
CROSS JOIN roles r
|
||||
WHERE u.email = '$TEST_USER_EMAIL'
|
||||
AND r.name = 'artist'
|
||||
ON CONFLICT (user_id, role) DO NOTHING;
|
||||
|
||||
-- Vérifier l'email de l'utilisateur (nécessaire pour certains endpoints)
|
||||
UPDATE users
|
||||
SET is_verified = true
|
||||
WHERE email = '$TEST_USER_EMAIL';
|
||||
|
||||
-- Vérification
|
||||
SELECT u.email, r.name as role_name, u.is_verified
|
||||
FROM users u
|
||||
LEFT JOIN user_roles ur ON u.id = ur.user_id
|
||||
LEFT JOIN roles r ON ur.role_id = r.id
|
||||
WHERE u.email = '$TEST_USER_EMAIL';
|
||||
EOF
|
||||
echo "✅ [SETUP] Test user role updated successfully!"
|
||||
else
|
||||
echo "❌ [SETUP] Neither psql nor Docker found. Please run the SQL manually:"
|
||||
echo ""
|
||||
cat "$(dirname "$0")/setup-test-user-role.sql"
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
-- Script SQL pour promouvoir l'utilisateur de test en "artist"
|
||||
-- Ce script doit être exécuté AVANT les tests E2E pour permettre les uploads
|
||||
|
||||
-- Option 1: Mettre à jour directement le rôle dans la table users (si le champ existe)
|
||||
-- UPDATE users SET role = 'artist' WHERE email = 'user@example.com';
|
||||
|
||||
-- Option 2: Ajouter le rôle via la table user_roles (recommandé si RBAC est utilisé)
|
||||
-- D'abord, vérifier si le rôle "artist" existe dans la table roles
|
||||
-- NOTE: display_name est NOT NULL, il faut le fournir
|
||||
INSERT INTO roles (name, display_name, description)
|
||||
VALUES ('artist', 'Artist', 'Artist role for content creation')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Ensuite, ajouter le rôle à l'utilisateur
|
||||
-- NOTE: La colonne 'role' est aussi requise dans user_roles (pour compatibilité)
|
||||
INSERT INTO user_roles (user_id, role_id, role)
|
||||
SELECT
|
||||
u.id,
|
||||
r.id,
|
||||
'artist'
|
||||
FROM users u
|
||||
CROSS JOIN roles r
|
||||
WHERE u.email = 'user@example.com'
|
||||
AND r.name = 'artist'
|
||||
ON CONFLICT (user_id, role) DO NOTHING;
|
||||
|
||||
-- Vérifier l'email de l'utilisateur (nécessaire pour certains endpoints)
|
||||
UPDATE users
|
||||
SET is_verified = true
|
||||
WHERE email = 'user@example.com';
|
||||
|
||||
-- Vérification
|
||||
SELECT
|
||||
u.email,
|
||||
u.id as user_id,
|
||||
r.name as role_name
|
||||
FROM users u
|
||||
LEFT JOIN user_roles ur ON u.id = ur.user_id
|
||||
LEFT JOIN roles r ON ur.role_id = r.id
|
||||
WHERE u.email = 'user@example.com';
|
||||
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 414 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
|
|
@ -1,559 +0,0 @@
|
|||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
TEST_CONFIG,
|
||||
TEST_USERS,
|
||||
loginAsUser,
|
||||
forceSubmitForm,
|
||||
fillField,
|
||||
waitForToast,
|
||||
setupErrorCapture,
|
||||
getAuthToken,
|
||||
} from '../utils/test-helpers';
|
||||
|
||||
/**
|
||||
* Auth E2E Test Suite
|
||||
*
|
||||
* Couvre l'ensemble du cycle d'authentification :
|
||||
* - Registration (Inscription)
|
||||
* - Login (Connexion)
|
||||
* - Logout (Déconnexion)
|
||||
* - Route Guards (Redirection si non authentifié)
|
||||
* - Token Refresh (Rafraîchissement automatique)
|
||||
*/
|
||||
|
||||
test.describe('Authentication Flow', () => {
|
||||
// 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('; '),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 226 KiB |
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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));
|
||||
}
|
||||