The pre-fix `main, [role="main"]` signal hard-failed on any page
that used sidebar layouts without a semantic <main> — /social,
some /settings subroutes, /chat (via sidebar fallback). Workflow
tests (13-workflows × 3) cascaded-failed because one of their
navigateTo calls landed on such a page and the helper timed out
before the test could proceed.
Widened to accept:
* `main` / `[role="main"]` — the preferred signal, unchanged
* `[data-testid="app-sidebar"]` — rendered on every authenticated
route, stable against layout refactors
* `[data-page-root]` — explicit opt-in for pages that want a
test-stable readiness marker without a semantic change
All three 13-workflows @critical tests now pass (12/13 pass, 1
skipped data-dependent). 41-chat-deep also benefits: 27 passed
after the widening vs 20 pre-widening.
Not a relaxation — pages that rendered nothing still timeout at 20s.
This just accepts more shapes of "rendered, not broken", matching
the actual app's layout diversity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
396 lines
14 KiB
TypeScript
396 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, and stable against layout
|
|
// refactors that may or may not use semantic <main>) and any
|
|
// element marked `data-page-root` for explicit opt-in pages.
|
|
await page.locator(
|
|
'main, [role="main"], [data-testid="app-sidebar"], [data-page-root]',
|
|
).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)}`;
|
|
}
|