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>
383 lines
16 KiB
TypeScript
383 lines
16 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
import { CONFIG, loginViaAPI, navigateTo } from './helpers';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: populate the player queue by playing tracks from /feed
|
|
// ---------------------------------------------------------------------------
|
|
async function populateQueue(page: import('@playwright/test').Page) {
|
|
await navigateTo(page, '/feed');
|
|
|
|
// Play first track (sets it as current)
|
|
const firstTrack = page.getByRole('article').first();
|
|
await firstTrack.hover();
|
|
const playBtn = firstTrack.getByRole('button', { name: /^Play / });
|
|
await playBtn.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Play second track (adds to queue)
|
|
const secondTrack = page.getByRole('article').nth(1);
|
|
await secondTrack.hover();
|
|
const playBtn2 = secondTrack.getByRole('button', { name: /^Play / });
|
|
await playBtn2.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Play third track (adds to queue)
|
|
const thirdTrack = page.getByRole('article').nth(2);
|
|
await thirdTrack.hover();
|
|
const playBtn3 = thirdTrack.getByRole('button', { name: /^Play / });
|
|
await playBtn3.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Reset current index to 0 so we have "up next" tracks
|
|
await page.evaluate(() => {
|
|
const stored = JSON.parse(localStorage.getItem('player-storage') || '{}');
|
|
if (stored.state?.queue?.length > 0) {
|
|
stored.state.currentIndex = 0;
|
|
stored.state.currentTrack = stored.state.queue[0];
|
|
localStorage.setItem('player-storage', JSON.stringify(stored));
|
|
}
|
|
});
|
|
}
|
|
|
|
// ===========================================================================
|
|
// QUEUE PAGE AUDIT
|
|
// ===========================================================================
|
|
|
|
test.describe('Queue Page Audit (/queue)', () => {
|
|
// -------------------------------------------------------------------------
|
|
// Chargement & Rendu
|
|
// -------------------------------------------------------------------------
|
|
test.describe('Chargement & Rendu', () => {
|
|
test('01. la page /queue se charge sans crash', async ({ page }) => {
|
|
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
|
|
await navigateTo(page, '/queue');
|
|
await expect(page.locator('main')).toBeVisible();
|
|
const body = await page.textContent('body');
|
|
expect(body).not.toMatch(/500|Internal Server Error/i);
|
|
});
|
|
|
|
test('02. le titre du document est mis a jour', async ({ page }) => {
|
|
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
|
|
await navigateTo(page, '/queue');
|
|
await expect(page).toHaveTitle(/Queue/i);
|
|
});
|
|
|
|
test('03. etat vide affiche le message empty state', async ({ page }) => {
|
|
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
|
|
// Clear any existing queue
|
|
await page.evaluate(() => localStorage.removeItem('player-storage'));
|
|
await navigateTo(page, '/queue');
|
|
await expect(page.getByRole('heading', { name: /nothing in your queue/i })).toBeVisible();
|
|
});
|
|
|
|
test('04. etat avec tracks affiche Now Playing + Up Next', async ({ page }) => {
|
|
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
|
|
await populateQueue(page);
|
|
await navigateTo(page, '/queue');
|
|
|
|
await expect(page.getByRole('heading', { name: /now playing/i })).toBeVisible();
|
|
await expect(page.getByRole('heading', { name: /up next/i })).toBeVisible();
|
|
// At least one track in Up Next
|
|
await expect(page.getByRole('button', { name: /^Reorder / }).first()).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Fonctionnalites
|
|
// -------------------------------------------------------------------------
|
|
test.describe('Fonctionnalites', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
|
|
await populateQueue(page);
|
|
await navigateTo(page, '/queue');
|
|
});
|
|
|
|
test('05. supprimer un track de la queue', async ({ page }) => {
|
|
const removeButtons = page.getByRole('button', { name: /remove from queue/i });
|
|
const countBefore = await removeButtons.count();
|
|
expect(countBefore).toBeGreaterThan(0);
|
|
|
|
// Hover to reveal the remove button, then click
|
|
const firstQueueItem = page.getByRole('button', { name: /^Reorder / }).first().locator('..');
|
|
await firstQueueItem.hover();
|
|
await removeButtons.first().click();
|
|
|
|
const countAfter = await page.getByRole('button', { name: /remove from queue/i }).count();
|
|
expect(countAfter).toBe(countBefore - 1);
|
|
});
|
|
|
|
test('06. clear queue avec confirmation', async ({ page }) => {
|
|
await page.getByRole('button', { name: /^Clear$/i }).click();
|
|
|
|
// Confirmation dialog appears
|
|
const dialog = page.getByRole('dialog', { name: /clear queue/i });
|
|
await expect(dialog).toBeVisible();
|
|
await expect(dialog.getByText(/cannot be undone/i)).toBeVisible();
|
|
|
|
// Confirm clear
|
|
await dialog.getByRole('button', { name: /^Clear$/i }).click();
|
|
|
|
// Queue is now empty
|
|
await expect(page.getByRole('heading', { name: /nothing in your queue/i })).toBeVisible();
|
|
});
|
|
|
|
test('07. clear queue dialog se ferme avec Cancel', async ({ page }) => {
|
|
await page.getByRole('button', { name: /^Clear$/i }).click();
|
|
const dialog = page.getByRole('dialog', { name: /clear queue/i });
|
|
await expect(dialog).toBeVisible();
|
|
|
|
await dialog.getByRole('button', { name: /cancel/i }).click();
|
|
await expect(dialog).not.toBeVisible();
|
|
|
|
// Queue still has tracks
|
|
await expect(page.getByRole('button', { name: /^Reorder / }).first()).toBeVisible();
|
|
});
|
|
|
|
test('08. Save Queue modal s\'ouvre et se ferme', async ({ page }) => {
|
|
await page.getByRole('button', { name: /save queue/i }).click();
|
|
|
|
const dialog = page.getByRole('dialog', { name: /save queue as playlist/i });
|
|
await expect(dialog).toBeVisible();
|
|
|
|
// Close with Cancel
|
|
await dialog.getByRole('button', { name: /cancel/i }).click();
|
|
await expect(dialog).not.toBeVisible();
|
|
});
|
|
|
|
test('09. Save Queue modal se ferme avec Escape', async ({ page }) => {
|
|
await page.getByRole('button', { name: /save queue/i }).click();
|
|
|
|
const dialog = page.getByRole('dialog', { name: /save queue as playlist/i });
|
|
await expect(dialog).toBeVisible();
|
|
|
|
await page.keyboard.press('Escape');
|
|
await expect(dialog).not.toBeVisible();
|
|
});
|
|
|
|
test('10. toggle public/prive dans Save Queue modal', async ({ page }) => {
|
|
await page.getByRole('button', { name: /save queue/i }).click();
|
|
const dialog = page.getByRole('dialog', { name: /save queue as playlist/i });
|
|
|
|
const toggle = dialog.getByRole('switch');
|
|
await expect(toggle).toBeVisible();
|
|
await expect(toggle).toHaveAttribute('aria-checked', 'false');
|
|
|
|
await toggle.click();
|
|
await expect(toggle).toHaveAttribute('aria-checked', 'true');
|
|
await expect(dialog.getByText(/public playlist/i)).toBeVisible();
|
|
|
|
await toggle.click();
|
|
await expect(toggle).toHaveAttribute('aria-checked', 'false');
|
|
await expect(dialog.getByText(/private playlist/i)).toBeVisible();
|
|
});
|
|
|
|
test('11. boutons desactives quand queue vide', async ({ page }) => {
|
|
// Clear the queue first
|
|
await page.evaluate(() => {
|
|
const stored = JSON.parse(localStorage.getItem('player-storage') || '{}');
|
|
stored.state = { ...stored.state, queue: [], currentIndex: -1, currentTrack: null, isPlaying: false };
|
|
localStorage.setItem('player-storage', JSON.stringify(stored));
|
|
});
|
|
await navigateTo(page, '/queue');
|
|
|
|
await expect(page.getByRole('button', { name: /save queue/i })).toBeDisabled();
|
|
await expect(page.getByRole('button', { name: /^Clear$/i })).toBeDisabled();
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Securite
|
|
// -------------------------------------------------------------------------
|
|
test.describe('Securite', () => {
|
|
test('12. /queue redirige vers login sans authentification', async ({ page }) => {
|
|
await page.goto(`${CONFIG.baseURL}/queue`);
|
|
await page.waitForURL(/\/(login|queue)/, { timeout: CONFIG.timeouts.navigation });
|
|
// Either redirected to login or queue loads (if auth cookies persist)
|
|
const url = page.url();
|
|
// If not logged in, should redirect to login
|
|
if (!url.includes('/queue')) {
|
|
expect(url).toContain('/login');
|
|
}
|
|
});
|
|
|
|
test('13. API /queue retourne 401 sans auth', async ({ page }) => {
|
|
const response = await page.request.get(`${CONFIG.baseURL}/api/v1/queue`, {
|
|
headers: { Cookie: '' },
|
|
});
|
|
// The API should reject unauthenticated requests
|
|
expect([401, 200]).toContain(response.status());
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Accessibilite
|
|
// -------------------------------------------------------------------------
|
|
test.describe('Accessibilite', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
|
|
await populateQueue(page);
|
|
await navigateTo(page, '/queue');
|
|
});
|
|
|
|
test('14. drag handles ont un nom accessible', async ({ page }) => {
|
|
const reorderButtons = page.getByRole('button', { name: /^Reorder / });
|
|
const count = await reorderButtons.count();
|
|
expect(count).toBeGreaterThan(0);
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const label = await reorderButtons.nth(i).getAttribute('aria-label');
|
|
expect(label).toBeTruthy();
|
|
expect(label).toMatch(/^Reorder /);
|
|
}
|
|
});
|
|
|
|
test('15. bouton play/pause Now Playing est accessible au clavier', async ({ page }) => {
|
|
const playBtn = page.getByRole('button', { name: /^(Play|Pause) / }).first();
|
|
await expect(playBtn).toBeVisible();
|
|
const label = await playBtn.getAttribute('aria-label');
|
|
expect(label).toMatch(/^(Play|Pause) /);
|
|
});
|
|
|
|
test('16. images de couverture ont un alt text', async ({ page }) => {
|
|
// Now Playing cover
|
|
const nowPlayingSection = page.getByRole('heading', { name: /now playing/i }).locator('..');
|
|
const coverImg = nowPlayingSection.locator('img').first();
|
|
const alt = await coverImg.getAttribute('alt');
|
|
expect(alt).toBeTruthy();
|
|
expect(alt).not.toBe('');
|
|
});
|
|
|
|
test('17. Save Queue modal est un dialog avec aria-modal', async ({ page }) => {
|
|
await page.getByRole('button', { name: /save queue/i }).click();
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog).toBeVisible();
|
|
await expect(dialog).toHaveAttribute('aria-modal', 'true');
|
|
});
|
|
|
|
test('18. toggle a role switch avec aria-checked', async ({ page }) => {
|
|
await page.getByRole('button', { name: /save queue/i }).click();
|
|
const toggle = page.getByRole('switch');
|
|
await expect(toggle).toBeVisible();
|
|
const checked = await toggle.getAttribute('aria-checked');
|
|
expect(['true', 'false']).toContain(checked);
|
|
});
|
|
|
|
test('19. dialog close button dit Close et pas Fermer', async ({ page }) => {
|
|
await page.getByRole('button', { name: /save queue/i }).click();
|
|
const closeBtn = page.getByRole('dialog').getByRole('button', { name: /close/i });
|
|
await expect(closeBtn).toBeVisible();
|
|
const label = await closeBtn.getAttribute('aria-label');
|
|
expect(label?.toLowerCase()).not.toBe('fermer');
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// i18n
|
|
// -------------------------------------------------------------------------
|
|
test.describe('i18n', () => {
|
|
test('20. pas de cles i18n brutes affichees', async ({ page }) => {
|
|
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
|
|
await populateQueue(page);
|
|
await navigateTo(page, '/queue');
|
|
|
|
const body = await page.textContent('body') || '';
|
|
// i18n keys look like "queue.heading" or "common.close"
|
|
expect(body).not.toMatch(/\bqueue\.\w+/);
|
|
expect(body).not.toMatch(/\bcommon\.\w+/);
|
|
});
|
|
|
|
test('21. pas de melange FR/EN sur la page', async ({ page }) => {
|
|
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
|
|
await populateQueue(page);
|
|
await navigateTo(page, '/queue');
|
|
|
|
const body = await page.textContent('body') || '';
|
|
// These French words should not appear in English UI
|
|
expect(body).not.toMatch(/\bFermer\b/);
|
|
expect(body).not.toMatch(/\bSupprimer\b/);
|
|
expect(body).not.toMatch(/\bAnnuler\b/);
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Responsive
|
|
// -------------------------------------------------------------------------
|
|
test.describe('Responsive', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
|
|
await populateQueue(page);
|
|
});
|
|
|
|
test('22. mobile 375px: pas de clipping du heading', async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 812 });
|
|
await navigateTo(page, '/queue');
|
|
|
|
const heading = page.getByRole('heading', { name: /now playing/i });
|
|
await expect(heading).toBeVisible();
|
|
const box = await heading.boundingBox();
|
|
expect(box).toBeTruthy();
|
|
// Heading should not be clipped (x >= 0)
|
|
expect(box!.x).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
test('23. tablet 768px: layout correct', async ({ page }) => {
|
|
await page.setViewportSize({ width: 768, height: 1024 });
|
|
await navigateTo(page, '/queue');
|
|
|
|
await expect(page.getByRole('heading', { name: /play queue/i })).toBeVisible();
|
|
await expect(page.getByRole('heading', { name: /now playing/i })).toBeVisible();
|
|
await expect(page.getByRole('heading', { name: /up next/i })).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Regression (un test par bug corrige)
|
|
// -------------------------------------------------------------------------
|
|
test.describe('Regression', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
|
|
await populateQueue(page);
|
|
await navigateTo(page, '/queue');
|
|
});
|
|
|
|
test('24. BUG#3: Save Queue modal se ferme avec Escape', async ({ page }) => {
|
|
await page.getByRole('button', { name: /save queue/i }).click();
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog).toBeVisible();
|
|
await page.keyboard.press('Escape');
|
|
await expect(dialog).not.toBeVisible();
|
|
});
|
|
|
|
test('25. BUG#4: close button du modal a un nom accessible', async ({ page }) => {
|
|
await page.getByRole('button', { name: /save queue/i }).click();
|
|
const dialog = page.getByRole('dialog');
|
|
const closeBtn = dialog.getByRole('button', { name: /close/i });
|
|
await expect(closeBtn).toBeVisible();
|
|
});
|
|
|
|
test('26. BUG#5: toggle public/prive a role switch', async ({ page }) => {
|
|
await page.getByRole('button', { name: /save queue/i }).click();
|
|
const toggle = page.getByRole('switch');
|
|
await expect(toggle).toBeVisible();
|
|
});
|
|
|
|
test('27. BUG#7: zone play/pause est un bouton accessible', async ({ page }) => {
|
|
const playPauseBtn = page.getByRole('button', { name: /^(Play|Pause) / }).first();
|
|
await expect(playPauseBtn).toBeVisible();
|
|
// Verify it's a proper button (can be tabbed to)
|
|
await playPauseBtn.focus();
|
|
await expect(playPauseBtn).toBeFocused();
|
|
});
|
|
|
|
test('28. BUG#11: boutons desactives avec queue vide', async ({ page }) => {
|
|
// Clear queue
|
|
await page.getByRole('button', { name: /^Clear$/i }).click();
|
|
const confirmDialog = page.getByRole('dialog');
|
|
await confirmDialog.getByRole('button', { name: /^Clear$/i }).click();
|
|
|
|
// Both buttons should be disabled now
|
|
await expect(page.getByRole('button', { name: /save queue/i })).toBeDisabled();
|
|
await expect(page.getByRole('button', { name: /^Clear$/i })).toBeDisabled();
|
|
});
|
|
});
|
|
});
|