Files
Mangalord/frontend/e2e/manga-list.spec.ts
MechaCat02 def97d3087 feat: skeleton loading state on the home catalog
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>
2026-07-06 19:40:28 +02:00

234 lines
9.1 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';
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 150 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 5175 of 75');
await expect(page.getByTestId('manga-list')).toContainText('Manga 75');
await expect(page.getByTestId('manga-list')).not.toContainText('Manga 1');
});