import { test, expect, type Page } from './fixtures'; // Regression coverage for the reader page-sizing fix: pages must render at a // consistent width regardless of their intrinsic dimensions, and a long // (tall) page must extend downward and scroll instead of collapsing into a // thin vertical stripe. // // The failure this guards against: sizing used to be height-driven // (`max-height: 90vh` in single mode; unbounded natural width in continuous // mode), so a tall webtoon strip shrank to a sliver and a narrow scan and a // wide scan rendered at different widths. const MANGA_ID = 'm1'; const CHAPTER_ID = 'c1'; // A capped reading column of ~700px on desktop; both modes should honour it. const READING_WIDTH = 700; const manga = { id: MANGA_ID, title: 'Sizing Test', status: 'ongoing', alt_titles: [], description: null, cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0 }; const chapter = { id: CHAPTER_ID, manga_id: MANGA_ID, number: 1, title: null, page_count: 2, created_at: '2026-01-01T00:00:00Z', size_bytes: 0 }; // Page 1 is wide (1400×900), page 2 is a tall webtoon strip (800×4000). const pages = [ { id: 'p1', chapter_id: CHAPTER_ID, page_number: 1, storage_key: 'p/wide', content_type: 'image/svg+xml' }, { id: 'p2', chapter_id: CHAPTER_ID, page_number: 2, storage_key: 'p/tall', content_type: 'image/svg+xml' } ]; // An SVG with an explicit width/height gives the a real intrinsic // size, so the rendered aspect ratio matches a genuine page image. function svg(w: number, h: number): string { return ``; } async function mockReader(page: Page) { // Single dispatcher over the whole API surface — takes precedence over // the fixture's 503 fallback and avoids route-glob precedence puzzles. await page.route('**/api/v1/**', async (route) => { const { pathname } = new URL(route.request().url()); const json = (status: number, body: unknown) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); // Images. if (pathname.includes('/files/p/wide')) { return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg(1400, 900) }); } if (pathname.includes('/files/p/tall')) { return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg(800, 4000) }); } // Auth: public, anonymous. if (pathname.endsWith('/auth/config')) { return json(200, { self_register_enabled: true, private_mode: false }); } if (pathname.endsWith('/auth/me')) return json(401, { error: { code: 'unauthenticated', message: 'no' } }); if (pathname.endsWith('/auth/me/preferences')) { return json(401, { error: { code: 'unauthenticated', message: 'no' } }); } // Reader data. if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}/pages`)) { return json(200, { pages }); } if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}`)) return json(200, chapter); if (pathname.includes(`/mangas/${MANGA_ID}/chapters`)) { return json(200, { items: [chapter], page: { limit: 200, offset: 0, total: 1 } }); } if (pathname.endsWith(`/mangas/${MANGA_ID}`)) return json(200, manga); if (pathname.includes(`/me/read-progress/${MANGA_ID}`)) { return json(401, { error: { code: 'unauthenticated', message: 'no' } }); } console.warn(`[e2e] reader spec unmocked: ${pathname}`); return json(503, { error: { code: 'e2e_unmocked', message: pathname } }); }); } test('continuous mode renders all pages at a consistent capped width; tall pages scroll', async ({ page }) => { await page.addInitScript(() => localStorage.setItem('mangalord-reader-mode', 'continuous')); await mockReader(page); await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`); const wide = page.getByTestId('reader-page-1'); const tall = page.getByTestId('reader-page-2'); await expect(wide).toBeVisible(); await expect(tall).toBeVisible(); const wideBox = await wide.boundingBox(); const tallBox = await tall.boundingBox(); const viewport = page.viewportSize(); if (!wideBox || !tallBox || !viewport) throw new Error('missing layout metrics'); // 1. Consistent width regardless of intrinsic dimensions. expect(Math.abs(wideBox.width - tallBox.width)).toBeLessThanOrEqual(1); // 2. Capped reading column on desktop (not full-bleed, not a stripe). expect(wideBox.width).toBeGreaterThan(READING_WIDTH - 40); expect(wideBox.width).toBeLessThanOrEqual(READING_WIDTH + 2); // 3. The tall page extends past the viewport (scrolls, doesn't shrink). expect(tallBox.height).toBeGreaterThan(viewport.height); }); test('single mode renders a tall page at the capped width, not a thin stripe', async ({ page }) => { await mockReader(page); // default mode is single await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`); const img = page.getByTestId('reader-page'); await expect(img).toBeVisible(); // Advance from the wide first page to the tall second page. await page.getByTestId('reader-next').click(); await expect(img).toHaveAttribute('src', /tall/); const box = await img.boundingBox(); const viewport = page.viewportSize(); if (!box || !viewport) throw new Error('missing layout metrics'); // Width is the capped reading column, not a sliver. expect(box.width).toBeGreaterThan(READING_WIDTH - 40); expect(box.width).toBeLessThanOrEqual(READING_WIDTH + 2); // And the page runs past the viewport so the reader scrolls it. expect(box.height).toBeGreaterThan(viewport.height); });