Files
Mangalord/frontend/e2e/admin-analysis.spec.ts
MechaCat02 8b5bd99446 feat(analysis): live SSE updates in the admin coverage dashboard
The Analysis section now reflects worker progress in real time:

- Opens an EventSource on the live stream while mounted; a "Live" pill
  reflects connection state and reconnects on drop (probes after repeated
  failures so a lost session logs out).
- Applies events incrementally: enqueued marks in-scope loaded pages
  queued; started flips a chip to a pulsing "analyzing"; completed flips
  it green and live-bumps the manga + chapter coverage badges (guarded so
  no double count); failed flips it red. A compact activity ticker shows
  the last few events.
- Server numbers stay authoritative: re-loading the overview or a page
  grid clears the matching live overrides.

API client: analysisStatusStreamUrl() + AnalysisEvent type.

Tests: vitest for the stream URL; Playwright asserts SSE frames flow into
the ticker (existing coverage/drill/queue specs get a default quiet
stream). svelte-check + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:12:56 +02:00

296 lines
10 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: []
})
})
);
// 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,
chapter_id: chapterId,
page_number: 2
})}\n\n` +
`event: analysis\ndata: ${JSON.stringify({
kind: 'completed',
page_id: pageNone,
manga_id: mangaId,
chapter_id: chapterId,
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 page 2');
await expect(tick).toContainText('Analyzed page 2');
});
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);
});
});