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>
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user