Replaces the plain "Loading…" text with a content-shaped MangaGridSkeleton so the catalog grid's layout is reserved while it loads and swaps in without a shift. Keeps the data-testid="loading" wrapper (now role="status") for accessibility and selector stability. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
234 lines
9.1 KiB
TypeScript
234 lines
9.1 KiB
TypeScript
import { test, expect, type Page } from './fixtures';
|
||
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');
|
||
});
|
||
|
||
test('shows a skeleton grid while the catalog is loading, then the real grid', async ({ page }) => {
|
||
await mockAnonymous(page);
|
||
|
||
// Hold the catalog fetch open so the loading state is observable.
|
||
let release: () => void = () => {};
|
||
const gate = new Promise<void>((resolve) => {
|
||
release = resolve;
|
||
});
|
||
await page.route('**/api/v1/mangas*', async (route) => {
|
||
await gate;
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({
|
||
items: [
|
||
{
|
||
id: 'm1',
|
||
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',
|
||
authors: [],
|
||
genres: []
|
||
}
|
||
],
|
||
page: { limit: 50, offset: 0, total: 1 }
|
||
})
|
||
});
|
||
});
|
||
|
||
await page.goto('/');
|
||
|
||
// Skeleton stands in for the grid while the fetch is pending.
|
||
await expect(page.getByTestId('manga-grid-skeleton')).toBeVisible();
|
||
await expect(page.getByTestId('manga-list')).toHaveCount(0);
|
||
|
||
// Once data lands, the real grid replaces the skeleton.
|
||
release();
|
||
await expect(page.getByTestId('manga-list')).toContainText('Berserk');
|
||
await expect(page.getByTestId('manga-grid-skeleton')).toHaveCount(0);
|
||
});
|
||
|
||
// 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');
|
||
});
|