import { test, expect, type Page } from './fixtures'; // Regression spec for the reader-back-loop bug: previously the reader's // back arrow was a plain ``, which PUSHED a new // history entry every tap, so browser-back kept ping-ponging between // detail and reader instead of walking out to home. The reader now // intercepts left-click and does `history.back()` when there's // same-origin history to pop. const MID = 'a1111111-1111-1111-1111-111111111111'; const CID = 'c1111111-1111-1111-1111-111111111111'; const mangaBody = { id: MID, title: 'Berserk', status: 'ongoing', alt_titles: [], description: 'Short.', cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', authors: [], genres: [], tags: [] }; async function mockApis(page: Page) { await page.route('**/api/v1/auth/config', (r) => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ self_register_enabled: true, private_mode: false }) }) ); await page.route('**/api/v1/auth/me', (r) => r.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: { code: 'x', message: 'x' } }) }) ); await page.route('**/api/v1/auth/me/preferences', (r) => r.fulfill({ status: 401, contentType: 'application/json', body: '{}' }) ); await page.route('**/api/v1/me/bookmarks*', (r) => r.fulfill({ status: 401, contentType: 'application/json', body: '{}' }) ); // `**` (not `*`) so this also matches the per-manga path // `/me/read-progress/{id}` — Playwright's `*` stops at `/`, so a // single-star glob would let that request fall through to the (dead) // backend proxy and 500 the reader/detail load. await page.route('**/api/v1/me/read-progress**', (r) => r.fulfill({ status: 404, contentType: 'application/json', body: '{}' }) ); await page.route('**/api/v1/genres*', (r) => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) ); // Catalog returns a single card whose href points at MID so the // SPA-click walk lands on the manga we've mocked downstream. // `**/api/v1/mangas` alone doesn't intercept query strings // (`?limit=&sort=`), so the catalog request would fall through // to a real backend if one is running on :8080 and seed the // page with real-manga IDs that don't match the mock body. // `**/api/v1/mangas?**` (and the same suffix on the catch-all // below) makes the intercept query-tolerant. await page.route('**/api/v1/mangas?**', (r) => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [mangaBody], page: { limit: 50, offset: 0, total: 1 } }) }) ); await page.route('**/api/v1/mangas/**', (r) => { const u = new URL(r.request().url()); const last = u.pathname.split('/').pop(); if (last === CID) return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: CID, manga_id: MID, number: 1, title: 'Ch.1', page_count: 1, created_at: '2026-01-01T00:00:00Z' }) }); if (last === 'pages') return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ pages: [ { id: 'p', chapter_id: CID, page_number: 1, storage_key: 'x.png', content_type: 'image/png' } ] }) }); if (u.pathname.endsWith('/chapters')) return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [ { id: CID, manga_id: MID, number: 1, title: 'Ch.1', page_count: 1, created_at: '2026-01-01T00:00:00Z' } ], page: { limit: 50, offset: 0, total: 1 } }) }); return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mangaBody) }); }); const png = Buffer.from( '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082', 'hex' ); await page.route('**/api/v1/files/**', (r) => r.fulfill({ status: 200, contentType: 'image/png', body: png }) ); } test.describe('back-nav flow', () => { test('phone viewport: reader back pops history (does not push), then detail back returns to home', async ({ page }) => { await mockApis(page); await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/'); const initialLen = await page.evaluate(() => window.history.length); // Walk: home → detail → reader USING SPA NAVIGATION (link clicks, // not window.location.href). The bug only manifests on SPA nav // because `document.referrer` stays empty across pushState — so // a hard navigation would silently mask the regression. await page.locator('[data-testid="manga-list"] a').first().click(); await page.waitForURL(/\/manga\/[^/]+$/); await page.locator('[data-testid="chapter-list"] a').first().click(); await page.waitForURL(/\/chapter\//); const lenAtReader = await page.evaluate(() => window.history.length); expect(lenAtReader).toBe(initialLen + 2); // Tap reader back → goes back to detail WITHOUT pushing a new entry. await page.getByTestId('back-to-manga').click(); await page.waitForURL(/\/manga\/[^/]+$/); const lenAtDetail = await page.evaluate(() => window.history.length); expect(lenAtDetail).toBe(lenAtReader); // Tap detail back → walks out to home. await page.getByTestId('detail-back').click(); await page.waitForURL((url) => url.pathname === '/'); }); test('reader cover+title pushes detail when arrived from a non-detail page', async ({ page }) => { // The smart-cover-title fix: if the user got to the reader from // a page OTHER than this manga's detail (search, library, // direct link, etc.), the cover+title should push the detail // page so browser-back returns to the reader. The arrow stays // a pure "go back". // // We simulate "non-detail arrival" with a deep-link load // straight to the reader. That covers cold-tab + shared-link + // search-result cases — afterNavigate fires with from=null and // lastInternalPath stays null, so the cover+title click sees // a mismatch and pushes the detail page. await mockApis(page); await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`/manga/${MID}/chapter/${CID}`); await page.waitForSelector('[data-testid="back-to-manga"]'); const lenAtReader = await page.evaluate(() => window.history.length); await page.getByTestId('back-to-manga').click(); await page.waitForURL(/\/manga\/[^/]+$/); const lenAfter = await page.evaluate(() => window.history.length); // PUSH (not pop) — lenAtReader + 1. expect(lenAfter).toBe(lenAtReader + 1); // Browser-back from detail returns to reader (proves it was a // push, not a replace). await page.goBack(); await page.waitForURL(/\/chapter\//); }); test('reader arrow always pops history', async ({ page }) => { await mockApis(page); await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/'); await page.locator('[data-testid="manga-list"] a').first().click(); await page.waitForURL(/\/manga\/[^/]+$/); await page.locator('[data-testid="chapter-list"] a').first().click(); await page.waitForURL(/\/chapter\//); const lenAtReader = await page.evaluate(() => window.history.length); // Click the arrow — always pops, regardless of previous page. await page.getByTestId('reader-back-arrow').click(); await page.waitForURL(/\/manga\/[^/]+$/); const lenAfter = await page.evaluate(() => window.history.length); expect(lenAfter).toBe(lenAtReader); }); });