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 | 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 }] }) }) ); // 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('Hello there'); await expect(modal).toContainText('action'); await expect(page.getByTestId('admin-analysis-detail-warnings')).toContainText( 'gore' ); }); 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('NSFW'); await expect(row).toContainText('2.4s'); // duration column 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 + by-model', 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(); await expect(page.getByTestId('analysis-metrics-n')).toContainText('3204'); await expect(page.getByTestId('analysis-metrics-avg')).toContainText('2.4s'); await expect(page.getByTestId('analysis-metrics')).toContainText('qwen2-vl-7b'); }); 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); }); });