From ee0a829cdda29b325d26e876b3f425a1b935ca39 Mon Sep 17 00:00:00 2001 From: 25f1000058 <25f1000058@ds.study.iitm.ac.in> Date: Sat, 6 Jun 2026 10:50:59 +0530 Subject: [PATCH 1/5] fixed: Replaced Scans alert() failures with toast feedback --- frontend/src/pages/Scans.tsx | 107 +++++----- .../unit/pages/Scans.failures.test.tsx | 194 ++++++++++++++++++ 2 files changed, 251 insertions(+), 50 deletions(-) create mode 100644 frontend/testing/unit/pages/Scans.failures.test.tsx diff --git a/frontend/src/pages/Scans.tsx b/frontend/src/pages/Scans.tsx index 0e93cd90..bf063ef3 100644 --- a/frontend/src/pages/Scans.tsx +++ b/frontend/src/pages/Scans.tsx @@ -10,6 +10,7 @@ import { } from "../utils/date"; import { ConfirmModal } from "../components/ConfirmModal"; import Pagination from "../components/Pagination"; +import { useToast } from "../components/ToastContext"; interface Task { task_id: string; @@ -56,6 +57,7 @@ const itemVariants = { export default function Scans() { const navigate = useNavigate(); + const { addToast } = useToast(); const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); const [filter, setFilter] = useState("all"); @@ -76,7 +78,7 @@ export default function Scans() { isOpen: false, title: "", message: "", - onConfirm: () => {}, + onConfirm: () => { }, type: "warning", }); @@ -195,13 +197,20 @@ export default function Scans() { type: "danger", onConfirm: async () => { try { + throw new Error("Simulating a backend crash"); + await deleteTask(taskId); setTasks((prev) => prev.filter((t) => t.task_id !== taskId)); if (expandedId === taskId) setExpandedId(null); setModalState(prev => ({ ...prev, isOpen: false })); + + addToast('Record purged cleanly.', 'success'); + } catch (err) { console.error("Failed to delete task:", err); - alert("Failed to delete task. It might still be running."); + + addToast('Failed to delete task. It might still be running.', 'error'); + setModalState(prev => ({ ...prev, isOpen: false })); } }, @@ -221,16 +230,18 @@ export default function Scans() { setSelectedIds([]); setExpandedId(null); setModalState(prev => ({ ...prev, isOpen: false })); + addToast("Operational history cleared successfully.", "success"); } catch (err) { console.error("Failed to clear history:", err); - alert("Failed to clear history. Ensure no tasks are currently running."); + addToast("Failed to clear history. Ensure no tasks are currently running.", "error"); + setModalState(prev => ({ ...prev, isOpen: false })); } }, }); } - async function handleBulkDelete() { +async function handleBulkDelete() { if (selectedIds.length === 0) return; setModalState({ isOpen: true, @@ -239,13 +250,15 @@ export default function Scans() { type: "danger", onConfirm: async () => { try { + const deletedCount = selectedIds.length; await bulkDeleteTasks(selectedIds); setTasks((prev) => prev.filter((t) => !selectedIds.includes(t.task_id))); setSelectedIds([]); setModalState(prev => ({ ...prev, isOpen: false })); + addToast(`Successfully deleted ${deletedCount} records.`, "success"); } catch (err) { console.error("Bulk delete failed:", err); - alert("Failed to delete some tasks. Ensure they are not currently running."); + addToast("Failed to delete some tasks. Ensure they are not currently running.", "error"); setModalState(prev => ({ ...prev, isOpen: false })); } }, @@ -319,11 +332,10 @@ export default function Scans() {
- )} + + )} {(task.status === "completed" || task.status === "failed") && ( - - )} + + )}
); -} +} \ No newline at end of file diff --git a/frontend/testing/unit/pages/Scans.failures.test.tsx b/frontend/testing/unit/pages/Scans.failures.test.tsx new file mode 100644 index 00000000..5d4e5cc3 --- /dev/null +++ b/frontend/testing/unit/pages/Scans.failures.test.tsx @@ -0,0 +1,194 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Scans from '../../../src/pages/Scans'; +import { ToastProvider } from '../../../src/components/ToastContext'; + +vi.mock('../../../src/api', () => ({ + API_BASE: 'http://localhost', + deleteTask: vi.fn(), + clearAllTasks: vi.fn(), + bulkDeleteTasks: vi.fn(), +})); + +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => vi.fn() }; +}); + +const COMPLETED_TASK = { + task_id: 'task-abc-123', + plugin_id: 'nmap', + tool: 'Nmap Scanner', + target: 'example.com', + status: 'completed' as const, + created_at: '2026-05-29T10:00:00Z', +}; + +const ONE_TASK_RESPONSE = { + tasks: [COMPLETED_TASK], + pagination: { total_items: 1 }, +}; + +const TWO_TASK_RESPONSE = { + tasks: [ + COMPLETED_TASK, + { ...COMPLETED_TASK, task_id: 'task-def-456', tool: 'Second Scanner' }, + ], + pagination: { total_items: 2 }, +}; + +function renderScans() { + return render( + + + + + , + ); +} + +async function waitForTasks(toolName: string) { + await screen.findByText(toolName, {}, { timeout: 3000 }); +} + +async function expandTask(toolName: string) { + const card = await screen.findByText(toolName); + fireEvent.click(card); + await screen.findByRole('button', { name: /delete_record/i }); +} + +async function clickConfirm() { + const confirmBtn = await screen.findByRole('button', { name: /^confirm$/i }); + fireEvent.click(confirmBtn); +} + +beforeEach(() => { + vi.useRealTimers(); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'visible', + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('Scans — destructive-action failure feedback', () => { + describe('handleTaskDelete failure', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(ONE_TASK_RESPONSE), + })); + }); + + it('shows an error toast when deleteTask rejects', async () => { + const { deleteTask } = await import('../../../src/api'); + vi.mocked(deleteTask).mockRejectedValueOnce(new Error('backend crash')); + + renderScans(); + await waitForTasks('Nmap Scanner'); + await expandTask('Nmap Scanner'); + + fireEvent.click(screen.getByRole('button', { name: /delete_record/i })); + await clickConfirm(); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + expect(screen.getByText(/failed to delete task/i)).toBeInTheDocument(); + }); + + it('does not remove the task from the list when deleteTask rejects', async () => { + const { deleteTask } = await import('../../../src/api'); + vi.mocked(deleteTask).mockRejectedValueOnce(new Error('network error')); + + renderScans(); + await waitForTasks('Nmap Scanner'); + await expandTask('Nmap Scanner'); + + fireEvent.click(screen.getByRole('button', { name: /delete_record/i })); + await clickConfirm(); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + expect(screen.getByText('Nmap Scanner')).toBeInTheDocument(); + }); + }); + + describe('handleBulkDelete failure', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(TWO_TASK_RESPONSE), + })); + }); + + it('shows an error toast when bulkDeleteTasks rejects', async () => { + const { bulkDeleteTasks } = await import('../../../src/api'); + vi.mocked(bulkDeleteTasks).mockRejectedValueOnce(new Error('bulk delete failed')); + + renderScans(); + await waitForTasks('Nmap Scanner'); + + fireEvent.click(screen.getByRole('button', { name: /select_all/i })); + + const bulkBtn = await screen.findByRole('button', { name: /prune_selected_records/i }); + fireEvent.click(bulkBtn); + await clickConfirm(); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + expect(screen.getByText(/failed to delete some tasks/i)).toBeInTheDocument(); + }); + + it('keeps tasks in the list when bulkDeleteTasks rejects', async () => { + const { bulkDeleteTasks } = await import('../../../src/api'); + vi.mocked(bulkDeleteTasks).mockRejectedValueOnce(new Error('bulk delete failed')); + + renderScans(); + await waitForTasks('Nmap Scanner'); + + fireEvent.click(screen.getByRole('button', { name: /select_all/i })); + + const bulkBtn = await screen.findByRole('button', { name: /prune_selected_records/i }); + fireEvent.click(bulkBtn); + await clickConfirm(); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + expect(screen.getByText('Nmap Scanner')).toBeInTheDocument(); + expect(screen.getByText('Second Scanner')).toBeInTheDocument(); + }); + }); + + describe('handleClearAll failure', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(ONE_TASK_RESPONSE), + })); + }); + + it('shows an error toast when clearAllTasks rejects', async () => { + const { clearAllTasks } = await import('../../../src/api'); + vi.mocked(clearAllTasks).mockRejectedValueOnce(new Error('tasks still running')); + + renderScans(); + await waitForTasks('Nmap Scanner'); + + fireEvent.click(screen.getByRole('button', { name: /purge_all_records/i })); + await clickConfirm(); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + expect(screen.getByText(/failed to clear history/i)).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file From eca43d099c294579b2820b9e4f34c0fa4abf9bd7 Mon Sep 17 00:00:00 2001 From: 25f1000058 <25f1000058@ds.study.iitm.ac.in> Date: Sat, 6 Jun 2026 11:13:46 +0530 Subject: [PATCH 2/5] fix(frontend): remove trailing whitespace in Scans.tsx --- frontend/src/pages/Scans.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/Scans.tsx b/frontend/src/pages/Scans.tsx index bf063ef3..cc57d1fe 100644 --- a/frontend/src/pages/Scans.tsx +++ b/frontend/src/pages/Scans.tsx @@ -203,14 +203,14 @@ export default function Scans() { setTasks((prev) => prev.filter((t) => t.task_id !== taskId)); if (expandedId === taskId) setExpandedId(null); setModalState(prev => ({ ...prev, isOpen: false })); - + addToast('Record purged cleanly.', 'success'); - + } catch (err) { console.error("Failed to delete task:", err); - + addToast('Failed to delete task. It might still be running.', 'error'); - + setModalState(prev => ({ ...prev, isOpen: false })); } }, @@ -234,7 +234,7 @@ export default function Scans() { } catch (err) { console.error("Failed to clear history:", err); addToast("Failed to clear history. Ensure no tasks are currently running.", "error"); - + setModalState(prev => ({ ...prev, isOpen: false })); } }, From 0f165774661bfcebb0263a83a6ba04f56b164ff1 Mon Sep 17 00:00:00 2001 From: 25f1000058 <25f1000058@ds.study.iitm.ac.in> Date: Sat, 6 Jun 2026 11:20:52 +0530 Subject: [PATCH 3/5] fix(frontend): removed the debug throw from handleTaskDelete --- frontend/src/pages/Scans.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/Scans.tsx b/frontend/src/pages/Scans.tsx index cc57d1fe..ad941cb1 100644 --- a/frontend/src/pages/Scans.tsx +++ b/frontend/src/pages/Scans.tsx @@ -67,7 +67,7 @@ export default function Scans() { const [total, setTotal] = useState(0); const PAGE_LIMIT = 10; - // Modal state for confirm dialogs + const [modalState, setModalState] = useState<{ isOpen: boolean; title: string; @@ -82,7 +82,7 @@ export default function Scans() { type: "warning", }); - // Ref so the visibilitychange handler always sees the current interval id + const intervalRef = useRef | null>(null); const requestSeqRef = useRef(0); const abortRef = useRef(null); @@ -197,8 +197,6 @@ export default function Scans() { type: "danger", onConfirm: async () => { try { - throw new Error("Simulating a backend crash"); - await deleteTask(taskId); setTasks((prev) => prev.filter((t) => t.task_id !== taskId)); if (expandedId === taskId) setExpandedId(null); From 98acd3ac9a20719add2a0676b209ad5ab716e33d Mon Sep 17 00:00:00 2001 From: 25f1000058 <25f1000058@ds.study.iitm.ac.in> Date: Sat, 6 Jun 2026 11:23:06 +0530 Subject: [PATCH 4/5] fixed: removed trailing whitespace --- frontend/src/pages/Scans.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/Scans.tsx b/frontend/src/pages/Scans.tsx index ad941cb1..39015fab 100644 --- a/frontend/src/pages/Scans.tsx +++ b/frontend/src/pages/Scans.tsx @@ -82,7 +82,7 @@ export default function Scans() { type: "warning", }); - + const intervalRef = useRef | null>(null); const requestSeqRef = useRef(0); const abortRef = useRef(null); From 3625ee9d47a02d1606aaf0cddb24d7fb35a83b29 Mon Sep 17 00:00:00 2001 From: 25f1000058 <25f1000058@ds.study.iitm.ac.in> Date: Sat, 6 Jun 2026 11:32:42 +0530 Subject: [PATCH 5/5] fix(tests): wrap Workflows with ToastProvider, remove trailing whitespace --- .../testing/unit/pages/Workflows.test.tsx | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/frontend/testing/unit/pages/Workflows.test.tsx b/frontend/testing/unit/pages/Workflows.test.tsx index 7c304da8..718219f7 100644 --- a/frontend/testing/unit/pages/Workflows.test.tsx +++ b/frontend/testing/unit/pages/Workflows.test.tsx @@ -2,6 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router-dom' import Workflows from '../../../src/pages/Workflows' +import { ToastProvider } from '../../../src/components/ToastContext' import { getWorkflows, createWorkflow, runWorkflow, updateWorkflow, deleteWorkflow } from '../../../src/api' vi.mock('../../../src/api', () => ({ @@ -32,34 +33,36 @@ const disabledWorkflow = { function renderPage() { return render( - + + + ) } describe('Workflows — loading and empty states', () => { - beforeEach(() => { + beforeEach(() => { vi.mocked(getWorkflows).mockClear() -}) + }) it('shows loading spinner while fetching', () => { - vi.mocked(getWorkflows).mockReturnValue(new Promise(() => {})) + vi.mocked(getWorkflows).mockReturnValue(new Promise(() => { })) renderPage() expect(screen.getByText(/Loading Workflows/i)).toBeInTheDocument() }) it('shows empty state when no workflows exist', async () => { - vi.mocked(getWorkflows).mockResolvedValue([]) - renderPage() - expect(await screen.findByText(/No Workflows/i)).toBeInTheDocument() - expect(screen.getByText(/Create a workflow to automate recurring scans/i)).toBeInTheDocument() + vi.mocked(getWorkflows).mockResolvedValue([]) + renderPage() + expect(await screen.findByText(/No Workflows/i)).toBeInTheDocument() + expect(screen.getByText(/Create a workflow to automate recurring scans/i)).toBeInTheDocument() }) it('shows error state when fetch fails', async () => { - vi.mocked(getWorkflows).mockRejectedValue(new Error('Network error')) - renderPage() - expect(await screen.findByText('Failed to load')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /Retry/i })).toBeInTheDocument() + vi.mocked(getWorkflows).mockRejectedValue(new Error('Network error')) + renderPage() + expect(await screen.findByText('Failed to load')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Retry/i })).toBeInTheDocument() }) it('retries fetch when retry button is clicked', async () => { @@ -212,4 +215,4 @@ describe('Workflows — delete action', () => { expect(vi.mocked(deleteWorkflow)).not.toHaveBeenCalled() expect(screen.getByText('Nightly Scan')).toBeInTheDocument() }) -}) +}) \ No newline at end of file