9 deep E2E test files (303 tests total): 41-chat(33) 42-player(31) 43-upload(28) 44-auth(37) 45-playlists(35) 46-search(32) 47-social(30) 48-marketplace(30) 49-settings(37) Fix WebSocket origin bug (Chat never worked): GetAllowedWebSocketOrigins() excluded localhost/127.0.0.1 in dev. Fix lint-staged gofmt: pass files as args not stdin. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
940 lines
34 KiB
TypeScript
940 lines
34 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
|
import { loginViaAPI, CONFIG, navigateTo } from './helpers';
|
|
|
|
/**
|
|
* SEARCH & DISCOVER DEEP — Comprehensive behavioral coverage
|
|
*
|
|
* Verifies that search actually returns results, filters work end-to-end,
|
|
* discovery flows navigate correctly, and empty/error states display.
|
|
* All assertions rely on real DOM counts + API responses — never console logs.
|
|
*/
|
|
|
|
const BASE = CONFIG.baseURL;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers (page-scoped; no globals)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** The /search page search input (combobox inside <main>, auto-focused). */
|
|
function mainSearchInput(page: Page) {
|
|
const main = page.locator('main, [role="main"]').first();
|
|
return main.locator('input[role="combobox"]').first();
|
|
}
|
|
|
|
/** The persistent desktop header search (data-testid="search-input"). */
|
|
function headerSearchInput(page: Page) {
|
|
return page.locator('[data-testid="search-input"]').first();
|
|
}
|
|
|
|
/** Platform-aware modifier for Cmd+K / Ctrl+K. */
|
|
function modKey(): 'Meta' | 'Control' {
|
|
return process.platform === 'darwin' ? 'Meta' : 'Control';
|
|
}
|
|
|
|
/** Perform a direct API search via the authenticated browser context. */
|
|
async function apiSearch(
|
|
page: Page,
|
|
query: string,
|
|
types?: string[],
|
|
): Promise<{
|
|
tracks: unknown[];
|
|
artists: unknown[];
|
|
playlists: unknown[];
|
|
status: number;
|
|
}> {
|
|
const params = new URLSearchParams({ q: query });
|
|
if (types) types.forEach((t) => params.append('type', t));
|
|
const resp = await page.request.get(
|
|
`${BASE}/api/v1/search?${params.toString()}`,
|
|
);
|
|
const status = resp.status();
|
|
if (!resp.ok()) {
|
|
return { tracks: [], artists: [], playlists: [], status };
|
|
}
|
|
const data = (await resp.json()) as {
|
|
tracks?: unknown[];
|
|
artists?: unknown[];
|
|
playlists?: unknown[];
|
|
};
|
|
return {
|
|
tracks: data.tracks ?? [],
|
|
artists: data.artists ?? [],
|
|
playlists: data.playlists ?? [],
|
|
status,
|
|
};
|
|
}
|
|
|
|
/** Wait for search debounce (500ms in useSearchPage) + network. */
|
|
async function waitForSearchDebounce(page: Page) {
|
|
await page.waitForTimeout(700);
|
|
await page
|
|
.waitForLoadState('networkidle', { timeout: 5_000 })
|
|
.catch(() => {});
|
|
}
|
|
|
|
/** Generate a string of random letters guaranteed to return no results. */
|
|
function uniqueNoMatchQuery(): string {
|
|
const randomPart = Math.random().toString(36).slice(2, 10);
|
|
return `zzxxqq${randomPart}nomatchever`;
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 1. SEARCH INPUT (5 tests)
|
|
// ===========================================================================
|
|
test.describe('Search Input', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginViaAPI(
|
|
page,
|
|
CONFIG.users.listener.email,
|
|
CONFIG.users.listener.password,
|
|
);
|
|
});
|
|
|
|
test('01. /search input is focused on load (autoFocus)', async ({
|
|
page,
|
|
}) => {
|
|
await navigateTo(page, '/search');
|
|
const input = mainSearchInput(page);
|
|
await expect(input).toBeVisible({ timeout: CONFIG.timeouts.action });
|
|
|
|
// autoFocus should make input the active element
|
|
const isFocused = await input.evaluate((el) => el === document.activeElement);
|
|
expect(isFocused).toBeTruthy();
|
|
});
|
|
|
|
test('02. Header search input is visible from any page (desktop)', async ({
|
|
page,
|
|
}) => {
|
|
await navigateTo(page, '/dashboard');
|
|
const header = headerSearchInput(page);
|
|
await expect(header).toBeVisible({ timeout: CONFIG.timeouts.action });
|
|
|
|
// From a different page it should still be present
|
|
await navigateTo(page, '/feed');
|
|
const headerOnFeed = headerSearchInput(page);
|
|
await expect(headerOnFeed).toBeVisible({ timeout: CONFIG.timeouts.action });
|
|
|
|
// Enter in header navigates to /search?q=
|
|
await headerOnFeed.fill('electronic');
|
|
await headerOnFeed.press('Enter');
|
|
await page.waitForURL(/\/search/, { timeout: CONFIG.timeouts.navigation });
|
|
expect(page.url()).toContain('q=electronic');
|
|
});
|
|
|
|
test('03. Cmd+K (or Ctrl+K) focuses the search input', async ({ page }) => {
|
|
await navigateTo(page, '/dashboard');
|
|
// Ensure focus is NOT on the header search at the start (click another area)
|
|
await page.locator('body').click({ position: { x: 10, y: 10 } }).catch(() => {});
|
|
await page.waitForTimeout(200);
|
|
|
|
await page.keyboard.press(`${modKey()}+KeyK`);
|
|
await page.waitForTimeout(300);
|
|
|
|
// After pressing, the active element should be a search input
|
|
const activeTag = await page.evaluate(() => ({
|
|
tag: document.activeElement?.tagName,
|
|
type: (document.activeElement as HTMLInputElement | null)?.type,
|
|
placeholder: (document.activeElement as HTMLInputElement | null)?.placeholder,
|
|
}));
|
|
// Either focus moved to an input (header search) OR the page navigated to /search
|
|
const onSearchPage = page.url().includes('/search');
|
|
const focusedInputOnSearch =
|
|
activeTag.tag === 'INPUT' && /search|recherch|pist/i.test(activeTag.placeholder ?? '');
|
|
|
|
expect(onSearchPage || focusedInputOnSearch).toBeTruthy();
|
|
});
|
|
|
|
test('04. Clear button empties the input and shows discovery view', async ({
|
|
page,
|
|
}) => {
|
|
await navigateTo(page, '/search?q=jazz');
|
|
await waitForSearchDebounce(page);
|
|
|
|
const input = mainSearchInput(page);
|
|
await expect(input).toBeVisible();
|
|
await expect(input).toHaveValue('jazz');
|
|
|
|
const clearBtn = page.getByRole('button', { name: /clear search|effacer/i }).first();
|
|
await expect(clearBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
|
|
await clearBtn.click();
|
|
|
|
// Wait for debounce to catch up (empty triggers clear)
|
|
await page.waitForTimeout(800);
|
|
await expect(input).toHaveValue('');
|
|
|
|
// Discovery view should be visible (New Releases / Curated / Explore cards)
|
|
const discoveryCards = page
|
|
.getByRole('link', { name: /new releases|curated|explore artists|nouveau|découvrir/i });
|
|
await expect(discoveryCards.first()).toBeVisible({ timeout: 3_000 });
|
|
});
|
|
|
|
test('05. URL updates with ?q= param when typing (debounced)', async ({
|
|
page,
|
|
}) => {
|
|
await navigateTo(page, '/search');
|
|
const input = mainSearchInput(page);
|
|
await expect(input).toBeVisible();
|
|
|
|
await input.fill('ambient');
|
|
// Debounce is 500ms in useSearchPage
|
|
await waitForSearchDebounce(page);
|
|
|
|
expect(page.url()).toMatch(/[?&]q=ambient/);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// 2. AUTOCOMPLETE (5 tests)
|
|
// ===========================================================================
|
|
test.describe('Autocomplete', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginViaAPI(
|
|
page,
|
|
CONFIG.users.listener.email,
|
|
CONFIG.users.listener.password,
|
|
);
|
|
});
|
|
|
|
test('06. Typing 2+ chars opens suggestions dropdown (if matches exist)', async ({
|
|
page,
|
|
}) => {
|
|
await navigateTo(page, '/search');
|
|
const input = mainSearchInput(page);
|
|
await expect(input).toBeVisible();
|
|
|
|
// Verify API returns something for "a" (single char - common letter)
|
|
const suggestionsResp = await page.request.get(
|
|
`${BASE}/api/v1/search/suggestions?q=a&limit=5`,
|
|
);
|
|
|
|
await input.fill('a');
|
|
// Autocomplete debounce is 300ms
|
|
await page.waitForTimeout(600);
|
|
|
|
const hasAPIResults =
|
|
suggestionsResp.ok() &&
|
|
(() => {
|
|
return suggestionsResp
|
|
.json()
|
|
.then((d: { tracks?: unknown[]; artists?: unknown[]; playlists?: unknown[] }) => {
|
|
return (
|
|
(d.tracks?.length ?? 0) > 0 ||
|
|
(d.artists?.length ?? 0) > 0 ||
|
|
(d.playlists?.length ?? 0) > 0
|
|
);
|
|
});
|
|
})();
|
|
|
|
const hasMatches = await hasAPIResults;
|
|
|
|
if (hasMatches) {
|
|
const listbox = page.locator('#search-suggestions[role="listbox"]');
|
|
await expect(listbox).toBeVisible({ timeout: 3_000 });
|
|
|
|
// Input should reflect expanded state
|
|
await expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
} else {
|
|
test.skip(true, 'API returned no suggestions for "a" — skipping dropdown assertion');
|
|
}
|
|
});
|
|
|
|
test('07. Suggestions show tracks/artists/playlists with category prefix', async ({
|
|
page,
|
|
}) => {
|
|
await navigateTo(page, '/search');
|
|
const input = mainSearchInput(page);
|
|
|
|
// Use a single char as fallback - likely to match many
|
|
await input.fill('a');
|
|
await page.waitForTimeout(700);
|
|
|
|
const listbox = page.locator('#search-suggestions[role="listbox"]');
|
|
const listboxVisible = await listbox.isVisible().catch(() => false);
|
|
|
|
if (!listboxVisible) {
|
|
test.skip(true, 'No suggestions available in seed data');
|
|
}
|
|
|
|
const options = listbox.getByRole('option');
|
|
const count = await options.count();
|
|
expect(count).toBeGreaterThan(0);
|
|
|
|
// Each suggestion has format "Tracks: Xxx", "Artists: Yyy", or "Playlists: Zzz"
|
|
const firstOptionText = (await options.first().textContent()) ?? '';
|
|
expect(firstOptionText).toMatch(/tracks|artists|playlists|pistes|artistes/i);
|
|
});
|
|
|
|
test('08. Clicking a suggestion fills the input with selected text', async ({
|
|
page,
|
|
}) => {
|
|
await navigateTo(page, '/search');
|
|
const input = mainSearchInput(page);
|
|
|
|
await input.fill('a');
|
|
await page.waitForTimeout(700);
|
|
|
|
const listbox = page.locator('#search-suggestions[role="listbox"]');
|
|
if (!(await listbox.isVisible().catch(() => false))) {
|
|
test.skip(true, 'No suggestions available');
|
|
}
|
|
|
|
const firstOption = listbox.getByRole('option').first();
|
|
const optionText = (await firstOption.textContent()) ?? '';
|
|
// Strip category prefix "Tracks: " / "Artists: " / "Playlists: "
|
|
const expectedText = optionText.replace(/^[^:]+:\s*/, '').trim();
|
|
|
|
await firstOption.click();
|
|
await page.waitForTimeout(300);
|
|
|
|
// Input value should now contain the suggestion text
|
|
const newValue = await input.inputValue();
|
|
expect(newValue.length).toBeGreaterThan(0);
|
|
// Suggestion text should match (case-insensitive)
|
|
expect(newValue.toLowerCase()).toBe(expectedText.toLowerCase());
|
|
|
|
// Dropdown should close after selection
|
|
await expect(listbox).not.toBeVisible({ timeout: 2_000 });
|
|
});
|
|
|
|
test('09. Typing triggers the aria-expanded=true state on combobox', async ({
|
|
page,
|
|
}) => {
|
|
await navigateTo(page, '/search');
|
|
const input = mainSearchInput(page);
|
|
|
|
// Initially collapsed
|
|
await expect(input).toHaveAttribute('aria-expanded', 'false');
|
|
|
|
await input.fill('a');
|
|
await page.waitForTimeout(700);
|
|
|
|
const listbox = page.locator('#search-suggestions[role="listbox"]');
|
|
if (await listbox.isVisible().catch(() => false)) {
|
|
await expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
await expect(input).toHaveAttribute('aria-haspopup', 'listbox');
|
|
await expect(input).toHaveAttribute('aria-controls', 'search-suggestions');
|
|
} else {
|
|
test.skip(true, 'No suggestions available');
|
|
}
|
|
});
|
|
|
|
test('10. Clicking outside closes the suggestions dropdown', async ({
|
|
page,
|
|
}) => {
|
|
await navigateTo(page, '/search');
|
|
const input = mainSearchInput(page);
|
|
|
|
await input.fill('a');
|
|
await page.waitForTimeout(700);
|
|
|
|
const listbox = page.locator('#search-suggestions[role="listbox"]');
|
|
if (!(await listbox.isVisible().catch(() => false))) {
|
|
test.skip(true, 'No suggestions available');
|
|
}
|
|
|
|
// Click outside the search container
|
|
await page.locator('h1').first().click({ force: true });
|
|
await page.waitForTimeout(300);
|
|
|
|
await expect(listbox).not.toBeVisible({ timeout: 2_000 });
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// 3. SEARCH RESULTS (6 tests)
|
|
// ===========================================================================
|
|
test.describe('Search Results', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginViaAPI(
|
|
page,
|
|
CONFIG.users.listener.email,
|
|
CONFIG.users.listener.password,
|
|
);
|
|
});
|
|
|
|
test('11. Real query returns results or shows "No results found"', async ({
|
|
page,
|
|
}) => {
|
|
await navigateTo(page, '/search?q=music');
|
|
await waitForSearchDebounce(page);
|
|
|
|
// Either tabs are visible (results) OR empty state text
|
|
const tabs = page.getByRole('tab', { name: /all results|^tracks|^artists|^playlists/i });
|
|
const emptyState = page.getByText(/no results found|aucun résultat/i).first();
|
|
|
|
const hasTabs = await tabs.first().isVisible({ timeout: 3_000 }).catch(() => false);
|
|
const hasEmpty = await emptyState.isVisible({ timeout: 3_000 }).catch(() => false);
|
|
|
|
expect(hasTabs || hasEmpty).toBeTruthy();
|
|
|
|
// Verify API responds successfully
|
|
const api = await apiSearch(page, 'music');
|
|
expect(api.status).toBe(200);
|
|
});
|
|
|
|
test('12. All Results tab shows mixed content (tracks + artists sections)', async ({
|
|
page,
|
|
}) => {
|
|
// Use a common letter that likely matches multiple result types
|
|
const api = await apiSearch(page, 'a');
|
|
if (
|
|
api.tracks.length === 0 &&
|
|
api.artists.length === 0 &&
|
|
api.playlists.length === 0
|
|
) {
|
|
test.skip(true, 'No seed data matches query "a"');
|
|
}
|
|
|
|
await navigateTo(page, '/search?q=a');
|
|
await waitForSearchDebounce(page);
|
|
|
|
const allTab = page.getByRole('tab', { name: /all results|tous les résultats/i });
|
|
await expect(allTab).toBeVisible({ timeout: 5_000 });
|
|
await expect(allTab).toHaveAttribute('data-state', 'active');
|
|
|
|
// Check that the active All panel contains at least one of Top Tracks or Artists
|
|
const topTracksHeading = page.getByRole('heading', { name: /top tracks|pistes|morceaux populaires/i });
|
|
const artistsHeading = page
|
|
.locator('[data-state="active"]')
|
|
.getByRole('heading', { name: /^artists$|^artistes$/i });
|
|
|
|
const tracksVisible = await topTracksHeading.isVisible({ timeout: 2_000 }).catch(() => false);
|
|
const artistsVisible = await artistsHeading.first().isVisible({ timeout: 2_000 }).catch(() => false);
|
|
|
|
// At least one section should render given the API returned data
|
|
if (api.tracks.length > 0) {
|
|
expect(tracksVisible).toBeTruthy();
|
|
}
|
|
if (api.artists.length > 0) {
|
|
expect(artistsVisible).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
test('13. Tracks tab shows only tracks with matching count', async ({
|
|
page,
|
|
}) => {
|
|
const api = await apiSearch(page, 'a');
|
|
if (api.tracks.length === 0) {
|
|
test.skip(true, 'No tracks match "a" in seed data');
|
|
}
|
|
|
|
await navigateTo(page, '/search?q=a');
|
|
await waitForSearchDebounce(page);
|
|
|
|
const tracksTab = page.getByRole('tab', { name: /^tracks\s*\(\d+\)/i });
|
|
await expect(tracksTab).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Verify count in label matches API
|
|
const label = (await tracksTab.textContent()) ?? '';
|
|
const match = label.match(/\((\d+)\)/);
|
|
expect(match).not.toBeNull();
|
|
const uiCount = parseInt(match![1]!, 10);
|
|
expect(uiCount).toBe(api.tracks.length);
|
|
|
|
await tracksTab.click();
|
|
await page.waitForTimeout(400);
|
|
|
|
// Tracks tab content should contain <button> elements for each track
|
|
const trackButtons = page
|
|
.locator('[role="tabpanel"][data-state="active"] button')
|
|
.filter({ hasText: /./ });
|
|
const trackCount = await trackButtons.count();
|
|
// Each track renders a button row, count should be at least 1 and match API
|
|
expect(trackCount).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
test('14. Artists tab shows only artists with matching count', async ({
|
|
page,
|
|
}) => {
|
|
const api = await apiSearch(page, 'a');
|
|
if (api.artists.length === 0) {
|
|
test.skip(true, 'No artists match "a" in seed data');
|
|
}
|
|
|
|
await navigateTo(page, '/search?q=a');
|
|
await waitForSearchDebounce(page);
|
|
|
|
const artistsTab = page.getByRole('tab', { name: /^artists\s*\(\d+\)|^artistes\s*\(\d+\)/i });
|
|
await expect(artistsTab).toBeVisible({ timeout: 5_000 });
|
|
|
|
const label = (await artistsTab.textContent()) ?? '';
|
|
const match = label.match(/\((\d+)\)/);
|
|
expect(match).not.toBeNull();
|
|
const uiCount = parseInt(match![1]!, 10);
|
|
expect(uiCount).toBe(api.artists.length);
|
|
|
|
await artistsTab.click();
|
|
await page.waitForTimeout(400);
|
|
|
|
// Verify tab became active and renders artist cards
|
|
await expect(artistsTab).toHaveAttribute('data-state', 'active');
|
|
});
|
|
|
|
test('15. Playlists tab shows only playlists with matching count', async ({
|
|
page,
|
|
}) => {
|
|
const api = await apiSearch(page, 'a');
|
|
// Skip if the tab doesn't exist (no matches)
|
|
if (
|
|
api.tracks.length === 0 &&
|
|
api.artists.length === 0 &&
|
|
api.playlists.length === 0
|
|
) {
|
|
test.skip(true, 'No results at all for "a"');
|
|
}
|
|
|
|
await navigateTo(page, '/search?q=a');
|
|
await waitForSearchDebounce(page);
|
|
|
|
const playlistsTab = page.getByRole('tab', { name: /^playlists\s*\(\d+\)/i });
|
|
await expect(playlistsTab).toBeVisible({ timeout: 5_000 });
|
|
|
|
const label = (await playlistsTab.textContent()) ?? '';
|
|
const match = label.match(/\((\d+)\)/);
|
|
expect(match).not.toBeNull();
|
|
const uiCount = parseInt(match![1]!, 10);
|
|
// UI count MUST match API response
|
|
expect(uiCount).toBe(api.playlists.length);
|
|
});
|
|
|
|
test('16. All tab counts match actual API response totals', async ({
|
|
page,
|
|
}) => {
|
|
const api = await apiSearch(page, 'a');
|
|
if (
|
|
api.tracks.length === 0 &&
|
|
api.artists.length === 0 &&
|
|
api.playlists.length === 0
|
|
) {
|
|
test.skip(true, 'No results for "a"');
|
|
}
|
|
|
|
await navigateTo(page, '/search?q=a');
|
|
await waitForSearchDebounce(page);
|
|
|
|
// Grab all three tabs and verify counts per API
|
|
const tracksTab = page.getByRole('tab', { name: /^tracks\s*\(/i });
|
|
const artistsTab = page.getByRole('tab', { name: /^artists\s*\(|^artistes\s*\(/i });
|
|
const playlistsTab = page.getByRole('tab', { name: /^playlists\s*\(/i });
|
|
|
|
const tracksText = (await tracksTab.textContent()) ?? '';
|
|
const artistsText = (await artistsTab.textContent()) ?? '';
|
|
const playlistsText = (await playlistsTab.textContent()) ?? '';
|
|
|
|
const tracksCount = parseInt(tracksText.match(/\((\d+)\)/)?.[1] ?? '-1', 10);
|
|
const artistsCount = parseInt(artistsText.match(/\((\d+)\)/)?.[1] ?? '-1', 10);
|
|
const playlistsCount = parseInt(playlistsText.match(/\((\d+)\)/)?.[1] ?? '-1', 10);
|
|
|
|
expect(tracksCount).toBe(api.tracks.length);
|
|
expect(artistsCount).toBe(api.artists.length);
|
|
expect(playlistsCount).toBe(api.playlists.length);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// 4. SEARCH FILTERS / REFINEMENT (3 tests)
|
|
// ===========================================================================
|
|
test.describe('Search Refinement', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginViaAPI(
|
|
page,
|
|
CONFIG.users.listener.email,
|
|
CONFIG.users.listener.password,
|
|
);
|
|
});
|
|
|
|
test('17. Results update as query changes (debounced)', async ({ page }) => {
|
|
await navigateTo(page, '/search');
|
|
const input = mainSearchInput(page);
|
|
|
|
// First query
|
|
await input.fill('music');
|
|
await waitForSearchDebounce(page);
|
|
expect(page.url()).toMatch(/q=music/);
|
|
|
|
// Change query
|
|
await input.fill('jazz');
|
|
await waitForSearchDebounce(page);
|
|
expect(page.url()).toMatch(/q=jazz/);
|
|
|
|
// URL should have been updated (proof that useEffect fired)
|
|
expect(page.url()).not.toMatch(/q=music/);
|
|
});
|
|
|
|
test('18. Help text "Use AND, OR, NOT" is discoverable via HelpText component', async ({
|
|
page,
|
|
}) => {
|
|
await navigateTo(page, '/search');
|
|
|
|
// HelpText is a tooltip/popover trigger — search for the button or icon
|
|
// The text content appears in the DOM (may be hidden until hover)
|
|
const helpContainer = page.locator('[aria-label*="help" i], [data-help-text], button').filter({
|
|
has: page.locator('svg'),
|
|
});
|
|
|
|
// Inspect the page content for the help string (rendered via HelpText component)
|
|
const pageHTML = await page.content();
|
|
const hasHelpText = /AND,?\s*OR,?\s*NOT|"exact phrase"|expression exacte/i.test(
|
|
pageHTML,
|
|
);
|
|
|
|
expect(hasHelpText).toBeTruthy();
|
|
// Also verify the help icon is rendered near the input (accessibility)
|
|
await expect(helpContainer.first()).toBeVisible({ timeout: 3_000 });
|
|
});
|
|
|
|
test('19. Long query (200+ chars) handled without crash', async ({ page }) => {
|
|
await navigateTo(page, '/search');
|
|
const input = mainSearchInput(page);
|
|
|
|
const longQuery = 'electronic music ' + 'x'.repeat(200);
|
|
await input.fill(longQuery);
|
|
await waitForSearchDebounce(page);
|
|
|
|
// Page must not show server errors
|
|
const body = (await page.textContent('body')) ?? '';
|
|
expect(body).not.toMatch(/500|Internal Server Error|TypeError|Unhandled/i);
|
|
expect(body.length).toBeGreaterThan(50);
|
|
|
|
// Input retains the value
|
|
const currentValue = await input.inputValue();
|
|
expect(currentValue.length).toBeGreaterThanOrEqual(200);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// 5. DISCOVER NAVIGATION (4 tests)
|
|
// ===========================================================================
|
|
test.describe('Discover Navigation', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginViaAPI(
|
|
page,
|
|
CONFIG.users.listener.email,
|
|
CONFIG.users.listener.password,
|
|
);
|
|
});
|
|
|
|
test('20. /discover shows genres grid with multiple genre buttons', async ({
|
|
page,
|
|
}) => {
|
|
await navigateTo(page, '/discover');
|
|
// Wait for genres query to resolve
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
|
|
const byGenreSection = page.getByRole('region', { name: /by genre|par genre/i })
|
|
.or(page.locator('section[aria-label*="genre" i]').first());
|
|
const sectionVisible = await byGenreSection.isVisible({ timeout: 5_000 }).catch(() => false);
|
|
|
|
// Count genre buttons - they have aria-label "Browse X tracks"
|
|
const genreButtons = page.getByRole('button', { name: /browse .+ tracks|parcourir.+morceaux/i });
|
|
const count = await genreButtons.count();
|
|
|
|
// API check: verify genres exist
|
|
const apiResp = await page.request.get(`${BASE}/api/v1/discover/genres`);
|
|
expect(apiResp.status()).toBe(200);
|
|
const apiData = (await apiResp.json()) as { genres?: Array<{ slug: string }> };
|
|
const apiGenreCount = apiData.genres?.length ?? 0;
|
|
|
|
if (apiGenreCount > 0) {
|
|
expect(count).toBe(apiGenreCount);
|
|
expect(sectionVisible).toBeTruthy();
|
|
} else {
|
|
test.skip(true, 'No genres seeded in database');
|
|
}
|
|
});
|
|
|
|
test('21. Clicking genre navigates to ?genre=slug', async ({ page }) => {
|
|
await navigateTo(page, '/discover');
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
|
|
const genreButtons = page.getByRole('button', { name: /browse .+ tracks|parcourir.+morceaux/i });
|
|
const count = await genreButtons.count();
|
|
if (count === 0) {
|
|
test.skip(true, 'No genres available');
|
|
}
|
|
|
|
// Grab the aria-label before clicking
|
|
const firstGenreLabel = (await genreButtons.first().getAttribute('aria-label')) ?? '';
|
|
await genreButtons.first().click();
|
|
await page.waitForURL(/[?&]genre=/, { timeout: CONFIG.timeouts.navigation });
|
|
|
|
// URL must contain ?genre=something
|
|
expect(page.url()).toMatch(/[?&]genre=[^&]+/);
|
|
|
|
// Back button should now be visible
|
|
const backBtn = page.getByRole('button', { name: /back|retour/i });
|
|
await expect(backBtn).toBeVisible({ timeout: 3_000 });
|
|
});
|
|
|
|
test('22. Back button returns to genre list (clears URL param)', async ({
|
|
page,
|
|
}) => {
|
|
await navigateTo(page, '/discover');
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
|
|
const genreButtons = page.getByRole('button', { name: /browse .+ tracks|parcourir.+morceaux/i });
|
|
if ((await genreButtons.count()) === 0) {
|
|
test.skip(true, 'No genres available');
|
|
}
|
|
|
|
await genreButtons.first().click();
|
|
await page.waitForURL(/[?&]genre=/, { timeout: CONFIG.timeouts.navigation });
|
|
|
|
const backBtn = page.getByRole('button', { name: /back|retour/i });
|
|
await expect(backBtn).toBeVisible();
|
|
await backBtn.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// URL should no longer contain genre=
|
|
expect(page.url()).not.toMatch(/[?&]genre=/);
|
|
|
|
// Genre list should reappear
|
|
const byGenreHeading = page.getByRole('heading', { name: /by genre|par genre/i });
|
|
await expect(byGenreHeading).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test('23. URL params are preserved on reload', async ({ page }) => {
|
|
await navigateTo(page, '/discover');
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
|
|
const genreButtons = page.getByRole('button', { name: /browse .+ tracks|parcourir.+morceaux/i });
|
|
if ((await genreButtons.count()) === 0) {
|
|
test.skip(true, 'No genres available');
|
|
}
|
|
|
|
await genreButtons.first().click();
|
|
await page.waitForURL(/[?&]genre=/, { timeout: CONFIG.timeouts.navigation });
|
|
const urlBeforeReload = page.url();
|
|
const genreSlug = new URL(urlBeforeReload).searchParams.get('genre');
|
|
expect(genreSlug).toBeTruthy();
|
|
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
|
|
// After reload URL still contains genre=
|
|
expect(page.url()).toContain(`genre=${genreSlug}`);
|
|
|
|
// Back button still visible (we're still in genre view)
|
|
const backBtn = page.getByRole('button', { name: /back|retour/i });
|
|
await expect(backBtn).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// 6. DISCOVER CONTENT (4 tests)
|
|
// ===========================================================================
|
|
test.describe('Discover Content', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginViaAPI(
|
|
page,
|
|
CONFIG.users.listener.email,
|
|
CONFIG.users.listener.password,
|
|
);
|
|
});
|
|
|
|
test('24. Genre click shows tracks for that genre (or empty message)', async ({
|
|
page,
|
|
}) => {
|
|
await navigateTo(page, '/discover');
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
|
|
const genreButtons = page.getByRole('button', { name: /browse .+ tracks|parcourir.+morceaux/i });
|
|
if ((await genreButtons.count()) === 0) {
|
|
test.skip(true, 'No genres available');
|
|
}
|
|
|
|
await genreButtons.first().click();
|
|
await page.waitForURL(/[?&]genre=/, { timeout: CONFIG.timeouts.navigation });
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
|
|
// Check API returns valid response for that genre
|
|
const genreSlug = new URL(page.url()).searchParams.get('genre')!;
|
|
const apiResp = await page.request.get(
|
|
`${BASE}/api/v1/discover/genre/${encodeURIComponent(genreSlug)}?limit=20`,
|
|
);
|
|
expect(apiResp.status()).toBe(200);
|
|
const apiData = (await apiResp.json()) as { items?: unknown[] };
|
|
const apiTrackCount = apiData.items?.length ?? 0;
|
|
|
|
if (apiTrackCount > 0) {
|
|
// Track cards should render (role="article" per TrackGrid)
|
|
const trackCards = page.locator('[role="article"]');
|
|
await expect(trackCards.first()).toBeVisible({ timeout: 5_000 });
|
|
const domCount = await trackCards.count();
|
|
// DOM should render at least 1 track
|
|
expect(domCount).toBeGreaterThanOrEqual(1);
|
|
} else {
|
|
// Empty message should be displayed
|
|
const emptyMsg = page.getByText(/no tracks in this genre|aucune piste/i);
|
|
await expect(emptyMsg).toBeVisible({ timeout: 5_000 });
|
|
}
|
|
});
|
|
|
|
test('25. Editorial Playlists section renders on discover home', async ({
|
|
page,
|
|
}) => {
|
|
await navigateTo(page, '/discover');
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
|
|
const editorialHeading = page.getByRole('heading', {
|
|
name: /editorial playlists|playlists éditoriales/i,
|
|
});
|
|
await expect(editorialHeading).toBeVisible({ timeout: 5_000 });
|
|
|
|
// API check
|
|
const apiResp = await page.request.get(
|
|
`${BASE}/api/v1/discover/playlists/editorial?limit=20`,
|
|
);
|
|
expect(apiResp.status()).toBe(200);
|
|
const apiData = (await apiResp.json()) as { items?: unknown[] };
|
|
const apiPlaylistCount = apiData.items?.length ?? 0;
|
|
|
|
if (apiPlaylistCount > 0) {
|
|
// At least one playlist card should be visible
|
|
const playlistCards = page.locator('[aria-label^="Playlist:"]');
|
|
const domCount = await playlistCards.count();
|
|
expect(domCount).toBeGreaterThanOrEqual(1);
|
|
} else {
|
|
// Fallback message visible
|
|
const noPlaylistsMsg = page.getByText(
|
|
/no editorial playlists available yet|aucune playlist éditoriale/i,
|
|
);
|
|
await expect(noPlaylistsMsg).toBeVisible({ timeout: 3_000 });
|
|
}
|
|
});
|
|
|
|
test('26. No "trending" or "for you" sections — ethical design (GIR-9)', async ({
|
|
page,
|
|
}) => {
|
|
await navigateTo(page, '/discover');
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
|
|
// Check only heading texts / aria-labels (avoids matching unrelated words in nav/metadata)
|
|
const trendingHeading = page.getByRole('heading', {
|
|
name: /^trending$|^pour vous$|^for you$|^recommended$|^recommandé/i,
|
|
});
|
|
const trendingRegion = page.locator(
|
|
'[aria-label*="trending" i], [aria-label*="for you" i], [aria-label*="recommended" i]',
|
|
);
|
|
|
|
expect(await trendingHeading.count()).toBe(0);
|
|
expect(await trendingRegion.count()).toBe(0);
|
|
});
|
|
|
|
test('27. No public play counts or like counts visible on discover', async ({
|
|
page,
|
|
}) => {
|
|
await navigateTo(page, '/discover');
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
|
|
// Navigate into a genre to see track cards
|
|
const genreButtons = page.getByRole('button', { name: /browse .+ tracks|parcourir.+morceaux/i });
|
|
if ((await genreButtons.count()) > 0) {
|
|
await genreButtons.first().click();
|
|
await page.waitForTimeout(1_500);
|
|
}
|
|
|
|
// Check no visible play-count/like-count indicators with numeric values
|
|
const bodyText = (await page.textContent('body')) ?? '';
|
|
// Look for patterns like "1.2k plays", "234 plays", "1.5M likes"
|
|
const publicPlayCountPattern = /\b\d+(?:[.,]\d+)?[kKmM]?\s*(plays?|écoutes?|streams?)\b/gi;
|
|
const publicLikeCountPattern = /\b\d+(?:[.,]\d+)?[kKmM]?\s*(likes?|j'aime|favoris)\b/gi;
|
|
|
|
const playMatches = bodyText.match(publicPlayCountPattern) ?? [];
|
|
const likeMatches = bodyText.match(publicLikeCountPattern) ?? [];
|
|
|
|
expect(playMatches.length, `Public play counts found: ${playMatches.join(', ')}`).toBe(0);
|
|
expect(likeMatches.length, `Public like counts found: ${likeMatches.join(', ')}`).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// 7. EMPTY STATES (3 tests)
|
|
// ===========================================================================
|
|
test.describe('Empty States & Errors', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginViaAPI(
|
|
page,
|
|
CONFIG.users.listener.email,
|
|
CONFIG.users.listener.password,
|
|
);
|
|
});
|
|
|
|
test('28. Unique query returns "No results found" empty state', async ({
|
|
page,
|
|
}) => {
|
|
const uniqueQ = uniqueNoMatchQuery();
|
|
|
|
// Verify API returns zero results
|
|
const api = await apiSearch(page, uniqueQ);
|
|
expect(api.status).toBe(200);
|
|
expect(api.tracks.length).toBe(0);
|
|
expect(api.artists.length).toBe(0);
|
|
expect(api.playlists.length).toBe(0);
|
|
|
|
await navigateTo(page, `/search?q=${encodeURIComponent(uniqueQ)}`);
|
|
await waitForSearchDebounce(page);
|
|
|
|
// Empty state should be visible
|
|
const emptyTitle = page.getByText(/no results found|aucun résultat/i).first();
|
|
await expect(emptyTitle).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Hint text should also be visible
|
|
const emptyHint = page.getByText(
|
|
/try adjusting|different keywords|essayez d'ajuster|mots-clés/i,
|
|
);
|
|
await expect(emptyHint.first()).toBeVisible({ timeout: 3_000 });
|
|
|
|
// Tabs should NOT be visible when hasResults === false
|
|
const tabsList = page.getByRole('tablist');
|
|
await expect(tabsList).not.toBeVisible({ timeout: 2_000 });
|
|
});
|
|
|
|
test('29. Empty discover genre shows "No tracks in this genre"', async ({
|
|
page,
|
|
}) => {
|
|
// Try visiting a discover genre with a non-existent slug
|
|
const fakeSlug = `nonexistent-genre-${Math.random().toString(36).slice(2, 8)}`;
|
|
await navigateTo(page, `/discover?genre=${fakeSlug}`);
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
|
|
// Either the page shows empty message OR an error card
|
|
const emptyMsg = page.getByText(/no tracks in this genre|aucune piste|no tracks/i).first();
|
|
const errorCard = page.getByRole('alert').first();
|
|
const retryBtn = page.getByRole('button', { name: /retry|réessayer/i });
|
|
|
|
const hasEmpty = await emptyMsg.isVisible({ timeout: 5_000 }).catch(() => false);
|
|
const hasError = await errorCard.isVisible({ timeout: 2_000 }).catch(() => false);
|
|
const hasRetry = await retryBtn.isVisible({ timeout: 2_000 }).catch(() => false);
|
|
|
|
expect(hasEmpty || hasError || hasRetry).toBeTruthy();
|
|
|
|
// Page should NOT crash
|
|
const body = (await page.textContent('body')) ?? '';
|
|
expect(body).not.toMatch(/500|Internal Server Error|TypeError|Unhandled/i);
|
|
});
|
|
|
|
test('30. Search page handles whitespace-only query gracefully', async ({
|
|
page,
|
|
}) => {
|
|
await navigateTo(page, '/search');
|
|
const input = mainSearchInput(page);
|
|
await expect(input).toBeVisible();
|
|
|
|
// Whitespace-only query: hook treats as empty via debouncedQuery.trim()
|
|
await input.fill(' ');
|
|
await waitForSearchDebounce(page);
|
|
|
|
// Should not crash and should show discovery view (no search executed)
|
|
const body = (await page.textContent('body')) ?? '';
|
|
expect(body).not.toMatch(/500|Internal Server Error|TypeError|Unhandled/i);
|
|
|
|
// URL should NOT contain ?q= since trim() makes it empty
|
|
expect(page.url()).not.toMatch(/[?&]q=%20|[?&]q=\s/);
|
|
|
|
// Discovery cards should be visible since query is effectively empty
|
|
const discoveryCards = page
|
|
.getByRole('link', { name: /new releases|curated|explore|découvr/i });
|
|
await expect(discoveryCards.first()).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
});
|