The route-mocked E2E specs had drifted from the app they exercise, so
each rendered an empty/500 page and timed out on missing elements:
- Manga detail + reader loads now also fetch read-progress/{id} and
/similar; several specs never mocked them, so the load's Promise.all
rejected. Add the mocks and fill the manga fixtures out to a real
MangaDetail (status/alt_titles/authors/genres/tags/... ) so the
components stop throwing mid-render.
- back-nav + library: widen `read-progress*`/`page-tags` globs — `*`
doesn't cross `/`, so `/me/read-progress/{id}` fell through to the
proxy and 500'd; mock the library page-tags pair too.
- manga-list search: wait for the initial load to settle before
searching so the search fetch can't race the mount-time load.
- reader-chapter-select: assert the shared `chapterLabel` output.
- admin-analysis: the active OCR backend extracts text only, so the
detail modal shows OCR (no tags/NSFW) and metrics shows tiles +
trend charts (no by-model) — assert what the OCR-era UI renders.
- profile: assert the wrong-password 401 stays inline (guards the
fix in the preceding commit) and mock the guest bookmark/collection
fetches the /profile load maps to the sign-in prompt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
436 lines
16 KiB
TypeScript
436 lines
16 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
|
|
|
// E2E for the admin Analysis section: coverage overview + badges, drill
|
|
// manga → chapter → page, the page-detail modal, and the enqueue actions.
|
|
// Fully mocked.
|
|
|
|
const DESKTOP = { width: 1280, height: 720 } as const;
|
|
|
|
const mangaId = 'a9999999-9999-9999-9999-999999999999';
|
|
const chapterId = 'c9999999-9999-9999-9999-999999999999';
|
|
const pageDone = 'p1111111-1111-1111-1111-111111111111';
|
|
const pageNone = 'p2222222-2222-2222-2222-222222222222';
|
|
|
|
const adminUser = {
|
|
id: 'u11111111-1111-1111-1111-111111111111',
|
|
username: 'admin',
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
is_admin: true
|
|
};
|
|
|
|
const systemStats = {
|
|
disk: null,
|
|
memory: { total_bytes: 1, used_bytes: 0, percent_used: 0 },
|
|
cpu: { percent_used: 0 },
|
|
alerts: []
|
|
};
|
|
|
|
type Captured = { reenqueue: Record<string, unknown> | null; analyzeCalls: number };
|
|
|
|
async function mockAdmin(page: Page, cap: Captured) {
|
|
await page.route('**/api/v1/auth/config', (r) =>
|
|
r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
|
})
|
|
);
|
|
await page.route('**/api/v1/auth/me', (r) =>
|
|
r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ user: adminUser })
|
|
})
|
|
);
|
|
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
|
r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ reader_mode: 'single', reader_page_gap: 'small' })
|
|
})
|
|
);
|
|
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
|
r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
|
})
|
|
);
|
|
await page.route('**/api/v1/admin/system', (r) =>
|
|
r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(systemStats)
|
|
})
|
|
);
|
|
|
|
// Coverage overview. Registered before the more specific routes below
|
|
// so the later-registered (more specific) globs win for their URLs.
|
|
await page.route('**/api/v1/admin/analysis/mangas**', (r) =>
|
|
r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
items: [
|
|
{
|
|
manga_id: mangaId,
|
|
title: 'Berserk',
|
|
total_pages: 2,
|
|
analyzed_pages: 1
|
|
}
|
|
],
|
|
page: { limit: 25, offset: 0, total: 1 }
|
|
})
|
|
})
|
|
);
|
|
await page.route('**/api/v1/admin/analysis/mangas/*/chapters', (r) =>
|
|
r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
items: [
|
|
{
|
|
chapter_id: chapterId,
|
|
number: 1,
|
|
title: 'The Brand',
|
|
total_pages: 2,
|
|
analyzed_pages: 1
|
|
}
|
|
]
|
|
})
|
|
})
|
|
);
|
|
await page.route('**/api/v1/admin/analysis/chapters/*/pages', (r) =>
|
|
r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
items: [
|
|
{ page_id: pageDone, page_number: 1, status: 'done' },
|
|
{ page_id: pageNone, page_number: 2, status: 'none' }
|
|
]
|
|
})
|
|
})
|
|
);
|
|
await page.route(`**/api/v1/admin/analysis/pages/${pageDone}`, (r) =>
|
|
r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
page_id: pageDone,
|
|
page_number: 1,
|
|
chapter_id: chapterId,
|
|
manga_id: mangaId,
|
|
status: 'done',
|
|
is_nsfw: true,
|
|
scene_description: 'A rainy street at night.',
|
|
model: 'test-model',
|
|
error: null,
|
|
analyzed_at: '2026-06-13T12:00:00Z',
|
|
ocr: [{ kind: 'speech', text: 'Hello there' }],
|
|
tags: ['action', 'city'],
|
|
content_warnings: ['gore']
|
|
})
|
|
})
|
|
);
|
|
await page.route(`**/api/v1/admin/analysis/pages/${pageNone}`, (r) =>
|
|
r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
page_id: pageNone,
|
|
page_number: 2,
|
|
chapter_id: chapterId,
|
|
manga_id: mangaId,
|
|
status: 'none',
|
|
is_nsfw: false,
|
|
scene_description: null,
|
|
model: null,
|
|
error: null,
|
|
analyzed_at: null,
|
|
ocr: [],
|
|
tags: [],
|
|
content_warnings: []
|
|
})
|
|
})
|
|
);
|
|
|
|
// Analysis history (terminal-outcome log). Registered before the
|
|
// page-detail routes are matched by their more-specific globs.
|
|
await page.route('**/api/v1/admin/analysis/history**', (r) =>
|
|
r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
items: [
|
|
{
|
|
page_id: pageDone,
|
|
page_number: 1,
|
|
chapter_id: chapterId,
|
|
chapter_number: 1,
|
|
manga_id: mangaId,
|
|
manga_title: 'Berserk',
|
|
status: 'done',
|
|
is_nsfw: true,
|
|
model: 'test-model',
|
|
error: null,
|
|
analyzed_at: '2026-06-13T12:00:00Z',
|
|
duration_ms: 2400
|
|
}
|
|
],
|
|
page: { limit: 25, offset: 0, total: 1 }
|
|
})
|
|
})
|
|
);
|
|
|
|
await page.route('**/api/v1/admin/analysis/metrics**', (r) =>
|
|
r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
n: 3204,
|
|
ok: 3159,
|
|
failed: 45,
|
|
avg_ms: 2400,
|
|
by_model: [{ model: 'qwen2-vl-7b', avg_ms: 2400, n: 3100 }]
|
|
})
|
|
})
|
|
);
|
|
// The metrics tab also pulls a per-bucket time series for the trend
|
|
// charts. Registered after the aggregate `metrics**` route so it wins
|
|
// for the more specific `/metrics/series` path.
|
|
await page.route('**/api/v1/admin/analysis/metrics/series**', (r) =>
|
|
r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
buckets: [
|
|
{ t: '2026-06-12T00:00:00Z', n: 1600, ok: 1580, failed: 20, avg_ms: 2300 },
|
|
{ t: '2026-06-13T00:00:00Z', n: 1604, ok: 1579, failed: 25, avg_ms: 2500 }
|
|
]
|
|
})
|
|
})
|
|
);
|
|
|
|
// Default: keep the SSE connection pending (no events) so tests that
|
|
// don't care about live updates don't trigger reconnect churn. The
|
|
// live-updates test overrides this with a fulfilling stream.
|
|
await page.route(
|
|
'**/api/v1/admin/analysis/status/stream',
|
|
() => new Promise(() => {})
|
|
);
|
|
|
|
await page.route('**/api/v1/admin/analysis/reenqueue', (r) => {
|
|
cap.reenqueue = JSON.parse(r.request().postData() ?? '{}');
|
|
return r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ enqueued: 12 })
|
|
});
|
|
});
|
|
await page.route(`**/api/v1/admin/pages/${pageNone}/analyze`, (r) => {
|
|
cap.analyzeCalls += 1;
|
|
return r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ enqueued: true })
|
|
});
|
|
});
|
|
}
|
|
|
|
test.describe('/admin/analysis', () => {
|
|
test('overview shows coverage badges and queues the whole library', async ({
|
|
page
|
|
}) => {
|
|
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
|
|
await mockAdmin(page, cap);
|
|
await page.setViewportSize(DESKTOP);
|
|
await page.goto('/admin/analysis');
|
|
|
|
const badge = page.getByTestId(`admin-analysis-coverage-manga-${mangaId}`);
|
|
await expect(badge).toBeVisible();
|
|
await expect(badge).toContainText('Partial 1/2');
|
|
|
|
await page.getByTestId('admin-analysis-enqueue-library').click();
|
|
await expect(page.getByTestId('admin-analysis-notice')).toContainText(
|
|
'Enqueued 12 pages'
|
|
);
|
|
expect(cap.reenqueue).toEqual({ only_unanalyzed: true });
|
|
});
|
|
|
|
test('drill manga → chapter → page and inspect the result', async ({ page }) => {
|
|
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
|
|
await mockAdmin(page, cap);
|
|
await page.setViewportSize(DESKTOP);
|
|
await page.goto('/admin/analysis');
|
|
|
|
await page.getByTestId(`admin-analysis-expand-${mangaId}`).click();
|
|
await expect(
|
|
page.getByTestId(`admin-analysis-coverage-chapter-${chapterId}`)
|
|
).toContainText('Partial 1/2');
|
|
|
|
await page.getByTestId(`admin-analysis-expand-chapter-${chapterId}`).click();
|
|
const doneChip = page.getByTestId(`admin-analysis-page-${pageDone}`);
|
|
await expect(doneChip).toHaveAttribute('data-status', 'done');
|
|
|
|
await doneChip.click();
|
|
const modal = page.getByTestId('admin-analysis-detail');
|
|
await expect(modal).toBeVisible();
|
|
await expect(
|
|
page.getByTestId('admin-analysis-detail-status')
|
|
).toContainText('Analyzed');
|
|
await expect(modal).toContainText('model test-model');
|
|
// OCR-only backend: the detail surfaces the extracted OCR lines (with
|
|
// their kind) — tags / scene / NSFW belong to the dormant vision
|
|
// backend and are intentionally not shown here.
|
|
await expect(modal).toContainText('OCR text');
|
|
await expect(modal).toContainText('Hello there');
|
|
});
|
|
|
|
test('live SSE events drive the indicator and activity ticker', async ({
|
|
page
|
|
}) => {
|
|
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
|
|
await mockAdmin(page, cap);
|
|
// Override the default hanging stream with one that emits two frames.
|
|
const frames =
|
|
`event: analysis\ndata: ${JSON.stringify({
|
|
kind: 'started',
|
|
page_id: pageNone,
|
|
manga_id: mangaId,
|
|
manga_title: 'Berserk',
|
|
chapter_id: chapterId,
|
|
chapter_number: 1,
|
|
page_number: 2
|
|
})}\n\n` +
|
|
`event: analysis\ndata: ${JSON.stringify({
|
|
kind: 'completed',
|
|
page_id: pageNone,
|
|
manga_id: mangaId,
|
|
manga_title: 'Berserk',
|
|
chapter_id: chapterId,
|
|
chapter_number: 1,
|
|
page_number: 2
|
|
})}\n\n`;
|
|
let served = false;
|
|
await page.route('**/api/v1/admin/analysis/status/stream', (r) => {
|
|
if (served) return new Promise(() => {}); // hang on reconnect
|
|
served = true;
|
|
return r.fulfill({
|
|
status: 200,
|
|
headers: { 'content-type': 'text/event-stream' },
|
|
body: frames
|
|
});
|
|
});
|
|
|
|
await page.setViewportSize(DESKTOP);
|
|
await page.goto('/admin/analysis');
|
|
|
|
// The two SSE frames are parsed and applied to the activity ticker.
|
|
// (The mocked stream closes after its body, so the live pill flips
|
|
// back to "Reconnecting…" — the ticker is the durable signal.)
|
|
const tick = page.getByTestId('admin-analysis-ticker');
|
|
await expect(tick).toContainText('Analyzing Berserk');
|
|
await expect(tick).toContainText('Analyzed page 2');
|
|
});
|
|
|
|
test('now-analyzing banner appears from SSE and Jump expands the chapter', async ({
|
|
page
|
|
}) => {
|
|
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
|
|
await mockAdmin(page, cap);
|
|
// A single `started` frame, then hang so the banner stays put.
|
|
const frame = `event: analysis\ndata: ${JSON.stringify({
|
|
kind: 'started',
|
|
page_id: pageDone,
|
|
manga_id: mangaId,
|
|
manga_title: 'Berserk',
|
|
chapter_id: chapterId,
|
|
chapter_number: 1,
|
|
page_number: 1
|
|
})}\n\n`;
|
|
let served = false;
|
|
await page.route('**/api/v1/admin/analysis/status/stream', (r) => {
|
|
if (served) return new Promise(() => {});
|
|
served = true;
|
|
return r.fulfill({
|
|
status: 200,
|
|
headers: { 'content-type': 'text/event-stream' },
|
|
body: frame
|
|
});
|
|
});
|
|
|
|
await page.setViewportSize(DESKTOP);
|
|
await page.goto('/admin/analysis');
|
|
|
|
const banner = page.getByTestId('admin-analysis-now');
|
|
await expect(banner).toContainText('Now analyzing');
|
|
await expect(banner).toContainText('Berserk · Ch 1 · Page 1');
|
|
|
|
// Jump expands the manga + chapter and the analyzing chip appears.
|
|
await page.getByTestId('admin-analysis-now-jump').click();
|
|
await expect(page.getByTestId(`admin-analysis-page-${pageDone}`)).toBeVisible();
|
|
});
|
|
|
|
test('history tab lists terminal analyses and opens the detail modal', async ({
|
|
page
|
|
}) => {
|
|
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
|
|
await mockAdmin(page, cap);
|
|
await page.setViewportSize(DESKTOP);
|
|
await page.goto('/admin/analysis');
|
|
|
|
await page.getByTestId('admin-analysis-tab-history').click();
|
|
const row = page.getByTestId(`analysis-history-row-${pageDone}`);
|
|
await expect(row).toContainText('Berserk · Ch 1 · p1');
|
|
await expect(row).toContainText('test-model'); // model column
|
|
await expect(row).toContainText('2.4s'); // duration column
|
|
// (No NSFW flag: the active OCR backend extracts text only.)
|
|
|
|
await row.click();
|
|
await expect(page.getByTestId('admin-analysis-detail')).toBeVisible();
|
|
await expect(page.getByTestId('admin-analysis-detail-status')).toContainText(
|
|
'Analyzed'
|
|
);
|
|
});
|
|
|
|
test('metrics tab shows aggregate timing + trend charts', async ({ page }) => {
|
|
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
|
|
await mockAdmin(page, cap);
|
|
await page.setViewportSize(DESKTOP);
|
|
await page.goto('/admin/analysis');
|
|
|
|
await page.getByTestId('admin-analysis-tab-metrics').click();
|
|
// Aggregate tiles for the selected window.
|
|
await expect(page.getByTestId('analysis-metrics-n')).toContainText('3204');
|
|
await expect(page.getByTestId('analysis-metrics-avg')).toContainText('2.4s');
|
|
const metrics = page.getByTestId('analysis-metrics');
|
|
await expect(metrics).toContainText('Success');
|
|
await expect(metrics).toContainText('99%'); // 3159/3204
|
|
await expect(metrics).toContainText('45'); // failed
|
|
// The per-bucket trend charts render from the series endpoint.
|
|
await expect(page.getByTestId('analysis-metrics-charts')).toBeVisible();
|
|
});
|
|
|
|
test('queue an unanalyzed page from its detail modal', async ({ page }) => {
|
|
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
|
|
await mockAdmin(page, cap);
|
|
await page.setViewportSize(DESKTOP);
|
|
await page.goto('/admin/analysis');
|
|
|
|
await page.getByTestId(`admin-analysis-expand-${mangaId}`).click();
|
|
await page.getByTestId(`admin-analysis-expand-chapter-${chapterId}`).click();
|
|
await page.getByTestId(`admin-analysis-page-${pageNone}`).click();
|
|
|
|
await expect(page.getByTestId('admin-analysis-detail-status')).toContainText(
|
|
'Not analyzed'
|
|
);
|
|
await page.getByTestId('admin-analysis-detail-queue').click();
|
|
|
|
await expect(page.getByTestId('admin-analysis-notice')).toContainText(
|
|
'Queued page 2'
|
|
);
|
|
expect(cap.analyzeCalls).toBe(1);
|
|
});
|
|
});
|