Closing the dashboard tab or clicking outside the scan modal raised ClientConnectionResetError out of scan_images and abandoned the scan mid-batch, leaving a half-scanned database. Two overlapping scans could also run at once. - py/api/scan_job.py: ScanJob fans progress out to any number of SSE subscribers (latest snapshot first, then live events) - POST /prompt_manager/scan starts the scan as an asyncio task, or attaches to the one already running; a write to a dead socket ends that stream quietly while the scan continues - GET /prompt_manager/scan/status reports the running flag and last event - dashboard: dismissing the modal while a scan runs minimizes it to a progress pill, the page re-attaches to a running scan after a reload, a lost stream reconnects, and leaving the page mid-scan asks first
93 lines
2.9 KiB
Python
93 lines
2.9 KiB
Python
"""Unit tests for py/api/scan_job.py: fan-out of scan progress to SSE subscribers."""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import os
|
|
import sys
|
|
import unittest
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from py.api.scan_job import ScanJob # noqa: E402
|
|
|
|
|
|
async def _collect(job, stop_after=None):
|
|
seen = []
|
|
async with contextlib.aclosing(job.events()) as events:
|
|
async for payload in events:
|
|
seen.append(payload)
|
|
if stop_after is not None and len(seen) >= stop_after:
|
|
break
|
|
return seen
|
|
|
|
|
|
class TestScanJob(unittest.IsolatedAsyncioTestCase):
|
|
|
|
async def test_new_job_is_running_with_no_event(self):
|
|
job = ScanJob()
|
|
self.assertTrue(job.running)
|
|
self.assertIsNone(job.last_event)
|
|
|
|
async def test_subscriber_receives_events_until_finish(self):
|
|
job = ScanJob()
|
|
consumer = asyncio.ensure_future(_collect(job))
|
|
await asyncio.sleep(0)
|
|
|
|
job.publish({"type": "progress", "progress": 10})
|
|
job.publish({"type": "complete"})
|
|
job.finish()
|
|
|
|
self.assertEqual(
|
|
await asyncio.wait_for(consumer, 2),
|
|
[{"type": "progress", "progress": 10}, {"type": "complete"}],
|
|
)
|
|
self.assertFalse(job.running)
|
|
|
|
async def test_late_subscriber_gets_latest_snapshot_then_live_events(self):
|
|
job = ScanJob()
|
|
job.publish({"type": "progress", "progress": 10})
|
|
job.publish({"type": "progress", "progress": 40})
|
|
|
|
consumer = asyncio.ensure_future(_collect(job))
|
|
await asyncio.sleep(0)
|
|
job.publish({"type": "complete"})
|
|
job.finish()
|
|
|
|
self.assertEqual(
|
|
await asyncio.wait_for(consumer, 2),
|
|
[{"type": "progress", "progress": 40}, {"type": "complete"}],
|
|
)
|
|
|
|
async def test_subscriber_after_finish_gets_only_the_final_event(self):
|
|
job = ScanJob()
|
|
job.publish({"type": "progress", "progress": 99})
|
|
job.publish({"type": "complete"})
|
|
job.finish()
|
|
|
|
self.assertEqual(await _collect(job), [{"type": "complete"}])
|
|
|
|
async def test_two_subscribers_both_see_every_event(self):
|
|
job = ScanJob()
|
|
first = asyncio.ensure_future(_collect(job))
|
|
second = asyncio.ensure_future(_collect(job))
|
|
await asyncio.sleep(0)
|
|
|
|
job.publish({"type": "progress", "progress": 1})
|
|
job.finish()
|
|
|
|
self.assertEqual(await first, [{"type": "progress", "progress": 1}])
|
|
self.assertEqual(await second, [{"type": "progress", "progress": 1}])
|
|
|
|
async def test_departed_subscriber_is_forgotten(self):
|
|
job = ScanJob()
|
|
job.publish({"type": "progress", "progress": 1})
|
|
await _collect(job, stop_after=1)
|
|
|
|
self.assertEqual(job.subscriber_count, 0)
|
|
job.publish({"type": "complete"}) # must not raise or queue anywhere
|
|
job.finish()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|