Update auth, playlists, tracks, search, profile, dashboard, player, settings, and social features. Add e2e audit specs for all major pages. Update ESLint config, vitest config, and route configuration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
363 lines
11 KiB
TypeScript
363 lines
11 KiB
TypeScript
/**
|
|
* E2E tests — Track Detail Page (/tracks/:id)
|
|
* Covers: loading, features, security, a11y, i18n, responsive, regression
|
|
*/
|
|
import { test, expect } from '@playwright/test';
|
|
import { loginViaAPI, CONFIG, navigateTo } from './helpers';
|
|
|
|
// Discover a valid track ID dynamically via API
|
|
async function getValidTrackId(page: import('@playwright/test').Page): Promise<string | null> {
|
|
const result = await page.evaluate(() =>
|
|
fetch('/api/v1/tracks?page=1&limit=1')
|
|
.then((r) => r.json())
|
|
.then((d) => d.data?.tracks?.[0]?.id ?? null),
|
|
);
|
|
return result as string | null;
|
|
}
|
|
|
|
test.describe('Track Detail (/tracks/:id)', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
|
|
});
|
|
|
|
test.describe('Chargement & Rendu', () => {
|
|
test('page loads without crash @critical', async ({ page }) => {
|
|
const trackId = await getValidTrackId(page);
|
|
if (!trackId) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
await navigateTo(page, `/tracks/${trackId}`);
|
|
|
|
const main = page.locator('main');
|
|
await expect(main).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Should show track title as h1
|
|
const heading = page.locator('h1');
|
|
await expect(heading).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test('no JS errors on valid track', async ({ page }) => {
|
|
const jsErrors: string[] = [];
|
|
page.on('pageerror', (error) => {
|
|
jsErrors.push(error.message);
|
|
});
|
|
|
|
const trackId = await getValidTrackId(page);
|
|
if (!trackId) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
await navigateTo(page, `/tracks/${trackId}`);
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
|
|
const criticalErrors = jsErrors.filter(
|
|
(e) => !e.includes('React DevTools'),
|
|
);
|
|
expect(criticalErrors).toHaveLength(0);
|
|
});
|
|
|
|
test('all key visual elements present', async ({ page }) => {
|
|
const trackId = await getValidTrackId(page);
|
|
if (!trackId) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
await navigateTo(page, `/tracks/${trackId}`);
|
|
|
|
// Back button
|
|
const backBtn = page.getByRole('button', { name: /back/i });
|
|
await expect(backBtn).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Play button
|
|
const playBtn = page.getByRole('button', { name: /^play$/i }).first();
|
|
await expect(playBtn).toBeVisible();
|
|
|
|
// Tabs
|
|
const discussionTab = page.getByRole('tab', { name: /discussion/i });
|
|
await expect(discussionTab).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('Fonctionnalites', () => {
|
|
test('metadata grid shows track info', async ({ page }) => {
|
|
const trackId = await getValidTrackId(page);
|
|
if (!trackId) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
await navigateTo(page, `/tracks/${trackId}`);
|
|
|
|
// Duration label should be present
|
|
const duration = page.getByText('Duration');
|
|
await expect(duration).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Genre should be visible
|
|
const genre = page.getByText('Genre');
|
|
await expect(genre).toBeVisible();
|
|
});
|
|
|
|
test('tabs switch content', async ({ page }) => {
|
|
const trackId = await getValidTrackId(page);
|
|
if (!trackId) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
await navigateTo(page, `/tracks/${trackId}`);
|
|
|
|
// Discussion tab selected by default
|
|
const discussionTab = page.getByRole('tab', { name: /discussion/i });
|
|
await expect(discussionTab).toHaveAttribute('data-state', 'active', { timeout: 10_000 });
|
|
|
|
// Click Lyrics tab
|
|
const lyricsTab = page.getByRole('tab', { name: /lyrics/i });
|
|
await lyricsTab.click();
|
|
await expect(lyricsTab).toHaveAttribute('data-state', 'active');
|
|
});
|
|
|
|
test('action buttons are present', async ({ page }) => {
|
|
const trackId = await getValidTrackId(page);
|
|
if (!trackId) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
await navigateTo(page, `/tracks/${trackId}`);
|
|
|
|
// Like button
|
|
const likeBtn = page.getByRole('button', { name: /add to favorites/i });
|
|
await expect(likeBtn).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Repost button
|
|
const repostBtn = page.getByRole('button', { name: /repost to your profile/i });
|
|
await expect(repostBtn).toBeVisible();
|
|
|
|
// Add to Queue
|
|
const queueBtn = page.getByRole('button', { name: /add to queue/i });
|
|
await expect(queueBtn).toBeVisible();
|
|
|
|
// Share
|
|
const shareBtn = page.getByRole('button', { name: /share/i }).first();
|
|
await expect(shareBtn).toBeVisible();
|
|
});
|
|
|
|
test('comment section shows placeholder', async ({ page }) => {
|
|
const trackId = await getValidTrackId(page);
|
|
if (!trackId) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
await navigateTo(page, `/tracks/${trackId}`);
|
|
|
|
const commentInput = page.getByPlaceholder(/write a comment/i);
|
|
await expect(commentInput).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
});
|
|
|
|
test.describe('Securite', () => {
|
|
test('invalid UUID shows error state', async ({ page }) => {
|
|
await navigateTo(page, '/tracks/00000000-0000-0000-0000-000000000000');
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
|
|
// Should show error/not-found state
|
|
const errorAlert = page.getByRole('alert');
|
|
await expect(errorAlert).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test('XSS injection in ID is safe', async ({ page }) => {
|
|
const jsErrors: string[] = [];
|
|
page.on('pageerror', (error) => {
|
|
jsErrors.push(error.message);
|
|
});
|
|
|
|
await navigateTo(page, '/tracks/%3Cscript%3Ealert(1)%3C%2Fscript%3E');
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
|
|
const xssErrors = jsErrors.filter((e) => e.includes('alert'));
|
|
expect(xssErrors).toHaveLength(0);
|
|
});
|
|
|
|
test('no sensitive data in URL', async ({ page }) => {
|
|
const trackId = await getValidTrackId(page);
|
|
if (!trackId) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
await navigateTo(page, `/tracks/${trackId}`);
|
|
|
|
const url = page.url();
|
|
expect(url).not.toMatch(/token=/i);
|
|
expect(url).not.toMatch(/@.*\./);
|
|
});
|
|
});
|
|
|
|
test.describe('Accessibilite', () => {
|
|
test('action buttons have descriptive aria-labels', async ({ page }) => {
|
|
const trackId = await getValidTrackId(page);
|
|
if (!trackId) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
await navigateTo(page, `/tracks/${trackId}`);
|
|
|
|
// Like button has meaningful aria-label
|
|
const likeBtn = page.getByRole('button', { name: /favorites/i }).first();
|
|
await expect(likeBtn).toBeVisible({ timeout: 10_000 });
|
|
const ariaLabel = await likeBtn.getAttribute('aria-label');
|
|
expect(ariaLabel).toBeTruthy();
|
|
expect(ariaLabel).not.toBe('Button');
|
|
|
|
// Repost button has meaningful aria-label
|
|
const repostBtn = page.getByRole('button', { name: /repost/i });
|
|
const repostLabel = await repostBtn.getAttribute('aria-label');
|
|
expect(repostLabel).toBeTruthy();
|
|
});
|
|
|
|
test('tabs have proper ARIA roles', async ({ page }) => {
|
|
const trackId = await getValidTrackId(page);
|
|
if (!trackId) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
await navigateTo(page, `/tracks/${trackId}`);
|
|
|
|
const tablist = page.getByRole('tablist');
|
|
await expect(tablist).toBeVisible({ timeout: 10_000 });
|
|
|
|
const tabs = page.getByRole('tab');
|
|
const count = await tabs.count();
|
|
expect(count).toBeGreaterThanOrEqual(4);
|
|
});
|
|
});
|
|
|
|
test.describe('i18n', () => {
|
|
test('no raw i18n keys displayed', async ({ page }) => {
|
|
const trackId = await getValidTrackId(page);
|
|
if (!trackId) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
await navigateTo(page, `/tracks/${trackId}`);
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
|
|
const bodyText = (await page.textContent('main')) || '';
|
|
expect(bodyText).not.toMatch(/tracks\.detail\./);
|
|
expect(bodyText).not.toMatch(/tracks\.commentSection\./);
|
|
expect(bodyText).not.toMatch(/tracks\.repost\./);
|
|
expect(bodyText).not.toMatch(/tracks\.likeAction\./);
|
|
expect(bodyText).not.toMatch(/tracks\.shareDialog\./);
|
|
expect(bodyText).not.toMatch(/tracks\.lyricsSection\./);
|
|
expect(bodyText).not.toMatch(/tracks\.stemsSection\./);
|
|
expect(bodyText).not.toMatch(/tracks\.stats\./);
|
|
});
|
|
|
|
test('no FR/EN mix on page', async ({ page }) => {
|
|
const trackId = await getValidTrackId(page);
|
|
if (!trackId) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
await navigateTo(page, `/tracks/${trackId}`);
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
|
|
const mainText = (await page.textContent('main')) || '';
|
|
// These FR strings should NOT appear when locale is EN
|
|
expect(mainText).not.toContain('Reposter sur votre profil');
|
|
expect(mainText).not.toContain('Commentaires');
|
|
expect(mainText).not.toContain('Écrire un commentaire');
|
|
expect(mainText).not.toContain('Aucun commentaire');
|
|
});
|
|
});
|
|
|
|
test.describe('Responsive', () => {
|
|
test('mobile (375px) no overflow', async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 812 });
|
|
|
|
const trackId = await getValidTrackId(page);
|
|
if (!trackId) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
await navigateTo(page, `/tracks/${trackId}`);
|
|
|
|
const main = page.locator('main');
|
|
await expect(main).toBeVisible({ timeout: 10_000 });
|
|
|
|
const overflowX = await page.evaluate(
|
|
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
|
);
|
|
expect(overflowX).toBe(false);
|
|
});
|
|
});
|
|
|
|
test.describe('Regression', () => {
|
|
test('BUG #1: page title includes track name', async ({ page }) => {
|
|
const trackId = await getValidTrackId(page);
|
|
if (!trackId) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
await navigateTo(page, `/tracks/${trackId}`);
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
await page.waitForTimeout(1_000);
|
|
|
|
const title = await page.title();
|
|
expect(title).toContain('Veza');
|
|
expect(title).not.toBe('Veza');
|
|
expect(title).toMatch(/.+ — Veza/);
|
|
});
|
|
|
|
test('BUG #2: repost button uses EN not FR', async ({ page }) => {
|
|
const trackId = await getValidTrackId(page);
|
|
if (!trackId) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
await navigateTo(page, `/tracks/${trackId}`);
|
|
|
|
// Repost button should have EN aria-label, not FR
|
|
const repostBtn = page.locator('button[aria-label]').filter({
|
|
has: page.locator('svg'),
|
|
});
|
|
const allLabels = await repostBtn.evaluateAll((btns) =>
|
|
btns.map((b) => b.getAttribute('aria-label')).filter(Boolean),
|
|
);
|
|
const frLabels = allLabels.filter(
|
|
(l) => l && (l.includes('Reposter') || l.includes('Retirer le repost')),
|
|
);
|
|
expect(frLabels).toHaveLength(0);
|
|
});
|
|
|
|
test('BUG #5: comment section uses EN not FR', async ({ page }) => {
|
|
const trackId = await getValidTrackId(page);
|
|
if (!trackId) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
await navigateTo(page, `/tracks/${trackId}`);
|
|
|
|
// Comments heading should be EN
|
|
const heading = page.getByRole('heading', { name: /comments/i });
|
|
await expect(heading).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Placeholder should be EN
|
|
const input = page.getByPlaceholder(/write a comment/i);
|
|
await expect(input).toBeVisible();
|
|
});
|
|
});
|
|
});
|