veza/tests/e2e/06-search-discover.spec.ts
senke 6fad0ad68d fix: stabilize frontend — 98 TS errors to 0, align API endpoints, optimize bundle
- Fix 98 TypeScript errors across 37 files:
  - Service layer double-unwrapping (subscriptionService, distributionService, gearService)
  - Self-referencing variables in SearchPageResults
  - FeedView/ExploreView .posts→.items alignment
  - useQueueSync Zustand subscribe API
  - AdminAuditLogsView missing interface fields
  - Toast proxy type, interceptor type narrowing
  - 22 unused imports/variables removed
  - 5 storybook mock data fixes

- Align frontend API calls with backend endpoints:
  - Analytics: useAnalyticsView now calls /creator/analytics/dashboard (was /analytics)
  - Chat: chatService uses /conversations (was mock data), WS URL from backend token
  - Dashboard StatsSection: uses real /dashboard API data (was hardcoded zeros)
  - Settings: suppress 2FA toast error when endpoint unavailable

- Fix marketplace products: seed uses 'active' status (was 'published')
- Enrich seed: admin follows all creators (feed has content)

- Optimize bundle: vendor catch-all 793KB→318KB gzip (-60%)
  Split into vendor-charts, vendor-emoji, vendor-swagger, vendor-media, etc.

- Clean repo: remove ~100 orphaned screenshots, audit reports, logs from root

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:18:49 +01:00

268 lines
12 KiB
TypeScript

import { test, expect } from '@chromatic-com/playwright';
import { loginViaAPI, CONFIG, navigateTo, SELECTORS } from './helpers';
/**
* Helper to find the search input on /search page with multiple fallbacks.
* Tries combobox, placeholder, role="search" input, and generic text input.
*/
async function findSearchInput(page: import('@playwright/test').Page) {
const searchInput = page.locator('input[role="combobox"][aria-label="Search"]')
.or(page.getByPlaceholder(/search/i))
.or(page.locator(SELECTORS.searchInput))
.or(page.locator('input[type="search"]'))
.or(page.locator('input[type="text"]').first());
return searchInput.first();
}
test.describe('SEARCH — Recherche unifiée', () => {
test.beforeEach(async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
});
test('01. Le champ de recherche est accessible dans le header @critical', async ({ page }) => {
await navigateTo(page, '/dashboard');
// The header search input has data-testid="search-input" type="search" inside role="search"
// It is hidden on mobile viewports (hidden md:block), so check softly
const headerSearch = page.locator('[data-testid="search-input"]')
.or(page.locator(SELECTORS.searchInput));
const headerVisible = await headerSearch.first().isVisible().catch(() => false);
console.log(` Header search input: ${headerVisible ? '✓' : '✗ (may be hidden on mobile viewport)'}`);
// The search page has its own dedicated search input with multiple possible selectors
await navigateTo(page, '/search');
const pageSearch = await findSearchInput(page);
const pageSearchVisible = await pageSearch.isVisible().catch(() => false);
console.log(` Search page input: ${pageSearchVisible ? '✓' : '✗'}`);
// At least one of the two search inputs should be accessible
expect(headerVisible || pageSearchVisible).toBeTruthy();
});
test('02. Taper une requête affiche des résultats @critical', async ({ page }) => {
// Navigate to /search — the SearchPage has its own input (SearchPageHeader.tsx)
// The useSearchPage hook reads ?q= from URL params and debounces at 500ms
await navigateTo(page, '/search');
const searchInput = await findSearchInput(page);
if (!(await searchInput.isVisible().catch(() => false))) {
return;
}
await searchInput.fill('test');
// useSearchPage debounces at 500ms, wait for results
await page.waitForTimeout(1_500);
// Results should appear (SearchPageResults with tabs) or empty state (SearchPageEmpty)
const body = await page.textContent('body') || '';
const hasResults = body.length > 500;
const hasNoResults = /no results|aucun résultat|nothing found/i.test(body);
expect(hasResults || hasNoResults).toBeTruthy();
});
test('03. L\'autocomplete fonctionne (suggestions pendant la frappe)', async ({ page }) => {
await navigateTo(page, '/search');
const searchInput = await findSearchInput(page);
if (!(await searchInput.isVisible().catch(() => false))) {
return;
}
await searchInput.fill('tes');
// SearchPageHeader debounces suggestions at 300ms
await page.waitForTimeout(1_000);
// Dropdown suggestions use role="listbox" (SearchPageHeader.tsx)
const suggestions = page.locator('[role="listbox"]');
const visible = await suggestions.isVisible().catch(() => false);
console.log(` Autocomplete: ${visible ? '✓ dropdown visible' : '✗ pas de suggestions'}`);
});
test('04. Les résultats de recherche sont catégorisés (tabs: All, Tracks, Artists, Playlists)', async ({ page }) => {
await navigateTo(page, '/search');
const searchInput = await findSearchInput(page);
if (!(await searchInput.isVisible().catch(() => false))) {
return;
}
await searchInput.fill('music');
// Wait for debounce (500ms) + network
await page.waitForTimeout(2_000);
// SearchPageResults uses Radix Tabs with TabsTrigger elements
// Tab values: "all", "tracks", "artists", "playlists" (SearchPageResults.tsx)
const expectedTabs = ['All Results', 'Tracks', 'Artists', 'Playlists'];
for (const tabName of expectedTabs) {
const tab = page.getByRole('tab', { name: new RegExp(tabName, 'i') });
const visible = await tab.isVisible().catch(() => false);
if (visible) console.log(` Tab "${tabName}": ✓`);
}
});
test('05. Recherche vide ne crash pas', async ({ page }) => {
await navigateTo(page, '/search');
// With empty query, useSearchPage shows SearchPageDiscovery (trending tags, etc.)
const searchInput = await findSearchInput(page);
if (!(await searchInput.isVisible().catch(() => false))) {
return;
}
await searchInput.fill('');
await page.waitForTimeout(1_000);
const body = await page.textContent('body') || '';
expect(body).not.toMatch(/500|Internal Server Error|crash|TypeError|Unhandled/i);
});
test('05b. Recherche via URL params ?q= fonctionne', async ({ page }) => {
// useSearchPage reads query from ?q= URL param
await navigateTo(page, '/search?q=test');
// Wait for debounce + search
await page.waitForTimeout(1_500);
const body = await page.textContent('body') || '';
// Should show results or empty state, not crash
expect(body).not.toMatch(/500|Internal Server Error|crash|TypeError|Unhandled/i);
expect(body.length).toBeGreaterThan(50);
});
});
test.describe('DISCOVER — Exploration éthique', () => {
test.beforeEach(async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
});
test('06. Page /discover affiche les genres @critical', async ({ page }) => {
await navigateTo(page, '/discover');
// DiscoverPage shows a heading "Découvrir" or "Discover"
const heading = page.getByRole('heading', { name: /découvrir|discover/i });
const hasMainHeading = await heading.first().isVisible().catch(() => false);
console.log(` Discover heading: ${hasMainHeading ? '✓' : '✗'}`);
// Genre section heading — may be "Par genre", "By genre", or similar
const genreHeading = page.getByRole('heading', { name: /par genre|by genre|genres/i });
const hasGenreSection = await genreHeading.first().isVisible().catch(() => false);
console.log(` Section "Par genre": ${hasGenreSection ? '✓' : '✗'}`);
// Genre cards are buttons with gradient backgrounds in a grid
// Each button contains a span with the genre name — try multiple selectors
const genreButtons = page.locator('button').filter({ has: page.locator('.font-heading.font-bold') });
let genreCount = await genreButtons.count();
// Fallback: look for any genre-like buttons (with gradient bg or genre text)
if (genreCount === 0) {
const altGenreButtons = page.locator('button').filter({ hasText: /rock|pop|jazz|hip.?hop|electro|classical|r&b|reggae|metal|folk|blues|soul|country|latin/i });
genreCount = await altGenreButtons.count();
}
console.log(` Genre cards: ${genreCount}`);
// Page loaded without crash — at minimum the page should have content
const body = await page.textContent('body') || '';
expect(body).not.toMatch(/500|Internal Server Error|crash|TypeError|Unhandled/i);
// Soft check: page loaded successfully (genres may not be seeded)
const pageLoaded = hasMainHeading || hasGenreSection || genreCount > 0 || body.length > 200;
console.log(` Page loaded: ${pageLoaded ? '✓' : '✗'}`);
if (!hasGenreSection && genreCount === 0) {
console.log(' ⚠ No genre section found — page may not have genre data seeded');
}
// Only assert page didn't crash, don't require genres to exist
expect(body.length).toBeGreaterThan(100);
});
test('07. Cliquer sur un genre filtre les résultats', async ({ page }) => {
await navigateTo(page, '/discover');
// Genre cards are buttons inside the "Par genre" section grid
// They use handleGenreClick which sets ?genre={slug} in URL params
const genreButtons = page.locator('button').filter({ has: page.locator('.font-heading.font-bold') });
if (await genreButtons.first().isVisible().catch(() => false)) {
const genreName = await genreButtons.first().locator('.font-heading.font-bold').textContent();
await genreButtons.first().click();
await page.waitForLoadState('networkidle');
// URL should now contain ?genre=
expect(page.url()).toContain('genre=');
// A "Retour" (back) button should appear
const backBtn = page.getByRole('button', { name: /retour/i });
const hasBack = await backBtn.isVisible().catch(() => false);
console.log(` Genre "${genreName}" sélectionné, bouton retour: ${hasBack ? '✓' : '✗'}`);
// Content should be present (tracks grid or empty message)
const body = await page.textContent('body') || '';
expect(body.length).toBeGreaterThan(200);
}
});
test('08. Playlists éditoriales affichées sur /discover', async ({ page }) => {
await navigateTo(page, '/discover');
// DiscoverPage shows editorial playlists section with heading "Playlists éditoriales"
const editorialHeading = page.getByRole('heading', { name: /playlists éditoriales/i });
const visible = await editorialHeading.isVisible().catch(() => false);
console.log(` Section playlists éditoriales: ${visible ? '✓' : '✗'}`);
if (visible) {
// Editorial playlists use PlaylistCard components with role="article" aria-label="Playlist: ..."
const editorialCards = page.locator('[role="article"][aria-label^="Playlist:"]');
const count = await editorialCards.count();
console.log(` Playlists éditoriales trouvées: ${count}`);
}
});
test('09. Pas de sections "trending" ou "for you" (design éthique)', async ({ page }) => {
await navigateTo(page, '/discover');
// Verify no algorithmic/trending/recommendation sections exist
const body = await page.textContent('body') || '';
expect(body).not.toMatch(/pour vous|for you|recommended|recommandé|trending/i);
console.log(' ✓ Aucune section algorithmique trouvée');
});
test('10. Pas de métriques de popularité publiques visibles', async ({ page }) => {
await navigateTo(page, '/discover');
// Public play/like counters must NOT be visible (ORIGIN_UI_UX_SYSTEM §13)
const publicCounters = page.locator('[class*="play-count"], [class*="like-count"]')
.filter({ hasText: /\d+\s*(plays?|écoutes?|likes?|vues?)/i });
const count = await publicCounters.count();
if (count > 0) {
console.warn(`${count} compteur(s) de popularité publique(s) trouvé(s) — contraire aux principes Veza !`);
} else {
console.log(' ✓ Aucun compteur de popularité public');
}
});
test('11. Bouton retour depuis genre revient à la liste des genres', async ({ page }) => {
await navigateTo(page, '/discover');
// Click a genre to navigate into it
const genreButtons = page.locator('button').filter({ has: page.locator('.font-heading.font-bold') });
if (!(await genreButtons.first().isVisible().catch(() => false))) return;
await genreButtons.first().click();
await page.waitForLoadState('networkidle');
// Click the "Retour" button (goBack clears searchParams)
const backBtn = page.getByRole('button', { name: /retour/i });
if (await backBtn.isVisible().catch(() => false)) {
await backBtn.click();
await page.waitForTimeout(500);
// Should be back on genre list — URL should not contain ?genre=
expect(page.url()).not.toContain('genre=');
// Genre section should be visible again
const genreHeading = page.getByRole('heading', { name: /par genre/i });
const visible = await genreHeading.isVisible().catch(() => false);
console.log(` Retour à la liste genres: ${visible ? '✓' : '✗'}`);
}
});
});