UNPKG

@accounter/scraper-app

Version:
145 lines (116 loc) 5.97 kB
import { afterEach, describe, expect, it } from 'vitest'; import { _resetRunState, startRun, type ScrapeTask } from '../scrape-runner.js'; import type { ServerMessage } from '../../shared/ws-protocol.js'; type TaskResult = { inserted: number; skipped: number; insertedIds: string[]; insertedTransactions: []; changedTransactions: [] }; function makeTask( sourceId: string, run: () => Promise<TaskResult> = async () => ({ inserted: 2, skipped: 1, insertedIds: ['id-1', 'id-2'], insertedTransactions: [], changedTransactions: [], }), ): ScrapeTask { return { sourceId, nickname: sourceId, type: 'poalim', run }; } afterEach(() => { _resetRunState(); }); // ── Sequential ──────────────────────────────────────────────────────────────── describe('sequential mode', () => { it('emits pending×2 → running→done task1 → running→done task2 → run-complete', async () => { const events: ServerMessage[] = []; const emit = (msg: ServerMessage) => events.push(msg); await startRun([makeTask('src-1'), makeTask('src-2')], false, emit); expect(events[0]).toEqual({ type: 'task-pending', sourceId: 'src-1' }); expect(events[1]).toEqual({ type: 'task-pending', sourceId: 'src-2' }); expect(events[2]).toMatchObject({ type: 'task-running', sourceId: 'src-1' }); expect(events[3]).toMatchObject({ type: 'task-done', sourceId: 'src-1', inserted: 2, skipped: 1 }); expect(events[4]).toMatchObject({ type: 'task-running', sourceId: 'src-2' }); expect(events[5]).toMatchObject({ type: 'task-done', sourceId: 'src-2', inserted: 2, skipped: 1 }); expect(events[6]).toEqual({ type: 'run-complete', totalInserted: 4, totalSkipped: 2, errors: 0 }); expect(events).toHaveLength(7); }); it('returns a RunRecord with correct totals and timestamps', async () => { const before = new Date(); const record = await startRun([makeTask('src-1')], false, () => {}); const after = new Date(); expect(record.id).toMatch(/^[\da-f-]{36}$/); expect(record.totalInserted).toBe(2); expect(record.totalSkipped).toBe(1); expect(record.errorCount).toBe(0); expect(record.startedAt >= before).toBe(true); expect(record.completedAt <= after).toBe(true); }); }); // ── Concurrent ──────────────────────────────────────────────────────────────── describe('concurrent mode', () => { it('both running events arrive before either done event', async () => { const events: ServerMessage[] = []; const emit = (msg: ServerMessage) => events.push(msg); const delayed = (): Promise<TaskResult> => new Promise(res => setTimeout(() => res({ inserted: 1, skipped: 0, insertedIds: ['x'], insertedTransactions: [], changedTransactions: [] }), 20)); await startRun([makeTask('src-1', delayed), makeTask('src-2', delayed)], true, emit); const runningIdxs = events.flatMap((e, i) => (e.type === 'task-running' ? [i] : [])); const doneIdxs = events.flatMap((e, i) => (e.type === 'task-done' ? [i] : [])); expect(runningIdxs).toHaveLength(2); expect(doneIdxs).toHaveLength(2); expect(Math.max(...runningIdxs)).toBeLessThan(Math.min(...doneIdxs)); }); it('totals reflect both tasks', async () => { const record = await startRun( [makeTask('src-1'), makeTask('src-2')], true, () => {}, ); expect(record.totalInserted).toBe(4); expect(record.totalSkipped).toBe(2); }); }); // ── Task error ──────────────────────────────────────────────────────────────── describe('task error handling', () => { it('emits task-error when run() throws, other task still completes', async () => { const events: ServerMessage[] = []; const emit = (msg: ServerMessage) => events.push(msg); const failing = makeTask('src-1', async () => { throw new Error('Scraper exploded'); }); await startRun([failing, makeTask('src-2')], false, emit); const taskError = events.find( e => e.type === 'task-error' && 'sourceId' in e && e.sourceId === 'src-1', ); expect(taskError).toBeTruthy(); expect((taskError as { message: string }).message).toContain('Scraper exploded'); expect(events.some(e => e.type === 'task-done' && 'sourceId' in e && e.sourceId === 'src-2')).toBe(true); expect(events.at(-1)).toMatchObject({ type: 'run-complete', errors: 1 }); }); it('run-complete totals reflect only the successful task', async () => { const record = await startRun( [ makeTask('src-1', async () => { throw new Error('fail'); }), makeTask('src-2'), ], false, () => {}, ); expect(record.totalInserted).toBe(2); // only src-2 expect(record.totalSkipped).toBe(1); }); }); // ── In-progress guard ───────────────────────────────────────────────────────── describe('in-progress guard', () => { it('throws when called while a run is already in progress', async () => { const neverResolves = (): Promise<TaskResult> => new Promise(_r => {}); // Start a run that never finishes (don't await it) const firstRun = startRun([makeTask('src-1', neverResolves)], false, () => {}); // Second call should reject immediately because _running is already true await expect(startRun([makeTask('src-2')], false, () => {})).rejects.toThrow( 'Run already in progress', ); // Cleanup: _resetRunState is called in afterEach, firstRun hangs but that's OK void firstRun; }); });