The desktop sort <select> hard-coded its four <option>s while the mobile sort sheet rendered them from SORT_FIELD_LABELS. The two happened to match, but a label rename in mangaSort.ts would have silently diverged the desktop control. Render the desktop options from the same map so they can't. No behaviour change (the rendered options are identical), so no version bump. Adds an e2e guard asserting the desktop select's option values and labels equal SORT_FIELD_LABELS in order — verified red against the old hard-coded markup (with a label renamed) and green after. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
161 lines
6.2 KiB
TypeScript
161 lines
6.2 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' } })
|
||
});
|
||
});
|
||
}
|
||
|
||
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',
|
||
author: 'Kentaro Miura',
|
||
description: null,
|
||
cover_image_path: null,
|
||
created_at: '2026-01-01T00:00:00Z',
|
||
updated_at: '2026-01-01T00:00:00Z'
|
||
}
|
||
]
|
||
: [];
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({ items, page: { limit: 50, offset: 0, total: null } })
|
||
});
|
||
});
|
||
|
||
await page.goto('/');
|
||
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');
|
||
});
|