The route-mocked E2E specs had drifted from the app they exercise, so
each rendered an empty/500 page and timed out on missing elements:
- Manga detail + reader loads now also fetch read-progress/{id} and
/similar; several specs never mocked them, so the load's Promise.all
rejected. Add the mocks and fill the manga fixtures out to a real
MangaDetail (status/alt_titles/authors/genres/tags/... ) so the
components stop throwing mid-render.
- back-nav + library: widen `read-progress*`/`page-tags` globs — `*`
doesn't cross `/`, so `/me/read-progress/{id}` fell through to the
proxy and 500'd; mock the library page-tags pair too.
- manga-list search: wait for the initial load to settle before
searching so the search fetch can't race the mount-time load.
- reader-chapter-select: assert the shared `chapterLabel` output.
- admin-analysis: the active OCR backend extracts text only, so the
detail modal shows OCR (no tags/NSFW) and metrics shows tiles +
trend charts (no by-model) — assert what the OCR-era UI renders.
- profile: assert the wrong-password 401 stays inline (guards the
fix in the preceding commit) and mock the guest bookmark/collection
fetches the /profile load maps to the sign-in prompt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
189 lines
7.5 KiB
TypeScript
189 lines
7.5 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
||
import { SORT_FIELD_LABELS } from '../src/lib/mangaSort';
|
||
|
||
// These E2E tests run against the SvelteKit dev server, which proxies /api
|
||
// to the backend. Playwright starts vite via `webServer` (see
|
||
// playwright.config.ts) unless E2E_BASE_URL points at a different deployment.
|
||
//
|
||
// Routes are mocked at the page level so the journeys are deterministic and
|
||
// don't require a live backend.
|
||
|
||
const emptyPage = { items: [], page: { limit: 50, offset: 0, total: null } };
|
||
|
||
async function mockAnonymous(page: Page) {
|
||
// Force public mode so the root +layout.ts doesn't bounce us to /login
|
||
// (a dev backend with PRIVATE_MODE=true must not leak into E2E runs).
|
||
await page.route('**/api/v1/auth/config', async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||
});
|
||
});
|
||
await page.route('**/api/v1/auth/me', async (route) => {
|
||
await route.fulfill({
|
||
status: 401,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } })
|
||
});
|
||
});
|
||
await page.route('**/api/v1/auth/me/preferences', async (route) => {
|
||
await route.fulfill({
|
||
status: 401,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } })
|
||
});
|
||
});
|
||
// The home page fetches genres on mount (filter UI). Mocking it keeps
|
||
// `onMount` fast and deterministic — otherwise the unmocked call stalls
|
||
// against the dead dev proxy, and the late-resolving initial load races
|
||
// (and clobbers) a load kicked off by an early search interaction.
|
||
await page.route('**/api/v1/genres*', async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify([])
|
||
});
|
||
});
|
||
}
|
||
|
||
test('home page renders the Mangalord heading and search input', async ({ page }) => {
|
||
await mockAnonymous(page);
|
||
await page.route('**/api/v1/mangas*', async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify(emptyPage)
|
||
});
|
||
});
|
||
|
||
await page.goto('/');
|
||
await expect(page.getByRole('link', { name: 'Mangalord' })).toBeVisible();
|
||
await expect(page.getByTestId('search-input')).toBeVisible();
|
||
await expect(page.getByTestId('empty')).toContainText('No mangas yet');
|
||
});
|
||
|
||
// Anti-drift guard: the desktop sort <select> and the mobile sort sheet must
|
||
// both enumerate exactly SORT_FIELD_LABELS, in the same order. The desktop
|
||
// select used to hard-code its <option>s, so a label rename in mangaSort.ts
|
||
// would silently diverge the two surfaces. Comparing the rendered options to
|
||
// the single source of truth makes that divergence fail loudly.
|
||
test('desktop sort options stay in sync with SORT_FIELD_LABELS', async ({ page }) => {
|
||
await mockAnonymous(page);
|
||
await page.route('**/api/v1/mangas*', async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify(emptyPage)
|
||
});
|
||
});
|
||
|
||
await page.goto('/');
|
||
const select = page.getByTestId('sort-select');
|
||
await expect(select).toBeVisible();
|
||
|
||
const options = select.locator('option');
|
||
const labels = (await options.allTextContents()).map((s) => s.trim());
|
||
const values = await options.evaluateAll((opts) =>
|
||
opts.map((o) => (o as HTMLOptionElement).value)
|
||
);
|
||
|
||
expect(values).toEqual(Object.keys(SORT_FIELD_LABELS));
|
||
expect(labels).toEqual(Object.values(SORT_FIELD_LABELS));
|
||
});
|
||
|
||
test('search updates the manga list', async ({ page }) => {
|
||
await mockAnonymous(page);
|
||
let lastSearch: string | null = null;
|
||
await page.route('**/api/v1/mangas*', async (route) => {
|
||
const url = new URL(route.request().url());
|
||
lastSearch = url.searchParams.get('search');
|
||
const items =
|
||
lastSearch === 'berserk'
|
||
? [
|
||
{
|
||
id: 'b1',
|
||
title: 'Berserk',
|
||
status: 'ongoing',
|
||
alt_titles: [],
|
||
description: null,
|
||
cover_image_path: null,
|
||
created_at: '2026-01-01T00:00:00Z',
|
||
updated_at: '2026-01-01T00:00:00Z',
|
||
// MangaCard = Manga & { authors, genres }; the card
|
||
// component maps over both, so they must be present.
|
||
authors: [{ id: 'a1', name: 'Kentaro Miura' }],
|
||
genres: []
|
||
}
|
||
]
|
||
: [];
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({ items, page: { limit: 50, offset: 0, total: null } })
|
||
});
|
||
});
|
||
|
||
await page.goto('/');
|
||
// Wait for the initial (empty) catalogue to settle before searching, so
|
||
// the search's fetch can't race the mount-time load — mirrors how the
|
||
// pagination test waits for the first render before interacting.
|
||
await expect(page.getByTestId('empty')).toBeVisible();
|
||
|
||
await page.getByTestId('search-input').fill('berserk');
|
||
await page.getByRole('button', { name: 'Search' }).click();
|
||
|
||
await expect(page.getByTestId('manga-list')).toContainText('Berserk');
|
||
expect(lastSearch).toBe('berserk');
|
||
});
|
||
|
||
test('clicking Next paginates to page 2 and updates the URL', async ({ page }) => {
|
||
await mockAnonymous(page);
|
||
|
||
// Fake a catalogue of 75 mangas; page 1 is ids 1..50, page 2 is ids 51..75.
|
||
const TOTAL = 75;
|
||
function mangaAt(i: number) {
|
||
return {
|
||
id: `m${i}`,
|
||
title: `Manga ${i}`,
|
||
author: 'Test',
|
||
description: null,
|
||
cover_image_path: null,
|
||
created_at: '2026-01-01T00:00:00Z',
|
||
updated_at: '2026-01-01T00:00:00Z',
|
||
authors: [],
|
||
genres: []
|
||
};
|
||
}
|
||
|
||
await page.route('**/api/v1/mangas*', async (route) => {
|
||
const url = new URL(route.request().url());
|
||
const limit = Number(url.searchParams.get('limit') ?? '50');
|
||
const offset = Number(url.searchParams.get('offset') ?? '0');
|
||
const items: ReturnType<typeof mangaAt>[] = [];
|
||
for (let i = offset + 1; i <= Math.min(offset + limit, TOTAL); i++) {
|
||
items.push(mangaAt(i));
|
||
}
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({
|
||
items,
|
||
page: { limit, offset, total: TOTAL }
|
||
})
|
||
});
|
||
});
|
||
|
||
await page.goto('/');
|
||
await expect(page.getByTestId('manga-total')).toContainText('Showing 1–50 of 75');
|
||
await expect(page.getByTestId('manga-list')).toContainText('Manga 1');
|
||
await expect(page.getByTestId('manga-list')).not.toContainText('Manga 75');
|
||
|
||
await page.getByTestId('manga-pager').getByRole('button', { name: /next/i }).click();
|
||
|
||
await expect(page).toHaveURL(/[?&]page=2(&|$)/);
|
||
await expect(page.getByTestId('manga-total')).toContainText('Showing 51–75 of 75');
|
||
await expect(page.getByTestId('manga-list')).toContainText('Manga 75');
|
||
await expect(page.getByTestId('manga-list')).not.toContainText('Manga 1');
|
||
});
|