The cache was skipping the login API call on cached hits, which meant new browser contexts never received the httpOnly auth cookies set by the backend. Each test's browser context is isolated, so the cookie must be freshly set per test via the actual login API call. The rate-limit motivation for the cache is now handled by DISABLE_RATE_LIMIT_FOR_TESTS=true in the backend when started via 'make dev-e2e'. Result: 58 -> 85 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
387 lines
13 KiB
TypeScript
387 lines
13 KiB
TypeScript
import { type Page, type Locator, expect } from '@playwright/test';
|
|
|
|
// =============================================================================
|
|
// CONFIGURATION — Basee sur le code source reel de Veza
|
|
// =============================================================================
|
|
|
|
export const CONFIG = {
|
|
/** Base URL du frontend Vite dev server (use 127.0.0.1 to avoid IPv6 ::1 resolution issues) */
|
|
baseURL: process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${process.env.PORT || '5173'}`,
|
|
|
|
/** Base URL de l'API backend (proxied via Vite en dev) */
|
|
apiURL: process.env.PLAYWRIGHT_API_URL || `http://127.0.0.1:${process.env.PORT || '5173'}`,
|
|
|
|
/** Base URL du stream server Rust */
|
|
streamURL: process.env.PLAYWRIGHT_STREAM_URL || 'http://localhost:18082',
|
|
|
|
/** Comptes de test (seed: veza-backend-api/cmd/tools/seed/seed_users.go) */
|
|
users: {
|
|
listener: {
|
|
email: 'user@veza.music',
|
|
password: 'User123!',
|
|
username: 'music_fan',
|
|
},
|
|
creator: {
|
|
email: 'artist@veza.music',
|
|
password: 'Artist123!',
|
|
username: 'top_artist',
|
|
},
|
|
admin: {
|
|
email: 'admin@veza.music',
|
|
password: 'Admin123!',
|
|
username: 'admin_veza',
|
|
},
|
|
moderator: {
|
|
email: 'mod@veza.music',
|
|
password: 'Mod123!',
|
|
username: 'mod_veza',
|
|
},
|
|
},
|
|
|
|
/** Timeouts (ms) */
|
|
timeouts: {
|
|
navigation: 15_000,
|
|
action: 5_000,
|
|
animation: 1_000,
|
|
networkIdle: 10_000,
|
|
},
|
|
} as const;
|
|
|
|
// =============================================================================
|
|
// AUTH HELPERS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Login via l'interface utilisateur (page /login).
|
|
* STRICT: echoue si le login ne redirige pas hors de /login.
|
|
*/
|
|
export async function loginViaUI(
|
|
page: Page,
|
|
email: string,
|
|
password: string,
|
|
options: { rememberMe?: boolean } = {},
|
|
): Promise<void> {
|
|
await page.goto(`${CONFIG.baseURL}/login`, { waitUntil: 'domcontentloaded' });
|
|
|
|
// Wait for the app to finish initializing (splash -> login form)
|
|
await page.locator('main, [role="main"]').first().waitFor({
|
|
state: 'visible',
|
|
timeout: CONFIG.timeouts.navigation,
|
|
});
|
|
|
|
const emailInput = page.locator('input[type="email"]');
|
|
await emailInput.waitFor({ state: 'visible', timeout: CONFIG.timeouts.navigation });
|
|
await emailInput.clear();
|
|
await emailInput.fill(email);
|
|
|
|
const passwordInput = page.locator('input[type="password"]');
|
|
await passwordInput.clear();
|
|
await passwordInput.fill(password);
|
|
|
|
if (options.rememberMe) {
|
|
const rememberMe = page.locator('#remember_me');
|
|
if (await rememberMe.isVisible()) {
|
|
await rememberMe.check();
|
|
}
|
|
}
|
|
|
|
const submitBtn = page.getByTestId('login-submit');
|
|
await expect(submitBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
|
|
await submitBtn.click();
|
|
|
|
// STRICT: must redirect away from /login
|
|
await page.waitForURL((url) => !url.pathname.includes('/login'), {
|
|
timeout: CONFIG.timeouts.navigation,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Login via l'API directement (plus rapide, pour les tests qui n'ont pas besoin de tester le login).
|
|
* STRICT: echoue si l'API retourne une erreur.
|
|
*
|
|
* Auth uses httpOnly cookies set by backend. Each test needs to call the login API
|
|
* so that the browser context receives the auth cookies. We cannot skip this — the
|
|
* cookie cannot be copied across browser contexts from JavaScript.
|
|
*/
|
|
export async function loginViaAPI(
|
|
page: Page,
|
|
email: string,
|
|
password: string,
|
|
): Promise<void> {
|
|
const base = CONFIG.baseURL;
|
|
await page.goto(`${base}/`, { waitUntil: 'commit', timeout: CONFIG.timeouts.navigation });
|
|
|
|
const response = await page.request.post(`${base}/api/v1/auth/login`, {
|
|
data: { email, password, remember_me: false },
|
|
});
|
|
|
|
// STRICT: login must succeed
|
|
expect(response.ok(), `Login API failed: ${response.status()} for ${email}`).toBeTruthy();
|
|
|
|
// Set the auth-storage flag so React knows user is authenticated.
|
|
// The actual token is in an httpOnly cookie set automatically by the backend response.
|
|
await page.evaluate(() => {
|
|
const authState = {
|
|
state: { isAuthenticated: true, isLoading: false, error: null },
|
|
version: 1,
|
|
};
|
|
localStorage.setItem('auth-storage', JSON.stringify(authState));
|
|
});
|
|
|
|
await page.goto(`${CONFIG.baseURL}/dashboard`, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
await page.locator('main, [role="main"]').first().waitFor({
|
|
state: 'visible',
|
|
timeout: 20_000,
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// NAVIGATION HELPERS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Navigue vers un path et attend que l'app soit prete.
|
|
* STRICT: echoue si la page ne charge pas (main element must appear).
|
|
*/
|
|
export async function navigateTo(page: Page, path: string): Promise<void> {
|
|
const url = path.startsWith('http') ? path : `${CONFIG.baseURL}${path}`;
|
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {
|
|
// networkidle can legitimately timeout on pages with websockets/polling — not a test failure
|
|
});
|
|
// App must render a main content area
|
|
await page.locator('main, [role="main"]').first().waitFor({
|
|
state: 'visible',
|
|
timeout: 20_000,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Verifie qu'une page se charge sans erreur critique.
|
|
* STRICT: fails on 500 errors visible in the page.
|
|
*/
|
|
export async function assertPageLoads(page: Page, path: string): Promise<string[]> {
|
|
const errors: string[] = [];
|
|
|
|
page.on('console', (msg) => {
|
|
if (msg.type() === 'error') {
|
|
errors.push(msg.text());
|
|
}
|
|
});
|
|
|
|
page.on('pageerror', (err) => {
|
|
errors.push(err.message);
|
|
});
|
|
|
|
await navigateTo(page, path);
|
|
|
|
const body = await page.textContent('body') || '';
|
|
expect(body).not.toMatch(/500|Internal Server Error/i);
|
|
|
|
return errors;
|
|
}
|
|
|
|
// =============================================================================
|
|
// FORM HELPERS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Remplit un formulaire avec les champs donnes.
|
|
*/
|
|
export async function fillForm(
|
|
page: Page,
|
|
fields: Record<string, string>,
|
|
): Promise<void> {
|
|
for (const [label, value] of Object.entries(fields)) {
|
|
const input = page.getByLabel(new RegExp(label, 'i'))
|
|
.or(page.getByPlaceholder(new RegExp(label, 'i')));
|
|
await input.first().fill(value);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ASSERTION HELPERS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Verifie qu'il n'y a pas de texte de debug visible.
|
|
* STRICT: fails if [object Object] or excessive undefined/null/NaN found.
|
|
*/
|
|
export async function assertNoDebugText(page: Page): Promise<void> {
|
|
const body = await page.textContent('body') || '';
|
|
expect(body).not.toContain('[object Object]');
|
|
const suspiciousPatterns = /\bundefined\b(?!.*password)|\bnull\b.*\bnull\b|\bNaN\b/g;
|
|
const matches = body.match(suspiciousPatterns);
|
|
expect(
|
|
matches?.length ?? 0,
|
|
`Debug text found in page: ${matches?.slice(0, 3).join(', ')}`,
|
|
).toBeLessThanOrEqual(2);
|
|
}
|
|
|
|
/**
|
|
* Verifie que la page n'a pas d'erreur serveur visible.
|
|
* STRICT: fails on 500 errors or empty body.
|
|
*/
|
|
export async function assertNotBroken(page: Page): Promise<void> {
|
|
const body = await page.textContent('body') || '';
|
|
expect(body).not.toMatch(/500|Internal Server Error|unexpected error/i);
|
|
expect(body.length).toBeGreaterThan(50);
|
|
}
|
|
|
|
/**
|
|
* Collecte les erreurs reseau (5xx) pendant une action.
|
|
*/
|
|
export async function collectNetworkErrors(
|
|
page: Page,
|
|
action: () => Promise<void>,
|
|
): Promise<string[]> {
|
|
const errors: string[] = [];
|
|
|
|
const handler = (response: { status: () => number; url: () => string }) => {
|
|
if (response.status() >= 500) {
|
|
errors.push(`${response.status()} ${response.url()}`);
|
|
}
|
|
};
|
|
|
|
page.on('response', handler);
|
|
await action();
|
|
page.off('response', handler);
|
|
|
|
return errors;
|
|
}
|
|
|
|
// =============================================================================
|
|
// LAYOUT HELPERS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Dismiss the mobile sidebar if it's open.
|
|
*/
|
|
export async function dismissMobileSidebar(page: Page): Promise<void> {
|
|
const sidebarOverlay = page.locator('div[aria-hidden="true"][role="presentation"].fixed.inset-0');
|
|
if (await sidebarOverlay.isVisible({ timeout: 1_000 }).catch(() => false)) {
|
|
await page.keyboard.press('Escape');
|
|
await sidebarOverlay.waitFor({ state: 'hidden', timeout: 3_000 });
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// PLAYER HELPERS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Verifie que le player global est visible et le retourne.
|
|
* STRICT: fails if player is not visible.
|
|
*/
|
|
export async function assertPlayerVisible(page: Page): Promise<Locator> {
|
|
const player = page.getByTestId('global-player')
|
|
.or(page.locator('[role="region"][aria-label="Global player"]'));
|
|
|
|
await expect(player.first()).toBeVisible({ timeout: CONFIG.timeouts.action });
|
|
return player.first();
|
|
}
|
|
|
|
/**
|
|
* Navigate to a page that displays track cards (role="article").
|
|
* Returns true if tracks are found, false if the database has no tracks.
|
|
* Does NOT swallow errors — navigation failures will throw.
|
|
*/
|
|
export async function navigateToPageWithTracks(page: Page): Promise<boolean> {
|
|
await dismissMobileSidebar(page);
|
|
|
|
// Try /feed first
|
|
await navigateTo(page, '/feed');
|
|
const feedTrack = page.locator('[role="article"]').first();
|
|
if (await feedTrack.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
|
return true;
|
|
}
|
|
|
|
// Fallback: /discover -> click first genre
|
|
await navigateTo(page, '/discover');
|
|
const genreBtn = page.locator('button').filter({ has: page.locator('.font-heading.font-bold') }).first();
|
|
|
|
if (await genreBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
|
await genreBtn.click();
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
const genreTrack = page.locator('[role="article"]').first();
|
|
if (await genreTrack.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Lance la lecture du premier track disponible.
|
|
* STRICT: fails if no play button is found or if it can't be clicked.
|
|
*/
|
|
export async function playFirstTrack(page: Page): Promise<void> {
|
|
await dismissMobileSidebar(page);
|
|
|
|
// If no track cards on current page, navigate to one that has them
|
|
const currentTrack = page.locator('[role="article"]').first();
|
|
if (!await currentTrack.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
|
const found = await navigateToPageWithTracks(page);
|
|
expect(found, 'No tracks found in feed or discover — database may need seeding').toBeTruthy();
|
|
}
|
|
|
|
// Hover to reveal play button
|
|
const trackCard = page.locator('[role="article"]').first();
|
|
await expect(trackCard).toBeVisible({ timeout: CONFIG.timeouts.action });
|
|
await trackCard.hover();
|
|
await page.waitForTimeout(300);
|
|
|
|
// Click play
|
|
const playBtn = page.getByRole('button', { name: /^lire |^play /i }).first()
|
|
.or(page.locator('[aria-label*="Lire"]').first())
|
|
.or(page.locator('[aria-label*="Play"]').first());
|
|
|
|
await expect(playBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
|
|
await playBtn.click();
|
|
|
|
// Wait for player to appear
|
|
await page.waitForTimeout(CONFIG.timeouts.animation);
|
|
}
|
|
|
|
// =============================================================================
|
|
// COMPONENT SELECTORS — Bases sur le code source reel
|
|
// =============================================================================
|
|
|
|
export const SELECTORS = {
|
|
sidebar: '[data-testid="app-sidebar"]',
|
|
header: 'header, [data-testid="app-header"], [role="banner"]',
|
|
playerBar: '[data-testid="global-player"]',
|
|
|
|
loginForm: '[data-testid="login-form"]',
|
|
registerForm: '[data-testid="register-form"]',
|
|
|
|
audioElement: '[data-testid="audio-element"]',
|
|
progressBar: '[role="slider"][aria-label="Progression"]',
|
|
volumeSlider: '[data-testid="volume-control"] [role="slider"]',
|
|
|
|
toast: '[data-testid="toast-alert"]',
|
|
trackCard: '[role="article"]',
|
|
searchInput: '[data-testid="search-input"], [role="search"] input, input[type="search"], input[role="searchbox"]',
|
|
} as const;
|
|
|
|
// =============================================================================
|
|
// UTILITY
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Attend qu'un toast soit visible, puis retourne son texte.
|
|
* STRICT: fails if no toast appears within timeout.
|
|
*/
|
|
export async function waitForToast(page: Page): Promise<string> {
|
|
const toast = page.getByTestId('toast-alert').first();
|
|
await expect(toast).toBeVisible({ timeout: CONFIG.timeouts.action });
|
|
return (await toast.textContent()) || '';
|
|
}
|
|
|
|
/**
|
|
* Genere un identifiant unique pour les donnees de test.
|
|
*/
|
|
export function testId(prefix = 'e2e'): string {
|
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
}
|