veza/tests/e2e/helpers.ts

456 lines
17 KiB
TypeScript
Raw Normal View History

import { type Page, type Locator, expect } from '@playwright/test';
// =============================================================================
// CONFIGURATION — Basée sur le code source réel 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/main.go) */
users: {
listener: {
email: 'listener1@veza.fr',
password: 'Password123!',
username: 'music_lover',
},
creator: {
email: 'amelie@veza.fr',
password: 'Password123!',
username: 'amelie_dubois',
},
admin: {
email: 'admin@veza.fr',
password: 'Password123!',
username: 'admin_veza',
},
moderator: {
email: 'mod@veza.fr',
password: 'Password123!',
username: 'moderator_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).
* Utilise les vrais sélecteurs du composant LoginPage.tsx.
*
* Le formulaire a :
* - Input email : label="Email", type="email"
* - Input password : label="Password", type="password"
* - Bouton submit : type="submit", texte "Sign In" (en) ou "Se connecter" (fr)
* - Checkbox remember_me : id="remember_me"
*/
export async function loginViaUI(
page: Page,
email: string,
password: string,
options: { rememberMe?: boolean } = {},
): Promise<void> {
await page.goto(`${CONFIG.baseURL}/login`, { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle').catch(() => {});
// Wait for the app to finish initializing (splash → login form)
await page.locator('main, [role="main"]').first().waitFor({
state: 'visible',
timeout: CONFIG.timeouts.navigation,
}).catch(() => {});
// DOM réel (vérifié via snapshot) :
// textbox "Email" → input[type="email"] (peut avoir une valeur pré-remplie "remember me")
// textbox "Password" → input[type="password"]
// button "Sign In" → data-testid="login-submit"
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);
// Remember me checkbox (optionnel)
if (options.rememberMe) {
const rememberMe = page.locator('#remember_me');
if (await rememberMe.isVisible().catch(() => false)) {
await rememberMe.check();
}
}
// Soumettre — le bouton est data-testid="login-submit" avec texte "Sign In"
const submitBtn = page.getByTestId('login-submit');
await submitBtn.waitFor({ state: 'visible', timeout: CONFIG.timeouts.action });
await submitBtn.click();
// Attendre la redirection (quitte /login)
const redirected = await page.waitForURL((url) => !url.pathname.includes('/login'), {
timeout: CONFIG.timeouts.navigation,
}).then(() => true).catch(() => false);
if (!redirected) {
// Retry once — rate limiting or slow API may have blocked the first attempt
const bodyText = await page.textContent('body').catch(() => '') || '';
if (/rate limit|trop de requêtes|429|too many|error|erreur/i.test(bodyText)) {
await page.waitForTimeout(2_000);
// Re-fill in case form was reset
const emailRetry = page.locator('input[type="email"]');
if (await emailRetry.isVisible().catch(() => false)) {
await emailRetry.clear();
await emailRetry.fill(email);
const pwRetry = page.locator('input[type="password"]').first();
await pwRetry.clear();
await pwRetry.fill(password);
}
await submitBtn.click();
await page.waitForURL((url) => !url.pathname.includes('/login'), {
timeout: CONFIG.timeouts.navigation,
}).catch(() => {});
}
}
}
/**
* Login via l'API directement (plus rapide, pour les tests qui n'ont pas besoin de tester le login).
* Beaucoup plus rapide que loginViaUI car évite le rendu complet de la SPA.
*
* POST /api/v1/auth/login set cookies + localStorage auth-storage
*/
export async function loginViaAPI(
page: Page,
email: string,
password: string,
): Promise<void> {
// Naviguer vers une page minimale pour initialiser le contexte navigateur (cookies, localStorage)
// about:blank ne permet pas localStorage, donc on utilise / avec un timeout court
const base = CONFIG.baseURL;
await page.goto(`${base}/`, { waitUntil: 'commit', timeout: CONFIG.timeouts.navigation });
// Appeler l'API login directement (bypass le rendu UI, juste un POST HTTP)
const response = await page.request.post(`${base}/api/v1/auth/login`, {
data: { email, password, remember_me: false },
});
if (!response.ok()) {
// Ne pas throw — le test appelant vérifiera si on est authentifié
console.warn(`loginViaAPI failed: ${response.status()}`);
return;
}
const body = await response.json();
const token = body?.data?.token?.access_token;
// Stocker l'état auth dans le Zustand store (auth-storage) pour que le frontend
// reconnaisse la session immédiatement au prochain chargement de page
await page.evaluate((_token: string | undefined) => {
const authState = {
state: { isAuthenticated: true, isLoading: false, error: null },
version: 1,
};
localStorage.setItem('auth-storage', JSON.stringify(authState));
}, token);
// Naviguer vers le dashboard — la SPA détecte isAuthenticated et affiche le layout authentifié
await page.goto(`${CONFIG.baseURL}/dashboard`, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.waitForLoadState('networkidle').catch(() => {});
// Wait for the app to finish auth initialization
await page.waitForTimeout(1_000);
}
// =============================================================================
// NAVIGATION HELPERS
// =============================================================================
/**
* Navigue vers un path et attend que l'app soit prête (splash screen disparu).
*
* L'app affiche un splash "Veza" pendant l'initialisation auth (refreshUser getMe).
* Une fois prête, elle rend soit AuthLayout (role="main") soit DashboardLayout (<main>).
* On attend donc qu'un élément `main` ou `[role="main"]` apparaisse.
*/
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').catch(() => {});
// Wait for the app to finish initializing (loading splash → actual page)
await page.locator('main, [role="main"]').first().waitFor({
state: 'visible',
timeout: 20_000,
}).catch(() => {});
}
/**
* Vérifie qu'une page se charge sans erreur critique.
* Retourne les erreurs console collectées.
*/
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);
// Vérifier pas de crash
const body = await page.textContent('body').catch(() => '') || '';
expect(body).not.toMatch(/500|Internal Server Error/i);
return errors;
}
// =============================================================================
// FORM HELPERS
// =============================================================================
/**
* Remplit un formulaire avec les champs donnés.
* Les clés sont les labels ou placeholders des champs.
*/
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
// =============================================================================
/**
* Vérifie qu'il n'y a pas de texte de debug visible (undefined, null, NaN, [object Object], etc.)
*/
export async function assertNoDebugText(page: Page): Promise<void> {
const body = await page.textContent('body').catch(() => '') || '';
// Patterns de debug courants
expect(body).not.toContain('[object Object]');
// Note: "undefined" et "null" peuvent apparaître dans du texte légitime,
// donc on vérifie seulement les occurrences suspectes
const suspiciousPatterns = /\bundefined\b(?!.*password)|\bnull\b.*\bnull\b|\bNaN\b/g;
const matches = body.match(suspiciousPatterns);
if (matches && matches.length > 2) {
console.warn(` ⚠ Texte suspect trouvé: ${matches.slice(0, 3).join(', ')}`);
}
}
/**
* Vérifie que la page n'a pas d'erreur serveur visible.
*/
export async function assertNotBroken(page: Page): Promise<void> {
const body = await page.textContent('body').catch(() => '') || '';
expect(body).not.toMatch(/500|Internal Server Error|unexpected error/i);
expect(body.length).toBeGreaterThan(50);
}
/**
* Collecte les erreurs réseau (5xx) pendant une période.
*/
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.
* The sidebar overlay is wrapped in a FocusTrap that intercepts pointer events,
* so clicking the overlay fails. Instead we press Escape which the FocusTrap handles.
*/
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 }).catch(() => {});
}
}
// =============================================================================
// PLAYER HELPERS
// =============================================================================
/**
* Vérifie que le player global est visible et le retourne.
* Le player a data-testid="global-player" et role="region" aria-label="Global player".
*/
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 actually displays track cards (role="article").
*
* Page rendering details:
* - /feed uses TrackGrid TrackCard (role="article"). Best for listener accounts
* who follow creators (seed: listener1 follows amelie, marcus, renzo).
* - /discover shows genre buttons by default; clicking a genre loads tracks via
* TrackGrid TrackCard (role="article").
* - /library uses its own LibraryPageGrid (NOT TrackCard), so no role="article".
* It also only shows the current user's OWN tracks (empty for listeners).
*/
export async function navigateToPageWithTracks(page: Page): Promise<boolean> {
// Fermer la sidebar mobile si ouverte (son FocusTrap intercepte les clics)
await dismissMobileSidebar(page);
// Try /feed first — it uses TrackGrid/TrackCard (role="article")
// and shows tracks from followed users + by_genres section
await navigateTo(page, '/feed');
const feedTrack = page.locator('[role="article"]').first();
if (await feedTrack.isVisible({ timeout: 5_000 }).catch(() => false)) {
return true;
}
// Fallback: /discover → genre buttons are <button> with .font-heading.font-bold spans
// Clicking a genre sets ?genre=slug which loads tracks via TrackGrid/TrackCard
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.
* Navigates to a page with tracks if none are visible on the current page.
* Les TrackCards ont un bouton play avec aria-label="Lire {title}" ou "Play {title}".
*/
export async function playFirstTrack(page: Page): Promise<void> {
// Fermer la sidebar mobile si ouverte (son FocusTrap intercepte les clics)
await dismissMobileSidebar(page);
// If no track cards are visible on the current page, navigate to one that has them
const currentTrack = page.locator('[role="article"]').first();
if (!await currentTrack.isVisible({ timeout: 3_000 }).catch(() => false)) {
await navigateToPageWithTracks(page);
}
// Hover sur le premier track card pour faire apparaître le bouton play
const trackCard = page.locator('[role="article"]').first()
.or(page.getByRole('button', { name: /piste:/i }).first());
if (await trackCard.isVisible().catch(() => false)) {
await trackCard.hover();
await page.waitForTimeout(300);
}
// Cliquer le bouton play (aria-label="Lire ...")
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 playBtn.waitFor({ state: 'visible', timeout: CONFIG.timeouts.action }).catch(() => {});
if (await playBtn.isVisible().catch(() => false)) {
await playBtn.click();
// Attendre que le player apparaisse
await page.waitForTimeout(CONFIG.timeouts.animation);
}
}
// =============================================================================
// COMPONENT SELECTORS — Basés sur le code source réel
// =============================================================================
export const SELECTORS = {
// Layout (vérifié via DOM snapshot)
sidebar: '[data-testid="app-sidebar"]', // complementary "Main sidebar"
header: 'header, [data-testid="app-header"], [role="banner"]',
playerBar: '[data-testid="global-player"]', // region "Global player"
// Auth
loginForm: '[data-testid="login-form"]',
registerForm: '[data-testid="register-form"]',
// Player (vérifié: les boutons n'ont PAS d'aria-labels)
audioElement: '[data-testid="audio-element"]',
progressBar: '[role="slider"][aria-label="Progression"]',
volumeSlider: '[data-testid="volume-control"] [role="slider"]',
// Toast
toast: '[data-testid="toast-alert"]',
// Cards — TrackCard component (used by TrackGrid on /feed, /discover?genre=...)
// Note: /library uses LibraryPageGrid which does NOT use TrackCard (no role="article")
trackCard: '[role="article"]',
// Search — Header search uses data-testid="search-input" type="search"
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.
*/
export async function waitForToast(page: Page): Promise<string> {
const toast = page.getByTestId('toast-alert').first();
await toast.waitFor({ state: 'visible', timeout: CONFIG.timeouts.action }).catch(() => {});
return (await toast.textContent()) || '';
}
/**
* Génère un identifiant unique pour les données de test.
*/
export function testId(prefix = 'e2e'): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
}