veza/tests/e2e/helpers.ts
senke 941dabdc97 fix(e2e): accept login-form as page readiness marker
31-auth-sessions:36 (Refresh token expiré) calls navigateTo('/dashboard')
expecting the auth guard to redirect to /login. The rc1-day2 widening
accepted `main / [role=main] / app-sidebar / data-page-root` — none
of which render on /login. Result: 20s timeout on a test that's
actually working (the redirect happens, the helper just doesn't
recognise the destination as "rendered").

Extend the accepted set with `[data-testid="login-form"]`, present
on LoginPage.tsx since v1.0.x. The login page was the only
authenticated-redirect destination not covered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 20:19:33 +02:00

400 lines
14 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 — accepts any of the
* standard "page rendered" signals so a page that uses sidebar
* layouts without a dedicated <main> (e.g. /social, /chat, some
* settings subroutes) still passes the readiness probe.
*/
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 some substantive root content. v1.0.7-rc1-day2:
// widened from `main, [role="main"]` to also accept:
// * the app sidebar (rendered on every authenticated route)
// * `data-page-root` (explicit opt-in)
// * the login form (for navigations that are expected to
// redirect to /login — token-expiry tests, unauthenticated
// probes)
// Pages that render none of these are still treated as broken and
// fail at 20s.
await page.locator(
'main, [role="main"], [data-testid="app-sidebar"], [data-page-root], [data-testid="login-form"]',
).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)}`;
}