Bundle of small UI/UX fixes plus a build hygiene tweak.
* List pagination — Home (`/`) and `/authors/[id]` silently capped at
the backend default of 50 with no UI to advance. New reusable
`Pager.svelte` (Prev/Next + numbered with ellipsis), URL-synced
`?page=N`, and filter/search/sort reset to page 1 so users aren't
stranded on an out-of-range page. Count label now shows a range
("Showing 51–100 of 237").
* Stale page title — Pages without a `<svelte:head><title>` left the
document title at whatever the last manga / author / collection page
set it to. Move static-route titles into a route-id → title map in
the root layout and invert every dynamic title to brand-first
(`Mangalord | {X}`) for consistency.
* Admin filter bar — `/admin/mangas` search input had `flex: 1` and
ballooned across the row, shoving the sync-state select + Search
button to the far right. Cap at 24rem, vertical-align the row, and
promote the previously aria-only "Sync state" label to visible text.
* Build hygiene — `backend/target` had grown to 68 GiB. Cleaned and
added `[profile.dev] debug = "line-tables-only"` (and `[profile.test]`
too) to cut future dev builds by ~50–70% while keeping line numbers
in backtraces.
Also: configure vitest to resolve Svelte's browser entry so
`@testing-library/svelte` can mount components in jsdom — needed for
the new `Pager.svelte.test.ts`.
Bump 0.48.0 -> 0.49.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
131 lines
4.9 KiB
TypeScript
131 lines
4.9 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
||
|
||
// 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');
|
||
});
|
||
|
||
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');
|
||
});
|