diff --git a/frontend/e2e/admin-analysis.spec.ts b/frontend/e2e/admin-analysis.spec.ts index 7acad49..d2848b0 100644 --- a/frontend/e2e/admin-analysis.spec.ts +++ b/frontend/e2e/admin-analysis.spec.ts @@ -155,6 +155,14 @@ async function mockAdmin(page: Page, cap: Captured) { }) ); + // 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({ @@ -221,6 +229,49 @@ test.describe('/admin/analysis', () => { ); }); + 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); diff --git a/frontend/src/lib/api/admin.test.ts b/frontend/src/lib/api/admin.test.ts index 58ca9e0..3315514 100644 --- a/frontend/src/lib/api/admin.test.ts +++ b/frontend/src/lib/api/admin.test.ts @@ -32,7 +32,8 @@ import { getAnalysisMangaCoverage, getAnalysisChapterCoverage, getAnalysisChapterPages, - getAnalysisPageDetail + getAnalysisPageDetail, + analysisStatusStreamUrl } from './admin'; function ok(body: unknown, status = 200): Response { @@ -580,4 +581,10 @@ describe('admin crawler api client', () => { expect(url).toMatch(/\/v1\/admin\/analysis\/pages\/p1$/); expect(d.status).toBe('none'); }); + + it('analysisStatusStreamUrl points at the SSE endpoint', () => { + expect(analysisStatusStreamUrl()).toMatch( + /\/v1\/admin\/analysis\/status\/stream$/ + ); + }); }); diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts index 8bf3563..3080946 100644 --- a/frontend/src/lib/api/admin.ts +++ b/frontend/src/lib/api/admin.ts @@ -532,3 +532,27 @@ export async function getAnalysisPageDetail( `/v1/admin/analysis/pages/${encodeURIComponent(pageId)}` ); } + +/** One live analysis event from the SSE stream. */ +export type AnalysisEvent = + | { + kind: 'enqueued'; + count: number; + manga_id: string | null; + chapter_id: string | null; + } + | { + kind: 'started' | 'completed' | 'failed'; + page_id: string; + manga_id: string; + chapter_id: string; + page_number: number; + }; + +/** URL of the live analysis SSE stream. Open with `new EventSource(...)` + * while the admin Analysis page is mounted and close it on navigate-away. + * Each message is a named `analysis` event whose `data` is an + * {@link AnalysisEvent}; a `lagged` event signals dropped frames. */ +export function analysisStatusStreamUrl(): string { + return apiUrl('/v1/admin/analysis/status/stream'); +} diff --git a/frontend/src/routes/admin/analysis/+page.svelte b/frontend/src/routes/admin/analysis/+page.svelte index d9a71d1..e5e9e0f 100644 --- a/frontend/src/routes/admin/analysis/+page.svelte +++ b/frontend/src/routes/admin/analysis/+page.svelte @@ -1,5 +1,5 @@