import { test, expect, type Page } from './fixtures'; // E2E for the per-page collection + tag flow added in v0.61.0. Mocks // the entire API so the spec runs without a backend. The same // fixtures + mockReader scaffolding as `mobile-reader.spec.ts`; // kept inline so the file stays self-contained. const MOBILE = { width: 390, height: 844 } as const; const DESKTOP = { width: 1280, height: 720 } as const; const mangaId = 'a9999999-9999-9999-9999-999999999999'; const chapterId = 'c9999999-9999-9999-9999-999999999999'; const pageId = 'p11111111-1111-1111-1111-111111111111'; const collectionId = 'cc111111-1111-1111-1111-111111111111'; const mangaFixture = { id: mangaId, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null, cover_image_path: `mangas/${mangaId}/cover.png`, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', authors: [{ id: 'au1', name: 'Kentaro Miura' }], genres: [], tags: [] }; const chapterFixture = { id: chapterId, manga_id: mangaId, number: 1, title: 'The Brand', page_count: 1, created_at: '2026-01-01T00:00:00Z' }; const pagesFixture = [ { id: pageId, chapter_id: chapterId, page_number: 1, storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0001.png`, content_type: 'image/png' } ]; const userFixture = { id: 'u11111111-1111-1111-1111-111111111111', username: 'tester', created_at: '2026-01-01T00:00:00Z', is_admin: false }; const collectionFixture = { id: collectionId, user_id: userFixture.id, name: 'Favorite panels', description: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', manga_count: 0, sample_covers: [] }; /** * Wire the full mock surface for the reader plus the new * `pages/:id/my-collections`, `pages/:id/my-tags`, `me/collections`, * and `collections/:id/pages` endpoints. `myCollectionsState` lets a * test mutate what /my-collections returns mid-flow so the "re-open * menu after add → In 1 collection" assertion is exercised. */ async function mockReader( page: Page, state: { collectionsContainingPage: string[]; tagsOnPage: string[]; admin?: boolean; analyzeCalls?: number; } ) { 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, is_admin: state.admin ?? false } }) }) ); // Admin force-analyze endpoint — counts invocations for assertions. await page.route(`**/api/v1/admin/pages/${pageId}/analyze`, (route) => { state.analyzeCalls = (state.analyzeCalls ?? 0) + 1; return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ enqueued: true }) }); }); 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/mangas/${mangaId}`, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mangaFixture) }) ); await page.route(`**/api/v1/mangas/${mangaId}/chapters`, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [chapterFixture], page: { limit: 50, offset: 0, total: 1 } }) }) ); await page.route(`**/api/v1/mangas/${mangaId}/chapters\\?*`, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [chapterFixture], page: { limit: 50, offset: 0, total: 1 } }) }) ); await page.route(`**/api/v1/mangas/${mangaId}/chapters/${chapterId}`, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(chapterFixture) }) ); await page.route( `**/api/v1/mangas/${mangaId}/chapters/${chapterId}/pages`, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ pages: pagesFixture }) }) ); await page.route(`**/api/v1/me/read-progress/${mangaId}`, (route) => route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: { code: 'not_found', message: 'not found' } }) }) ); // The new endpoints — these are what the context menu hits. await page.route(`**/api/v1/pages/${pageId}/my-collections`, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ collection_ids: state.collectionsContainingPage }) }) ); await page.route(`**/api/v1/pages/${pageId}/my-tags`, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tags: state.tagsOnPage }) }) ); await page.route('**/api/v1/me/collections*', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [collectionFixture], page: { limit: 200, offset: 0, total: 1 } }) }) ); await page.route( `**/api/v1/collections/${collectionId}/pages`, async (route) => { if (route.request().method() === 'POST') { state.collectionsContainingPage = [collectionId]; return route.fulfill({ status: 201, body: '' }); } return route.continue(); } ); // A landscape page image (wide aspect) so that, under the width-driven // reader sizing, the rendered page is SHORTER than the viewport. A tall // page would push its centre off-screen, and Playwright's `.click()` // would scroll it into view first — that synthetic scroll trips the // context menu's (by-design) close-on-scroll and flakes these tests. // Real users right-click at the visible cursor with no such scroll. const image = ``; await page.route('**/api/v1/files/**', (route) => route.fulfill({ status: 200, contentType: 'image/svg+xml', body: image }) ); } test.describe('page context menu (desktop right-click)', () => { test('right-click opens the menu with empty-state hints', async ({ page }) => { const state = { collectionsContainingPage: [], tagsOnPage: [] }; await mockReader(page, state); await page.setViewportSize(DESKTOP); await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); await expect(page.getByTestId('reader-page')).toBeVisible(); await expect(page.getByTestId('page-context-menu')).toBeHidden(); await page.getByTestId('reader-page').click({ button: 'right' }); const menu = page.getByTestId('page-context-menu'); await expect(menu).toBeVisible(); await expect( page.getByTestId('page-context-collections-line') ).toHaveText('Not in any collection'); await expect(page.getByTestId('page-context-tags-line')).toHaveText( 'No tags yet' ); }); test('Escape closes the menu', async ({ page }) => { await mockReader(page, { collectionsContainingPage: [], tagsOnPage: [] }); await page.setViewportSize(DESKTOP); await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); await page.getByTestId('reader-page').click({ button: 'right' }); await expect(page.getByTestId('page-context-menu')).toBeVisible(); await page.keyboard.press('Escape'); await expect(page.getByTestId('page-context-menu')).toBeHidden(); }); test('add-to-collection → modal → toggle → re-open menu shows "In 1 collection"', async ({ page }) => { const state = { collectionsContainingPage: [], tagsOnPage: [] }; await mockReader(page, state); await page.setViewportSize(DESKTOP); await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); await page.getByTestId('reader-page').click({ button: 'right' }); await page.getByTestId('page-context-add-to-collection').click(); // Modal opens with the user's one collection unchecked. const modal = page.getByTestId('add-to-collection-modal'); await expect(modal).toBeVisible(); const checkbox = modal.getByTestId(`collection-toggle-${collectionId}`); await expect(checkbox).not.toBeChecked(); await checkbox.check(); // The mock flips containing-page state on POST. Close + re-open // the menu to verify the contextual line reflects the new // server state. await page.keyboard.press('Escape'); await expect(modal).toBeHidden(); await page.getByTestId('reader-page').click({ button: 'right' }); await expect(page.getByTestId('page-context-collections-line')).toHaveText( 'In 1 collection' ); }); test('non-admin does not see the Queue for analysis action', async ({ page }) => { await mockReader(page, { collectionsContainingPage: [], tagsOnPage: [] }); await page.setViewportSize(DESKTOP); await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); await page.getByTestId('reader-page').click({ button: 'right' }); await expect(page.getByTestId('page-context-menu')).toBeVisible(); await expect(page.getByTestId('page-context-analyze')).toBeHidden(); }); test('admin can queue the page for analysis from the menu', async ({ page }) => { const state = { collectionsContainingPage: [], tagsOnPage: [], admin: true, analyzeCalls: 0 }; await mockReader(page, state); await page.setViewportSize(DESKTOP); await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); await page.getByTestId('reader-page').click({ button: 'right' }); const item = page.getByTestId('page-context-analyze'); await expect(item).toBeVisible(); await expect(item).toContainText('Queue for analysis'); await item.click(); await expect(item).toContainText('Queued for analysis'); expect(state.analyzeCalls).toBe(1); }); test('Shift+right-click falls through to the browser native menu', async ({ page }) => { await mockReader(page, { collectionsContainingPage: [], tagsOnPage: [] }); await page.setViewportSize(DESKTOP); await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); // Hold Shift while right-clicking. The reader's // `oncontextmenu` returns early on shiftKey, so the in-app // menu must NOT open. (Playwright doesn't surface the // native browser menu, but we can assert ours stays hidden.) await page.keyboard.down('Shift'); await page.getByTestId('reader-page').click({ button: 'right' }); await page.keyboard.up('Shift'); await expect(page.getByTestId('page-context-menu')).toBeHidden(); }); }); test.describe('page action sheet (mobile long-press)', () => { test('long-press on a page image opens the action sheet', async ({ page }) => { await mockReader(page, { collectionsContainingPage: [], tagsOnPage: [] }); await page.setViewportSize(MOBILE); await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); // Switch to continuous mode so the per-image long-press // handler is wired (single mode goes through TapZone). Both // codepaths funnel into the same Sheet, but the per-image // wiring is the riskier of the two — it's the one the audit // flagged for the multitouch fix. await page.getByTestId('reader-settings-btn').click(); await page .getByTestId('reader-settings-sheet') .getByRole('radio', { name: 'Continuous' }) .click(); // Close the settings sheet so its scrim isn't blocking. await page.keyboard.press('Escape'); const pageEl = page.getByTestId('reader-page-1'); await expect(pageEl).toBeVisible(); // Synthesize a touch pointerdown, wait past the 450ms timer. const box = await pageEl.boundingBox(); if (!box) throw new Error('page image has no bounding box'); const cx = box.x + box.width / 2; const cy = box.y + box.height / 2; await pageEl.dispatchEvent('pointerdown', { pointerType: 'touch', clientX: cx, clientY: cy, bubbles: true }); await page.waitForTimeout(550); await expect(page.getByTestId('page-action-sheet')).toBeVisible(); await expect(page.getByTestId('page-action-add-to-collection')).toBeVisible(); await expect(page.getByTestId('page-action-add-tag')).toBeVisible(); await expect(page.getByTestId('page-action-save-image')).toBeVisible(); await expect(page.getByTestId('page-action-copy-link')).toBeVisible(); // The editor itself opens as a bottom Sheet on mobile (not a centered // Modal), so the action-sheet → editor flow stays one consistent // idiom. Sheet renders a `${testid}-scrim`; Modal a `${testid}-backdrop`. await page.getByTestId('page-action-add-tag').click(); await expect(page.getByTestId('add-tags-modal-scrim')).toBeVisible(); await expect(page.getByTestId('add-tags-modal-backdrop')).toHaveCount(0); }); });