import { test, expect, type Page } from './fixtures'; // E2E for the `?page=N` deep-link path in both reader modes. The // single-mode case has been working since v0.x; the continuous-mode // scroll-to-page landed in v0.62.0 alongside the /search Pages tab. const mangaId = 'a9999999-9999-9999-9999-999999999999'; const chapterId = 'c9999999-9999-9999-9999-999999999999'; const chapterBId = 'c8888888-8888-8888-8888-888888888888'; const sixPages = Array.from({ length: 6 }, (_, i) => ({ id: `p${i + 1}1111111-1111-1111-1111-111111111111`, chapter_id: chapterId, page_number: i + 1, storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/000${i + 1}.png`, content_type: 'image/png' })); const fourPages = Array.from({ length: 4 }, (_, i) => ({ id: `p${i + 1}2222222-2222-2222-2222-222222222222`, chapter_id: chapterBId, page_number: i + 1, storage_key: `mangas/${mangaId}/chapters/${chapterBId}/pages/000${i + 1}.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 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: sixPages.length, created_at: '2026-01-01T00:00:00Z' }; const chapterBFixture = { id: chapterBId, manga_id: mangaId, number: 2, title: 'Guardians of Desire', page_count: fourPages.length, created_at: '2026-01-02T00:00:00Z' }; async function mockReader(page: Page, mode: 'single' | 'continuous') { 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 }) }) ); // Critical for this spec: the server-stored preference seeds // `preferences.readerMode` at hydration time. The new continuous- // mode scroll-to-page effect re-fires when `mode` flips from // its initial 'single' default to the hydrated value. await page.route('**/api/v1/auth/me/preferences', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ reader_mode: mode, 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, chapterBFixture], page: { limit: 50, offset: 0, total: 2 } }) }) ); 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/${chapterBId}`, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(chapterBFixture) }) ); await page.route( `**/api/v1/mangas/${mangaId}/chapters/${chapterId}/pages`, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ pages: sixPages }) }) ); await page.route( `**/api/v1/mangas/${mangaId}/chapters/${chapterBId}/pages`, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ pages: fourPages }) }) ); 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' } }) }) ); const png = Buffer.from( '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082', 'hex' ); await page.route('**/api/v1/files/**', (route) => route.fulfill({ status: 200, contentType: 'image/png', body: png }) ); } test.describe('reader ?page=N deep link', () => { test('continuous mode: pages 1..N are eager-loaded so the scroll target has settled height', async ({ page }) => { await mockReader(page, 'continuous'); await page.goto(`/manga/${mangaId}/chapter/${chapterId}?page=4`); await expect(page.getByTestId('reader-continuous')).toBeVisible(); // The whole chapter is eager, so the scroll target (page 4) and // every page before it have settled heights before the // scroll-to-`?page=N` effect runs — the target can't be pushed // past the viewport by later pages loading in. for (const n of [1, 2, 3, 4, 5, 6]) { await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute( 'loading', 'eager' ); } }); test('continuous mode: eager-loads the whole chapter up front', async ({ page }) => { await mockReader(page, 'continuous'); await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); await expect(page.getByTestId('reader-continuous')).toBeVisible(); // Every page is eager — the whole current chapter is preloaded so // pages don't pop in / reflow as the reader scrolls onto them. for (const n of [1, 2, 3, 4, 5, 6]) { await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute( 'loading', 'eager' ); } }); test('single mode: ?page=N opens at the requested page', async ({ page }) => { await mockReader(page, 'single'); await page.goto(`/manga/${mangaId}/chapter/${chapterId}?page=5`); await expect(page.getByTestId('reader-page')).toBeVisible(); await expect(page.getByTestId('page-indicator')).toHaveText('Page 5 / 6'); }); test('in-reader chapter selector resets state for the new chapter', async ({ page }) => { // Deep-link into chapter A at page 5. SvelteKit reuses the // component when we navigate to chapter B via the chapter // selector, so `index` (and the read-progress sentinel) // have to be reset by the page's chapter-change effect. // Without it, page-indicator would read "Page 5 / 4" (old // index, new pages.length) and the next progress flush would // poison chapter B's stored read-progress with chapter A's // high-water mark. await mockReader(page, 'single'); await page.goto(`/manga/${mangaId}/chapter/${chapterId}?page=5`); await expect(page.getByTestId('page-indicator')).toHaveText( 'Page 5 / 6' ); await page .getByTestId('reader-chapter-select') .selectOption(chapterBId); await expect(page).toHaveURL( new RegExp(`/manga/${mangaId}/chapter/${chapterBId}$`) ); await expect(page.getByTestId('page-indicator')).toHaveText( 'Page 1 / 4' ); }); });