import { test, expect } from '@chromatic-com/playwright'; import { loginViaAPI, CONFIG, navigateTo, assertNotBroken, assertNoDebugText, collectNetworkErrors, playFirstTrack, SELECTORS, } from './helpers'; // ============================================================================= // EDGE CASES — Formulaires vides // ============================================================================= test.describe('EDGE CASES — Formulaires vides', () => { test('01. Submit empty login form shows validation errors', async ({ page }) => { await navigateTo(page, '/login'); // Click submit without filling anything const submitBtn = page.getByRole('button', { name: /sign in|se connecter/i }); await submitBtn.click(); // Should stay on login page await expect(page).toHaveURL(/login/); // Should show validation error(s) or HTML5 validation prevents submission const body = await page.textContent('body') || ''; const hasValidation = /required|obligatoire|email|invalid|invalide/i.test(body); const emailInput = page.locator('input[type="email"]').first(); const validationMessage = await emailInput.evaluate( (el: HTMLInputElement) => el.validationMessage, ).catch(() => ''); expect(hasValidation || validationMessage.length > 0).toBeTruthy(); }); test('02. Submit empty register form shows validation errors', async ({ page }) => { await navigateTo(page, '/register'); // Click submit without filling anything const submitBtn = page.getByRole('button', { name: /s'inscrire|create account/i }); await submitBtn.click(); // Should stay on register page await expect(page).toHaveURL(/register/); // Check for validation errors const body = await page.textContent('body') || ''; const hasValidation = /required|obligatoire|invalid|invalide|trop court|too short/i.test(body); const usernameInput = page.locator('#register-username'); const validationMessage = await usernameInput.evaluate( (el: HTMLInputElement) => el.validationMessage, ).catch(() => ''); expect(hasValidation || validationMessage.length > 0).toBeTruthy(); }); test('03. Submit empty search does not crash', async ({ page }) => { await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); await navigateTo(page, '/search'); const searchInput = page.locator('input[role="combobox"][aria-label="Search"]') .or(page.getByPlaceholder(/search for tracks/i)); const searchVisible = await searchInput.first().isVisible().catch(() => false); test.skip(!searchVisible, 'Search input not visible on this page'); // Clear the input and press Enter await searchInput.first().fill(''); await searchInput.first().press('Enter'); await page.waitForTimeout(1_000); await assertNotBroken(page); }); test('04. Login with only email filled shows password error', async ({ page }) => { await navigateTo(page, '/login'); await page.getByLabel(/^email$/i).or(page.locator('input[type="email"]')).first().fill('test@example.com'); await page.getByRole('button', { name: /sign in|se connecter/i }).click(); // Should stay on login await expect(page).toHaveURL(/login/); // Password field should show validation const passwordInput = page.locator('input[type="password"]').first(); const validationMessage = await passwordInput.evaluate( (el: HTMLInputElement) => el.validationMessage, ).catch(() => ''); const body = await page.textContent('body') || ''; const hasError = validationMessage.length > 0 || /required|password|mot de passe/i.test(body); expect(hasError).toBeTruthy(); }); }); // ============================================================================= // EDGE CASES — Caracteres speciaux et injection // ============================================================================= test.describe('EDGE CASES — Caracteres speciaux', () => { test.beforeEach(async ({ page }) => { await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); }); test('05. XSS attempt in search does not execute @critical', async ({ page }) => { await navigateTo(page, '/search'); const searchInput = page.locator('input[role="combobox"][aria-label="Search"]') .or(page.getByPlaceholder(/search for tracks/i)); const searchVisible = await searchInput.first().isVisible().catch(() => false); test.skip(!searchVisible, 'Search input not visible on this page'); const xssPayload = ''; await searchInput.first().fill(xssPayload); await page.waitForTimeout(1_500); // Verify no alert dialog appeared const body = await page.textContent('body') || ''; expect(body).not.toMatch(/crash|TypeError/i); // The script tag should be sanitized — not rendered as HTML const scriptElements = await page.locator('script:has-text("xss")').count(); expect(scriptElements).toBe(0); }); test('06. SQL injection attempt in search does not crash @critical', async ({ page }) => { await navigateTo(page, '/search'); const searchInput = page.locator('input[role="combobox"][aria-label="Search"]') .or(page.getByPlaceholder(/search for tracks/i)); const searchVisible = await searchInput.first().isVisible().catch(() => false); test.skip(!searchVisible, 'Search input not visible on this page'); const sqlPayload = "'; DROP TABLE users; --"; await searchInput.first().fill(sqlPayload); await page.waitForTimeout(1_500); const body = await page.textContent('body') || ''; expect(body).not.toMatch(/crash|TypeError|syntax error|SQL/i); }); test('07. Very long string in search does not crash', async ({ page }) => { await navigateTo(page, '/search'); const searchInput = page.locator('input[role="combobox"][aria-label="Search"]') .or(page.getByPlaceholder(/search for tracks/i)); const searchVisible = await searchInput.first().isVisible().catch(() => false); test.skip(!searchVisible, 'Search input not visible on this page'); const longString = 'a'.repeat(600); await searchInput.first().fill(longString); await page.waitForTimeout(1_500); await assertNotBroken(page); }); test('08. Emoji search works without crash', async ({ page }) => { await navigateTo(page, '/search'); const searchInput = page.locator('input[role="combobox"][aria-label="Search"]') .or(page.getByPlaceholder(/search for tracks/i)); const searchVisible = await searchInput.first().isVisible().catch(() => false); test.skip(!searchVisible, 'Search input not visible on this page'); await searchInput.first().fill('music vibes'); await page.waitForTimeout(1_500); await assertNotBroken(page); }); test('09. Unicode and special characters in search', async ({ page }) => { await navigateTo(page, '/search'); const searchInput = page.locator('input[role="combobox"][aria-label="Search"]') .or(page.getByPlaceholder(/search for tracks/i)); const searchVisible = await searchInput.first().isVisible().catch(() => false); test.skip(!searchVisible, 'Search input not visible on this page'); const specialChars = 'cafe\u0301 mu\u0308sik \u00e9l\u00e8ve \u00f1'; await searchInput.first().fill(specialChars); await page.waitForTimeout(1_500); await assertNotBroken(page); }); test('10. HTML entities in login email field', async ({ page }) => { // This test needs to be on the login page, so clear the authenticated state // (beforeEach logged in via API — we need to undo that for this test) await page.evaluate(() => localStorage.removeItem('auth-storage')); await page.context().clearCookies(); await navigateTo(page, '/login'); // Wait for the login form to be fully visible before interacting await page.getByTestId('login-form').waitFor({ state: 'visible', timeout: 15_000 }); const emailInput = page.locator('input[type="email"]'); await emailInput.first().waitFor({ state: 'visible', timeout: 10_000 }); await emailInput.first().fill('test&bold@example.com'); await page.getByLabel(/^password$|^mot de passe$/i).or(page.locator('input[type="password"]')).first().fill('Password123!'); await page.getByRole('button', { name: /sign in|se connecter/i }).click(); // Should show error (invalid email format), not crash await page.waitForTimeout(1_500); const body = await page.textContent('body') || ''; expect(body).not.toMatch(/crash|TypeError/i); }); }); // ============================================================================= // EDGE CASES — Erreurs reseau // ============================================================================= test.describe('EDGE CASES — Erreurs reseau', () => { test('11. Simulated 500 error on API shows error message, no crash @critical', async ({ page }) => { await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); // Intercept a common API route and return 500 await page.route('**/api/v1/tracks**', (route) => { route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: { code: 'INTERNAL_ERROR', message: 'Simulated server error' } }), }); }); await navigateTo(page, '/library'); await page.waitForTimeout(2_000); // Page should not crash — should show an error state or empty state const body = await page.textContent('body') || ''; expect(body).not.toMatch(/crash|TypeError|Cannot read/i); expect(body.length).toBeGreaterThan(50); }); test('12. Simulated network timeout shows loading or error state', async ({ page }) => { await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); // Intercept API and simulate a timeout (abort after delay) await page.route('**/api/v1/search**', async (route) => { // Delay then abort to simulate timeout await new Promise((resolve) => setTimeout(resolve, 5_000)); route.abort('timedout'); }); await navigateTo(page, '/search'); const searchInput = page.locator('input[role="combobox"][aria-label="Search"]') .or(page.getByPlaceholder(/search for tracks/i)); const searchVisible = await searchInput.first().isVisible().catch(() => false); test.skip(!searchVisible, 'Search input not visible on this page'); await searchInput.first().fill('timeout test'); // Wait a moment - should show loading indicator or remain stable await page.waitForTimeout(2_000); const body = await page.textContent('body') || ''; expect(body).not.toMatch(/crash|TypeError|Cannot read/i); }); test('13. API returning malformed JSON does not crash page', async ({ page }) => { await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); await page.route('**/api/v1/tracks**', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: '{ invalid json !!!', }); }); await navigateTo(page, '/library'); await page.waitForTimeout(2_000); const body = await page.textContent('body') || ''; expect(body).not.toMatch(/crash|TypeError|Unexpected token/i); expect(body.length).toBeGreaterThan(50); }); }); // ============================================================================= // EDGE CASES — Ressources inexistantes // ============================================================================= test.describe('EDGE CASES — Ressources inexistantes', () => { test.beforeEach(async ({ page }) => { await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); }); test('14. /tracks/nonexistent-id shows 404 or error page @critical', async ({ page }) => { await navigateTo(page, '/tracks/nonexistent-id-99999'); const body = await page.textContent('body') || ''; // Should show a 404 page, error message, or redirect — not crash expect(body).not.toMatch(/crash|TypeError|Cannot read/i); const handled = /not found|introuvable|404|error|does not exist|n'existe pas/i.test(body) || page.url().includes('/404') || page.url().includes('/dashboard'); expect(handled).toBeTruthy(); }); test('15. /playlists/nonexistent-id shows 404 or error page', async ({ page }) => { await navigateTo(page, '/playlists/nonexistent-id-99999'); const body = await page.textContent('body') || ''; expect(body).not.toMatch(/crash|TypeError|Cannot read/i); const handled = /not found|introuvable|404|error/i.test(body) || page.url().includes('/404'); expect(handled).toBeTruthy(); }); test('16. /u/nonexistent-user shows 404 or error page', async ({ page }) => { await navigateTo(page, '/u/this-user-does-not-exist-at-all'); const body = await page.textContent('body') || ''; expect(body).not.toMatch(/crash|TypeError|Cannot read/i); const handled = /not found|introuvable|404|error|n'existe pas/i.test(body) || page.url().includes('/404'); expect(handled).toBeTruthy(); }); test('17. Completely unknown route shows 404 page', async ({ page }) => { await navigateTo(page, '/this-route-definitely-does-not-exist'); // Wait a bit for redirects to settle await page.waitForTimeout(2_000); const body = await page.textContent('body') || ''; // Should show 404 page or redirect, not blank or crash expect(body).not.toMatch(/crash|TypeError/i); // Body should have some content (at least a heading or navigation) expect(body.trim().length).toBeGreaterThan(10); const is404 = /404|not found|introuvable|page not found/i.test(body) || page.url().includes('/404'); expect(is404).toBeTruthy(); }); test('18. /marketplace/products/nonexistent-id handles gracefully', async ({ page }) => { await navigateTo(page, '/marketplace/products/nonexistent-product-id'); const body = await page.textContent('body') || ''; expect(body).not.toMatch(/crash|TypeError|Cannot read/i); }); }); // ============================================================================= // EDGE CASES — Double actions // ============================================================================= test.describe('EDGE CASES — Double actions', () => { test('19. Double-click on login submit does not cause duplicate requests @critical', async ({ page }) => { await navigateTo(page, '/login'); await page.getByLabel(/^email$/i).or(page.locator('input[type="email"]')).first().fill(CONFIG.users.listener.email); await page.getByLabel(/^password$|^mot de passe$/i).or(page.locator('input[type="password"]')).first().fill(CONFIG.users.listener.password); // Track API calls const loginRequests: string[] = []; page.on('request', (req) => { if (req.url().includes('/auth/login') && req.method() === 'POST') { loginRequests.push(req.url()); } }); const submitBtn = page.getByRole('button', { name: /sign in|se connecter/i }); // Double-click rapidly await submitBtn.dblclick(); // Wait for response await page.waitForTimeout(3_000); // Should have sent at most 2 requests (double-click), ideally 1 if debounced expect(loginRequests.length).toBeLessThanOrEqual(2); // The page should not crash regardless const body = await page.textContent('body') || ''; expect(body).not.toMatch(/crash|TypeError/i); }); test('20. Rapid page navigation does not crash the app', async ({ page }) => { await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); // Click through pages rapidly without waiting const pages = ['/dashboard', '/library', '/discover', '/search', '/playlists', '/profile', '/settings']; for (const route of pages) { page.goto(route, { waitUntil: 'commit' }).catch(() => {}); // Minimal delay to trigger navigation await page.waitForTimeout(200); } // Wait for final page to settle await page.waitForLoadState('domcontentloaded').catch(() => {}); await page.waitForTimeout(3_000); const body = await page.textContent('body') || ''; expect(body).not.toMatch(/crash|TypeError|Cannot read/i); // During rapid navigation, body may be minimal — just ensure no crash expect(body.trim().length).toBeGreaterThan(10); }); test('21. Double-click on like button toggles correctly', async ({ page }) => { await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); await navigateTo(page, '/discover'); // Find a like button const likeBtn = page.getByRole('button', { name: /ajouter aux favoris|add to favorites/i }).first(); const likeBtnVisible = await likeBtn.isVisible().catch(() => false); test.skip(!likeBtnVisible, 'No like button visible on discover page'); // Double-click to toggle like twice await likeBtn.dblclick(); await page.waitForTimeout(1_000); // Should not crash — state may or may not have changed await assertNotBroken(page); }); }); // ============================================================================= // EDGE CASES — Etat du navigateur // ============================================================================= test.describe('EDGE CASES — Etat du navigateur', () => { test('22. Clearing localStorage forces re-login', async ({ page }) => { await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); await page.waitForTimeout(2_000); // Clear auth storage await page.evaluate(() => { localStorage.removeItem('auth-storage'); localStorage.clear(); }); // Navigate to a protected page await navigateTo(page, '/dashboard'); await page.waitForTimeout(2_000); // Should redirect to login or show unauthenticated state const url = page.url(); const isLoggedOut = url.includes('/login') || url.includes('/register'); expect(isLoggedOut).toBeTruthy(); }); test('23. Accessing app with expired/invalid token shows login', async ({ page }) => { // Set an invalid auth state await page.goto('/login', { waitUntil: 'domcontentloaded' }); await page.evaluate(() => { localStorage.setItem('auth-storage', JSON.stringify({ state: { isAuthenticated: true, isLoading: false, error: null }, version: 1, })); }); // Try to access protected page with fake auth await navigateTo(page, '/dashboard'); await page.waitForTimeout(3_000); // The API should reject the invalid session and redirect to login const body = await page.textContent('body') || ''; expect(body).not.toMatch(/crash|TypeError/i); const url = page.url(); const handledInvalidToken = url.includes('/login') || url.includes('/register') || /unauthorized|session|expired|sign in/i.test(body); expect(handledInvalidToken).toBeTruthy(); }); test('24. Page loads correctly with JavaScript-disabled cookies notice', async ({ page }) => { // Verify the page loads and doesn't depend on cookies being pre-set await page.context().clearCookies(); await navigateTo(page, '/login'); const body = await page.textContent('body') || ''; expect(body).not.toMatch(/crash|TypeError/i); expect(body.length).toBeGreaterThan(50); }); }); // ============================================================================= // EDGE CASES — Concurrent interactions // ============================================================================= test.describe('EDGE CASES — Interactions concurrentes', () => { test('25. Multiple search queries in quick succession', async ({ page }) => { await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); await navigateTo(page, '/search'); const searchInput = page.locator('input[role="combobox"][aria-label="Search"]') .or(page.getByPlaceholder(/search for tracks/i)); const searchVisible = await searchInput.first().isVisible().catch(() => false); test.skip(!searchVisible, 'Search input not visible on this page'); // Type multiple queries rapidly to test debounce handling const queries = ['rock', 'jazz', 'electronic', 'hip hop', 'classical']; for (const query of queries) { await searchInput.first().fill(query); await page.waitForTimeout(100); // Very short delay between queries } // Wait for the final debounced search to resolve await page.waitForTimeout(2_000); await assertNotBroken(page); }); test('26. Opening search while player is active does not break either', async ({ page }) => { await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); // Start playing a track await navigateTo(page, '/discover'); // Play first track — hover on card then click play button const trackCard = page.locator('[role="article"]').first(); if (await trackCard.isVisible().catch(() => false)) { await trackCard.hover(); await page.waitForTimeout(300); } const playBtn = page.getByRole('button', { name: /^lire |^play /i }).first() .or(page.locator('[aria-label*="Lire"]').first()) .or(page.locator('[aria-label*="Play"]').first()); if (await playBtn.isVisible().catch(() => false)) { await playBtn.click(); await page.waitForTimeout(CONFIG.timeouts.animation); } // Navigate to search while track might be playing await navigateTo(page, '/search'); await assertNotBroken(page); // Search should work const searchInput = page.locator('input[role="combobox"][aria-label="Search"]') .or(page.getByPlaceholder(/search for tracks/i)); const searchVisible = await searchInput.first().isVisible().catch(() => false); if (searchVisible) { await searchInput.first().fill('test'); await page.waitForTimeout(1_500); await assertNotBroken(page); } }); });