import { test, expect, type Page } from '@playwright/test'; // E2E for the /search page shipped in v0.62.0. Five scenarios against // mocked endpoints — no backend needed. const DESKTOP = { width: 1280, height: 720 } as const; const userFixture = { id: 'u11111111-1111-1111-1111-111111111111', username: 'tester', created_at: '2026-01-01T00:00:00Z', is_admin: false }; const mangaId = 'a9999999-9999-9999-9999-999999999999'; const chapterId = 'c9999999-9999-9999-9999-999999999999'; const pageId = 'p11111111-1111-1111-1111-111111111111'; const distinct = [ { tag: 'funny', count: 38 }, { tag: 'fight', count: 24 } ]; const pagesResponse = { items: [ { tag: 'funny', page_id: pageId, chapter_id: chapterId, manga_id: mangaId, page_number: 5, chapter_number: 1, chapter_title: null, manga_title: 'Berserk', storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0005.png`, tagged_at: '2026-01-01T00:00:00Z' } ], page: { limit: 100, offset: 0, total: 1 } }; const chaptersDesc = { items: [ { chapter_id: chapterId, manga_id: mangaId, manga_title: 'Berserk', chapter_number: 1, chapter_title: null, match_count: 12, sample_storage_keys: [ `mangas/${mangaId}/chapters/${chapterId}/pages/0001.png`, `mangas/${mangaId}/chapters/${chapterId}/pages/0002.png`, `mangas/${mangaId}/chapters/${chapterId}/pages/0003.png` ] }, { chapter_id: 'c8888888-8888-8888-8888-888888888888', manga_id: mangaId, manga_title: 'Berserk', chapter_number: 2, chapter_title: null, match_count: 3, sample_storage_keys: [] } ], page: { limit: 100, offset: 0, total: 2 } }; // Same fixture reversed by the mock when `order=asc` is requested, // so the test can assert the order flip end-to-end. const chaptersAsc = { items: [...chaptersDesc.items].reverse(), page: chaptersDesc.page }; const mangasResponse = { items: [ { manga_id: mangaId, manga_title: 'Berserk', manga_cover_image_path: `mangas/${mangaId}/cover.png`, match_count: 28, sample_storage_keys: [ `mangas/${mangaId}/chapters/${chapterId}/pages/0005.png` ] } ], page: { limit: 100, offset: 0, total: 1 } }; const pageSearchResponse = { items: [ { page_id: pageId, chapter_id: chapterId, manga_id: mangaId, page_number: 5, chapter_number: 1, chapter_title: null, manga_title: 'Berserk', storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0005.png`, is_nsfw: true, content_warnings: ['gore'], rank: 0.9 } ], page: { limit: 100, offset: 0, total: 1 } }; async function mockSearch(page: Page) { await page.route('**/api/v1/auth/config', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ self_register_enabled: true, private_mode: false }) }) ); await page.route('**/api/v1/auth/me', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user: userFixture }) }) ); await page.route('**/api/v1/auth/me/preferences', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ reader_mode: 'single', reader_page_gap: 'small' }) }) ); await page.route('**/api/v1/me/bookmarks*', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) }) ); await page.route('**/api/v1/me/page-tags/distinct*', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: distinct }) }) ); await page.route('**/api/v1/me/page-search*', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(pageSearchResponse) }) ); await page.route('**/api/v1/me/page-tags?**', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(pagesResponse) }) ); await page.route('**/api/v1/me/page-tags/chapters*', (route) => { const u = new URL(route.request().url()); const body = u.searchParams.get('order') === 'asc' ? chaptersAsc : chaptersDesc; return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); }); await page.route('**/api/v1/me/page-tags/mangas*', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mangasResponse) }) ); // PNG stub for fileUrl(storage_key) thumbnails. const png = Buffer.from( '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082', 'hex' ); await page.route('**/api/v1/files/**', (route) => route.fulfill({ status: 200, contentType: 'image/png', body: png }) ); } test.describe('/search', () => { test('empty /search renders the chip cloud; clicking a chip sets ?tag=', async ({ page }) => { await mockSearch(page); await page.setViewportSize(DESKTOP); await page.goto('/search'); await expect(page.getByTestId('search-chip-cloud')).toBeVisible(); await expect(page.getByTestId('search-chip-funny')).toBeVisible(); await expect(page.getByTestId('search-chip-fight')).toBeVisible(); await page.getByTestId('search-chip-funny').click(); await expect(page).toHaveURL(/[?&]tag=funny/); await expect(page.getByTestId('search-active-tag')).toContainText('funny'); }); test('Pages tab shows results; clicking a row navigates to reader at ?page=N', async ({ page }) => { await mockSearch(page); await page.setViewportSize(DESKTOP); await page.goto('/search?tag=funny'); await expect(page.getByTestId('search-pages-list')).toBeVisible(); const row = page.getByTestId(`search-page-row-${pageId}`); await expect(row).toBeVisible(); // The breadcrumb link goes to the reader at ?page=5. const breadcrumbLink = row.locator('a.target'); await expect(breadcrumbLink).toHaveAttribute( 'href', `/manga/${mangaId}/chapter/${chapterId}?page=5` ); }); test('Chapters tab calls /chapters endpoint and renders ranked rows', async ({ page }) => { await mockSearch(page); await page.setViewportSize(DESKTOP); await page.goto('/search?tag=funny&view=chapters'); await expect(page.getByTestId('search-chapters-list')).toBeVisible(); // Default desc order — first row = highest match count (12). const rows = page.getByTestId('search-chapters-list').locator('li'); await expect(rows.nth(0)).toContainText('12 pages'); await expect(rows.nth(1)).toContainText('3 pages'); // Chapter row links to the reader at the chapter root. const firstTitle = rows.nth(0).locator('a.title').first(); await expect(firstTitle).toHaveAttribute( 'href', `/manga/${mangaId}/chapter/${chapterId}` ); }); test('Order toggle flips chapter rows', async ({ page }) => { await mockSearch(page); await page.setViewportSize(DESKTOP); await page.goto('/search?tag=funny&view=chapters'); // Click the "Fewest pages" segmented control. await page .getByTestId('search-sort') .getByRole('radio', { name: 'Fewest pages' }) .click(); await expect(page).toHaveURL(/[?&]order=asc/); const rows = page.getByTestId('search-chapters-list').locator('li'); await expect(rows.nth(0)).toContainText('3 pages'); await expect(rows.nth(1)).toContainText('12 pages'); }); test('Mangas tab renders rows linking to manga detail', async ({ page }) => { await mockSearch(page); await page.setViewportSize(DESKTOP); await page.goto('/search?tag=funny&view=mangas'); await expect(page.getByTestId('search-mangas-list')).toBeVisible(); const row = page.getByTestId(`search-manga-row-${mangaId}`); await expect(row).toContainText('Berserk'); await expect(row).toContainText('28 pages'); await expect(row.locator('a.title')).toHaveAttribute( 'href', `/manga/${mangaId}` ); }); test('text search sets ?text= and renders page results', async ({ page }) => { await mockSearch(page); await page.setViewportSize(DESKTOP); await page.goto('/search'); await page.getByTestId('search-text-input').fill('dragon'); await page.getByTestId('search-text-input').press('Enter'); await expect(page).toHaveURL(/text=dragon/); await expect(page.getByTestId('search-content-list')).toBeVisible(); await expect( page.getByTestId(`search-result-${pageId}`) ).toBeVisible(); }); test('content-warning toggle sets ?cw_include= and searches', async ({ page }) => { await mockSearch(page); await page.setViewportSize(DESKTOP); await page.goto('/search'); await page.getByTestId('cw-toggle-gore').click(); await expect(page).toHaveURL(/cw_include=gore/); await expect(page.getByTestId('search-content-list')).toBeVisible(); }); });