Files
Mangalord/frontend/e2e/reader-page-deep-link.spec.ts
MechaCat02 ded77fe4ba test(e2e): kill cold-start flakiness with an /api/v1 fallback + one retry
A full parallel run intermittently failed the first few tests: unmocked
/api/v1 calls fell through to the dev proxy, whose backend isn't running
under E2E, so each incurred a slow ECONNREFUSED round-trip + Vite error
logging that piled up across 8 workers at cold start.

- Add e2e/fixtures.ts: a shared `test` whose auto-use fixture installs an
  `**/api/v1/**` fallback (fast 503, logged) for any endpoint a spec
  didn't mock. Registered before the test body, so per-test routes still
  win; only genuine gaps land here. Turns silent mock-gap hangs into
  instant, attributable failures — and cuts the full run ~96s → ~25s.
- Point all 22 specs at ./fixtures instead of @playwright/test.
- retries: 1 in the config as a safety net for the residual Vite
  cold-compile timing (re-runs on the now-warm server).

Two consecutive full runs: 110 passed, 0 flaky, no retries consumed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:06:20 +02:00

251 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
// Pages 1..=4 (1-indexed in the testid, 0-indexed in the
// template; initialIndex = 3 means we eager-load 0..=3, i.e.
// testids 1..4). Without this guard, page 2+ would be lazy
// and their 0×0 placeholders would let the scroll target
// appear far above its final position.
for (const n of [1, 2, 3, 4]) {
await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute(
'loading',
'eager'
);
}
// Pages beyond the target stay lazy.
for (const n of [5, 6]) {
await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute(
'loading',
'lazy'
);
}
});
test('continuous mode: no ?page= eager-loads only the first two', async ({
page
}) => {
await mockReader(page, 'continuous');
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
await expect(page.getByTestId('reader-continuous')).toBeVisible();
await expect(page.getByTestId('reader-page-1')).toHaveAttribute(
'loading',
'eager'
);
await expect(page.getByTestId('reader-page-2')).toHaveAttribute(
'loading',
'eager'
);
await expect(page.getByTestId('reader-page-3')).toHaveAttribute(
'loading',
'lazy'
);
});
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'
);
});
});