Files
Mangalord/frontend/e2e/manga-list.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

189 lines
7.5 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');
});
// 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');
});