veza/tests/e2e/helpers.ts

383 lines
13 KiB
TypeScript
Raw Normal View History

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.
*/
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();
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 });
// Wait for auth initialization to complete
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').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)}`;
}