feat: add stop scan button in UI with backend cancellation support
- POST /scan/{run_id}/stop endpoint signals running scan to cancel
- Scanner checks cancellation flag between CIDR ranges and hosts, exits early
- Cancelled scans get status 'cancelled' instead of 'done'
- Stop button (red StopCircle) shown in Scan History panel for running scans
- 6 new backend tests, 5 new frontend tests
This commit is contained in:
@@ -12,7 +12,7 @@ from app.db.database import AsyncSessionLocal, get_db
|
|||||||
from app.db.models import Node, PendingDevice, ScanRun
|
from app.db.models import Node, PendingDevice, ScanRun
|
||||||
from app.schemas.nodes import NodeCreate
|
from app.schemas.nodes import NodeCreate
|
||||||
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
||||||
from app.services.scanner import run_scan
|
from app.services.scanner import request_cancel, run_scan
|
||||||
|
|
||||||
|
|
||||||
class ScanConfig(BaseModel):
|
class ScanConfig(BaseModel):
|
||||||
@@ -43,6 +43,21 @@ async def trigger_scan(
|
|||||||
return run
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{run_id}/stop", response_model=dict)
|
||||||
|
async def stop_scan(
|
||||||
|
run_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> dict[str, bool]:
|
||||||
|
run = await db.get(ScanRun, run_id)
|
||||||
|
if not run:
|
||||||
|
raise HTTPException(status_code=404, detail="Scan run not found")
|
||||||
|
if run.status != "running":
|
||||||
|
raise HTTPException(status_code=409, detail="Scan is not running")
|
||||||
|
request_cancel(run_id)
|
||||||
|
return {"stopping": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pending", response_model=list[PendingDeviceResponse])
|
@router.get("/pending", response_model=list[PendingDeviceResponse])
|
||||||
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
||||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
|
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ from app.services.fingerprint import fingerprint_ports, suggest_node_type
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Run IDs that have been requested to cancel
|
||||||
|
_cancelled_runs: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def request_cancel(run_id: str) -> None:
|
||||||
|
"""Signal a running scan to stop early."""
|
||||||
|
_cancelled_runs.add(run_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_cancelled(run_id: str) -> bool:
|
||||||
|
return run_id in _cancelled_runs
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import nmap
|
import nmap
|
||||||
_NMAP_AVAILABLE = True
|
_NMAP_AVAILABLE = True
|
||||||
@@ -123,10 +136,15 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
for cidr in ranges:
|
for cidr in ranges:
|
||||||
|
if _is_cancelled(run_id):
|
||||||
|
break
|
||||||
|
|
||||||
# Run nmap in a thread pool — does not block the event loop
|
# Run nmap in a thread pool — does not block the event loop
|
||||||
hosts = await asyncio.to_thread(_nmap_scan, cidr)
|
hosts = await asyncio.to_thread(_nmap_scan, cidr)
|
||||||
|
|
||||||
for host in hosts:
|
for host in hosts:
|
||||||
|
if _is_cancelled(run_id):
|
||||||
|
break
|
||||||
ip = host["ip"]
|
ip = host["ip"]
|
||||||
|
|
||||||
# Skip if device is already in the canvas (approved node)
|
# Skip if device is already in the canvas (approved node)
|
||||||
@@ -190,10 +208,10 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
|||||||
# Push WS event so the frontend refreshes pending panel
|
# Push WS event so the frontend refreshes pending panel
|
||||||
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
||||||
|
|
||||||
# Mark scan as done
|
# Mark scan as done or cancelled
|
||||||
run = await db.get(ScanRun, run_id)
|
run = await db.get(ScanRun, run_id)
|
||||||
if run:
|
if run:
|
||||||
run.status = "done"
|
run.status = "cancelled" if _is_cancelled(run_id) else "done"
|
||||||
run.devices_found = devices_found
|
run.devices_found = devices_found
|
||||||
run.finished_at = datetime.now(timezone.utc)
|
run.finished_at = datetime.now(timezone.utc)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -206,3 +224,5 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
|||||||
run.error = str(exc)
|
run.error = str(exc)
|
||||||
run.finished_at = datetime.now(timezone.utc)
|
run.finished_at = datetime.now(timezone.utc)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
finally:
|
||||||
|
_cancelled_runs.discard(run_id)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for scan routes: trigger, pending devices, approve/hide/ignore."""
|
"""Tests for scan routes: trigger, pending devices, approve/hide/ignore, stop."""
|
||||||
import uuid
|
import uuid
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.models import Node, PendingDevice, ScanRun
|
from app.db.models import Node, PendingDevice, ScanRun
|
||||||
from app.services.scanner import run_scan
|
from app.services.scanner import _cancelled_runs, request_cancel, run_scan
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -312,6 +312,99 @@ async def test_run_scan_skips_hidden_device(db_session: AsyncSession):
|
|||||||
assert result.scalar_one_or_none() is None
|
assert result.scalar_one_or_none() is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Stop scan ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_scan_requires_auth(client: AsyncClient):
|
||||||
|
res = await client.post("/api/v1/scan/fake-id/stop")
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_scan_not_found(client: AsyncClient, headers):
|
||||||
|
res = await client.post("/api/v1/scan/nonexistent-id/stop", headers=headers)
|
||||||
|
assert res.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_scan_not_running(client: AsyncClient, headers, db_session: AsyncSession):
|
||||||
|
run = ScanRun(id=str(uuid.uuid4()), status="done", ranges=["192.168.1.0/24"])
|
||||||
|
db_session.add(run)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
|
||||||
|
assert res.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_scan_success(client: AsyncClient, headers, db_session: AsyncSession):
|
||||||
|
run = ScanRun(id=str(uuid.uuid4()), status="running", ranges=["192.168.1.0/24"])
|
||||||
|
db_session.add(run)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json() == {"stopping": True}
|
||||||
|
# run_id added to cancel set
|
||||||
|
assert run.id in _cancelled_runs
|
||||||
|
# cleanup for other tests
|
||||||
|
_cancelled_runs.discard(run.id)
|
||||||
|
|
||||||
|
|
||||||
|
# --- run_scan cancellation ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_cancelled_marks_status(db_session: AsyncSession):
|
||||||
|
"""When cancel is requested before the scan starts, status becomes 'cancelled'."""
|
||||||
|
run_id = str(uuid.uuid4())
|
||||||
|
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||||
|
db_session.add(run)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
request_cancel(run_id)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]) as mock_nmap,
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||||
|
):
|
||||||
|
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||||
|
# nmap should not have been called — cancelled before first range
|
||||||
|
mock_nmap.assert_not_called()
|
||||||
|
|
||||||
|
await db_session.refresh(run)
|
||||||
|
assert run.status == "cancelled"
|
||||||
|
assert run.finished_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: AsyncSession):
|
||||||
|
"""Cancel flag set after first CIDR is started prevents processing of the second CIDR."""
|
||||||
|
run_id = str(uuid.uuid4())
|
||||||
|
run = ScanRun(id=run_id, status="running", ranges=["10.0.0.0/24", "10.0.1.0/24"])
|
||||||
|
db_session.add(run)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def nmap_side_effect(target: str):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
# Signal cancellation after the first CIDR scan completes
|
||||||
|
if call_count == 1:
|
||||||
|
request_cancel(run_id)
|
||||||
|
return []
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.scanner._nmap_scan", side_effect=nmap_side_effect),
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||||
|
):
|
||||||
|
await run_scan(["10.0.0.0/24", "10.0.1.0/24"], db_session, run_id)
|
||||||
|
|
||||||
|
assert call_count == 1 # second CIDR was skipped
|
||||||
|
await db_session.refresh(run)
|
||||||
|
assert run.status == "cancelled"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
|
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
|
||||||
"""Re-scanning the same IP updates services instead of creating a duplicate."""
|
"""Re-scanning the same IP updates services instead of creating a duplicate."""
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const scanApi = {
|
|||||||
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
|
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
|
||||||
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
||||||
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
||||||
|
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
|
||||||
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
|
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
|
||||||
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
|
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings } from 'lucide-react'
|
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle } from 'lucide-react'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
@@ -387,8 +387,26 @@ function ScanHistoryPanel() {
|
|||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [runs, load])
|
}, [runs, load])
|
||||||
|
|
||||||
|
const [stopping, setStopping] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleStop = async (runId: string) => {
|
||||||
|
setStopping(runId)
|
||||||
|
try {
|
||||||
|
await scanApi.stop(runId)
|
||||||
|
toast.success('Scan stop requested')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to stop scan')
|
||||||
|
} finally {
|
||||||
|
setStopping(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const statusColor = (s: string) =>
|
const statusColor = (s: string) =>
|
||||||
s === 'done' ? '#39d353' : s === 'running' ? '#e3b341' : s === 'error' ? '#f85149' : '#8b949e'
|
s === 'done' ? '#39d353'
|
||||||
|
: s === 'running' ? '#e3b341'
|
||||||
|
: s === 'error' ? '#f85149'
|
||||||
|
: s === 'cancelled' ? '#8b949e'
|
||||||
|
: '#8b949e'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
@@ -409,6 +427,24 @@ function ScanHistoryPanel() {
|
|||||||
<span className="font-mono text-foreground capitalize">{r.status}</span>
|
<span className="font-mono text-foreground capitalize">{r.status}</span>
|
||||||
{r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />}
|
{r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />}
|
||||||
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
|
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
|
||||||
|
{r.status === 'running' && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<button
|
||||||
|
aria-label="Stop scan"
|
||||||
|
onClick={() => handleStop(r.id)}
|
||||||
|
disabled={stopping === r.id}
|
||||||
|
className="p-0.5 text-[#f85149] hover:bg-[#f85149]/10 rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{stopping === r.id
|
||||||
|
? <Loader2 size={11} className="animate-spin" />
|
||||||
|
: <StopCircle size={11} />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left">Stop scan</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground text-[10px] mt-0.5">
|
<div className="text-muted-foreground text-[10px] mt-0.5">
|
||||||
{new Date(r.started_at).toLocaleString()}
|
{new Date(r.started_at).toLocaleString()}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { Sidebar } from '../Sidebar'
|
||||||
|
import * as canvasStore from '@/stores/canvasStore'
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
vi.mock('@/stores/canvasStore')
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
scanApi: {
|
||||||
|
trigger: vi.fn(),
|
||||||
|
pending: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
|
hidden: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
|
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
|
stop: vi.fn(),
|
||||||
|
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
|
||||||
|
},
|
||||||
|
settingsApi: { get: vi.fn(), save: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { scanApi } from '@/api/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const RUNNING_RUN = {
|
||||||
|
id: 'run-1',
|
||||||
|
status: 'running',
|
||||||
|
ranges: ['192.168.1.0/24'],
|
||||||
|
devices_found: 2,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
finished_at: null,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DONE_RUN = {
|
||||||
|
id: 'run-2',
|
||||||
|
status: 'done',
|
||||||
|
ranges: ['192.168.1.0/24'],
|
||||||
|
devices_found: 3,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
finished_at: new Date().toISOString(),
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const CANCELLED_RUN = {
|
||||||
|
id: 'run-3',
|
||||||
|
status: 'cancelled',
|
||||||
|
ranges: ['192.168.1.0/24'],
|
||||||
|
devices_found: 1,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
finished_at: new Date().toISOString(),
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSidebar() {
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
nodes: [],
|
||||||
|
hasUnsavedChanges: false,
|
||||||
|
hideIp: false,
|
||||||
|
toggleHideIp: vi.fn(),
|
||||||
|
addNode: vi.fn(),
|
||||||
|
scanEventTs: 0,
|
||||||
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar
|
||||||
|
onAddNode={vi.fn()}
|
||||||
|
onAddGroupRect={vi.fn()}
|
||||||
|
onScan={vi.fn()}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
onNodeApproved={vi.fn()}
|
||||||
|
/>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openHistory() {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Scan History' }))
|
||||||
|
// Wait for runs to load
|
||||||
|
await waitFor(() => expect(scanApi.runs).toHaveBeenCalled())
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ScanHistoryPanel — stop scan', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(toast.success).mockReset()
|
||||||
|
vi.mocked(toast.error).mockReset()
|
||||||
|
vi.mocked(scanApi.stop).mockReset()
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [] } as never)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows stop button only for running scans', async () => {
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never)
|
||||||
|
renderSidebar()
|
||||||
|
await openHistory()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('running')).toBeDefined())
|
||||||
|
|
||||||
|
// Exactly one stop button rendered (for the running scan only)
|
||||||
|
const stopButtons = screen.getAllByRole('button', { name: 'Stop scan' })
|
||||||
|
expect(stopButtons).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls scanApi.stop with the correct run ID on click', async () => {
|
||||||
|
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
|
||||||
|
renderSidebar()
|
||||||
|
await openHistory()
|
||||||
|
|
||||||
|
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
|
||||||
|
fireEvent.click(stopBtn)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scanApi.stop).toHaveBeenCalledWith('run-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success toast when stop succeeds', async () => {
|
||||||
|
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
|
||||||
|
renderSidebar()
|
||||||
|
await openHistory()
|
||||||
|
|
||||||
|
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
|
||||||
|
fireEvent.click(stopBtn)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('Scan stop requested')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast when stop fails', async () => {
|
||||||
|
vi.mocked(scanApi.stop).mockRejectedValue(new Error('network'))
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
|
||||||
|
renderSidebar()
|
||||||
|
await openHistory()
|
||||||
|
|
||||||
|
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
|
||||||
|
fireEvent.click(stopBtn)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Failed to stop scan')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders cancelled status without stop button or spinner', async () => {
|
||||||
|
vi.mocked(scanApi.runs).mockResolvedValue({ data: [CANCELLED_RUN] } as never)
|
||||||
|
renderSidebar()
|
||||||
|
await openHistory()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('cancelled')).toBeDefined())
|
||||||
|
|
||||||
|
// No stop button
|
||||||
|
expect(screen.queryByRole('button', { name: 'Stop scan' })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user