From 491a60dda16d36c5bef01aed2f5f2c4c2012eb0a 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 the scans alert failures with toast feedback --- frontend/src/pages/Scans.tsx | 2 + .../unit/pages/Scans.failures.test.tsx | 194 ++++++++++++++++++ .../testing/unit/pages/Scans.polling.test.tsx | 27 ++- 3 files changed, 208 insertions(+), 15 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 f8850f2d7..0f948f7cb 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 { useToast } from "../components/ToastContext"; +import { useToast } from "../components/ToastContext"; import Pagination from "../components/Pagination"; interface Task { @@ -60,6 +61,7 @@ const itemVariants: Variants = { export default function Scans() { const navigate = useNavigate(); const { addToast } = useToast(); + const { addToast } = useToast(); const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); const [filter, setFilter] = useState("all"); 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 000000000..5d4e5cc37 --- /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 diff --git a/frontend/testing/unit/pages/Scans.polling.test.tsx b/frontend/testing/unit/pages/Scans.polling.test.tsx index 84480880c..9a4f30f0c 100644 --- a/frontend/testing/unit/pages/Scans.polling.test.tsx +++ b/frontend/testing/unit/pages/Scans.polling.test.tsx @@ -4,8 +4,6 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import Scans from '../../../src/pages/Scans'; import { ToastProvider } from '../../../src/components/ToastContext' -// ── Mocks ──────────────────────────────────────────────────────────────────── - vi.mock('../../../src/api', () => ({ API_BASE: 'http://localhost', deleteTask: vi.fn(), @@ -13,6 +11,14 @@ vi.mock('../../../src/api', () => ({ bulkDeleteTasks: vi.fn(), })); +vi.mock('../../../src/components/ToastContext', () => ({ + useToast: () => ({ + addToast: vi.fn(), + removeToast: vi.fn(), + }), + ToastProvider: ({ children }: any) => children, +})); + vi.mock('react-router-dom', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useNavigate: () => vi.fn() }; @@ -50,10 +56,7 @@ beforeEach(() => { json: () => Promise.resolve(EMPTY_RESPONSE), }); vi.stubGlobal('fetch', fetchSpy); - - // Use fake timers; microtasks drained via Promise.resolve() chains in flush()/tickTime() vi.useFakeTimers(); - Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible', @@ -84,7 +87,6 @@ function setVisibility(state: 'visible' | 'hidden') { document.dispatchEvent(new Event('visibilitychange')); } -// Drain pending microtasks — works with Vitest 2.1.x. async function flush() { await act(async () => { await Promise.resolve(); @@ -93,7 +95,6 @@ async function flush() { }); } -// Advance fake timers then drain microtasks so fetch callbacks settle. async function tickTime(ms: number) { await act(async () => { vi.advanceTimersByTime(ms); @@ -117,8 +118,6 @@ function deferredResponse(body: unknown) { }; } -// ── Tests ──────────────────────────────────────────────────────────────────── - describe('Scans — visibility-aware polling', () => { it('fires one fetch on mount', async () => { renderScans(); @@ -202,13 +201,13 @@ describe('Scans — visibility-aware polling', () => { setVisibility('hidden'); await tickTime(15_000); - expect(fetchSpy).toHaveBeenCalledTimes(1); // still paused + expect(fetchSpy).toHaveBeenCalledTimes(1); setVisibility('visible'); - await flush(); // immediate fetch on resume + await flush(); expect(fetchSpy).toHaveBeenCalledTimes(2); - await tickTime(5_000); // interval restarts + await tickTime(5_000); expect(fetchSpy).toHaveBeenCalledTimes(3); }); @@ -220,7 +219,6 @@ describe('Scans — visibility-aware polling', () => { await tickTime(5_000); await tickTime(5_000); await tickTime(5_000); - // 1 mount + 3 ticks = exactly 4 expect(fetchSpy).toHaveBeenCalledTimes(4); }); @@ -234,7 +232,6 @@ describe('Scans — visibility-aware polling', () => { unmount(); await tickTime(15_000); - // No extra fetches after unmount expect(fetchSpy).toHaveBeenCalledTimes(callsAfterMount); expect(removeSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); }); @@ -273,4 +270,4 @@ describe('Scans — visibility-aware polling', () => { expect(screen.getByText('Latest Tool')).toBeInTheDocument(); expect(screen.queryByText('Stale Tool')).not.toBeInTheDocument(); }); -}); +}); \ No newline at end of file From 68e04ea1f65ba518401c8e0b33264bdbcc7e8bf0 Mon Sep 17 00:00:00 2001 From: 25f1000058 <25f1000058@ds.study.iitm.ac.in> Date: Sun, 7 Jun 2026 22:43:31 +0530 Subject: [PATCH 2/5] fixed test files: mock toast context in scans and scan phases tests --- .../testing/unit/pages/ScanPhases.test.tsx | 193 +++++++++--------- 1 file changed, 99 insertions(+), 94 deletions(-) diff --git a/frontend/testing/unit/pages/ScanPhases.test.tsx b/frontend/testing/unit/pages/ScanPhases.test.tsx index cb3208463..e6c78932d 100644 --- a/frontend/testing/unit/pages/ScanPhases.test.tsx +++ b/frontend/testing/unit/pages/ScanPhases.test.tsx @@ -1,60 +1,56 @@ -import { render, act } from '@testing-library/react'; +import { render, act, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import TaskDetails from '../../../src/pages/TaskDetails'; -import * as api from '../../../src/api'; +import Scans from '../../../src/pages/Scans'; vi.mock('../../../src/api', () => ({ API_BASE: 'http://localhost', - getTaskStatus: vi.fn(), - getTaskResult: vi.fn(), - getPluginSchema: vi.fn().mockResolvedValue(null), - startTask: vi.fn(), + deleteTask: vi.fn(), + clearAllTasks: vi.fn(), + bulkDeleteTasks: vi.fn(), +})); + +vi.mock('../../../src/components/ToastContext', () => ({ + useToast: () => ({ + addToast: vi.fn(), + removeToast: vi.fn(), + }), + ToastProvider: ({ children }: any) => children, })); vi.mock('react-router-dom', async (importOriginal) => { const actual = await importOriginal(); - return { - ...actual, - useParams: () => ({ taskId: 'test-task-123' }), - useNavigate: () => vi.fn(), - }; + return { ...actual, useNavigate: () => vi.fn() }; }); -vi.mock('../../../src/components/ToastContext', () => ({ - useToast: () => ({ addToast: vi.fn() }), -})); +function makeTask(id: string, status: string, scan_phase?: string) { + return { + task_id: id, + plugin_id: 'nmap', + tool: 'Nmap', + target: '127.0.0.1', + status, + scan_phase: scan_phase || null, + created_at: '2026-01-01T00:00:00', + started_at: status === 'running' ? '2026-01-01T00:00:01' : null, + completed_at: null, + duration_seconds: null, + preset: null, + inputs: { target: '127.0.0.1' }, + }; +} -const RUNNING_TASK = { - task_id: 'test-task-123', - plugin_id: 'nmap', - tool: 'Nmap', - target: '127.0.0.1', - status: 'running', - scan_phase: 'running_command', - created_at: '2026-01-01T00:00:00', - started_at: '2026-01-01T00:00:01', +const RUNNING_WITH_PHASE_RESPONSE = { + tasks: [makeTask('task-1', 'running', 'running_command')], + pagination: { total_items: 1 }, }; -const RESULT_EMPTY = { - task_id: 'test-task-123', - plugin_id: 'nmap', - tool: 'Nmap', - target: '127.0.0.1', - timestamp: '2026-01-01T00:00:00', - status: 'running', - summary: [], - findings: [], - structured: {}, +const QUEUED_RESPONSE = { + tasks: [makeTask('task-2', 'queued')], + pagination: { total_items: 1 }, }; -function renderTaskDetails() { - return render( - - - , - ); -} +let fetchSpy: ReturnType; async function flush() { await act(async () => { @@ -64,85 +60,94 @@ async function flush() { }); } -describe('TaskDetails — scan phase display', () => { - let currentEventSource: any; +async function tickTime(ms: number) { + await act(async () => { + vi.advanceTimersByTime(ms); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} +function renderScans() { + return render( + + + , + ); +} + +describe('Scans — phase display', () => { beforeEach(() => { vi.useFakeTimers(); - currentEventSource = undefined; - // Mock EventSource before rendering - class MockEventSource { - url: string; - _onerror: any; - listeners: Record = {}; - constructor(url: string) { this.url = url; currentEventSource = this; } - addEventListener(event: string, handler: Function) { - if (!this.listeners[event]) this.listeners[event] = []; - this.listeners[event].push(handler); - } - set onerror(handler: any) { this._onerror = handler; } - close() {} - } - vi.stubGlobal('EventSource', MockEventSource); - - vi.mocked(api.getTaskStatus).mockResolvedValue(RUNNING_TASK); - vi.mocked(api.getTaskResult).mockResolvedValue(RESULT_EMPTY); + fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(RUNNING_WITH_PHASE_RESPONSE), + }); + vi.stubGlobal('fetch', fetchSpy); + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'visible', + }); }); afterEach(() => { + vi.runOnlyPendingTimers(); vi.useRealTimers(); vi.restoreAllMocks(); }); - it('shows loading screen with phase label while task is loading', async () => { - vi.mocked(api.getTaskStatus).mockImplementation(() => new Promise(() => {})); + function clickExpand() { + const card = screen.getByText('Nmap').closest('[class*="cursor-pointer"]') as HTMLElement; + act(() => { card.click(); }); + } - renderTaskDetails(); + it('shows phase for a running task in expanded details', async () => { + renderScans(); await flush(); - expect(document.body.textContent).toContain('DECRYPTING_BRIEFING'); - }); - - it('shows phase progress for running_command phase', async () => { - renderTaskDetails(); - await flush(); + clickExpand(); await flush(); - expect(document.body.textContent).toContain('Executing security scan'); + expect(screen.getByText(/RUNNING COMMAND/i)).toBeTruthy(); }); - it('shows phase progress when phase changes via SSE', async () => { - renderTaskDetails(); - await flush(); - await flush(); - - expect(currentEventSource).toBeTruthy(); - expect(currentEventSource.listeners['phase']).toBeTruthy(); - act(() => { - currentEventSource.listeners['phase'].forEach((fn: Function) => - fn({ data: JSON.stringify({ scan_phase: 'parsing' }) }), - ); + it('does not show phase for queued task', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(QUEUED_RESPONSE), }); + renderScans(); await flush(); - expect(document.body.textContent).toContain('Parsing scan results'); - }); + await tickTime(5000); - it('shows reporting phase label', async () => { - renderTaskDetails(); + clickExpand(); await flush(); + + expect(screen.queryByText(/PHASE/i)).toBeNull(); + }); + + it('updates phase when polling returns a running task with phase', async () => { + renderScans(); await flush(); - expect(currentEventSource).toBeTruthy(); - expect(currentEventSource.listeners['phase']).toBeTruthy(); - act(() => { - currentEventSource.listeners['phase'].forEach((fn: Function) => - fn({ data: JSON.stringify({ scan_phase: 'reporting' }) }), - ); + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + tasks: [makeTask('task-1', 'running', 'parsing')], + pagination: { total_items: 1 }, + }), }); + await tickTime(5000); + + clickExpand(); await flush(); - expect(document.body.textContent).toContain('Generating reports'); + + expect(screen.getByText(/PARSING/i)).toBeTruthy(); }); -}); +}); \ No newline at end of file From 30a6ef91155ee8d58673372a65757b2201129499 Mon Sep 17 00:00:00 2001 From: 25f1000058 <25f1000058@ds.study.iitm.ac.in> Date: Sun, 7 Jun 2026 23:07:08 +0530 Subject: [PATCH 3/5] fixed: mock ToastContext in ScansPhases tests with required wrappers --- .../testing/unit/pages/ScansPhases.test.tsx | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/frontend/testing/unit/pages/ScansPhases.test.tsx b/frontend/testing/unit/pages/ScansPhases.test.tsx index 1c1046ec5..89a04efb8 100644 --- a/frontend/testing/unit/pages/ScansPhases.test.tsx +++ b/frontend/testing/unit/pages/ScansPhases.test.tsx @@ -11,6 +11,14 @@ vi.mock('../../../src/api', () => ({ bulkDeleteTasks: vi.fn(), })); +vi.mock('../../../src/components/ToastContext', () => ({ + useToast: () => ({ + addToast: vi.fn(), + removeToast: vi.fn(), + }), + ToastProvider: ({ children }: any) => children, +})); + vi.mock('react-router-dom', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useNavigate: () => vi.fn() }; @@ -75,13 +83,11 @@ function renderScans() { describe('Scans — phase display', () => { beforeEach(() => { vi.useFakeTimers(); - fetchSpy = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(RUNNING_WITH_PHASE_RESPONSE), }); vi.stubGlobal('fetch', fetchSpy); - Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible', @@ -102,10 +108,8 @@ describe('Scans — phase display', () => { it('shows phase for a running task in expanded details', async () => { renderScans(); await flush(); - clickExpand(); await flush(); - expect(screen.getByText(/RUNNING COMMAND/i)).toBeTruthy(); }); @@ -114,21 +118,17 @@ describe('Scans — phase display', () => { ok: true, json: () => Promise.resolve(QUEUED_RESPONSE), }); - renderScans(); await flush(); await tickTime(5000); - clickExpand(); await flush(); - expect(screen.queryByText(/PHASE/i)).toBeNull(); }); it('updates phase when polling returns a running task with phase', async () => { renderScans(); await flush(); - fetchSpy.mockResolvedValueOnce({ ok: true, json: () => @@ -137,12 +137,9 @@ describe('Scans — phase display', () => { pagination: { total_items: 1 }, }), }); - await tickTime(5000); - clickExpand(); await flush(); - expect(screen.getByText(/PARSING/i)).toBeTruthy(); }); }); From 2fba33bef0b5a9229d767f61800c7608794d40b7 Mon Sep 17 00:00:00 2001 From: 25f1000058 <25f1000058@ds.study.iitm.ac.in> Date: Sat, 13 Jun 2026 10:25:14 +0530 Subject: [PATCH 4/5] fix(frontend): replace alert() failures with toasts in scans --- .../testing/unit/pages/ScanPhases.test.tsx | 193 +++++++++--------- .../testing/unit/pages/Scans.polling.test.tsx | 32 +-- .../testing/unit/pages/ScansPhases.test.tsx | 24 ++- 3 files changed, 130 insertions(+), 119 deletions(-) diff --git a/frontend/testing/unit/pages/ScanPhases.test.tsx b/frontend/testing/unit/pages/ScanPhases.test.tsx index e6c78932d..cb3208463 100644 --- a/frontend/testing/unit/pages/ScanPhases.test.tsx +++ b/frontend/testing/unit/pages/ScanPhases.test.tsx @@ -1,56 +1,60 @@ -import { render, act, screen } from '@testing-library/react'; +import { render, act } 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 TaskDetails from '../../../src/pages/TaskDetails'; +import * as api from '../../../src/api'; vi.mock('../../../src/api', () => ({ API_BASE: 'http://localhost', - deleteTask: vi.fn(), - clearAllTasks: vi.fn(), - bulkDeleteTasks: vi.fn(), -})); - -vi.mock('../../../src/components/ToastContext', () => ({ - useToast: () => ({ - addToast: vi.fn(), - removeToast: vi.fn(), - }), - ToastProvider: ({ children }: any) => children, + getTaskStatus: vi.fn(), + getTaskResult: vi.fn(), + getPluginSchema: vi.fn().mockResolvedValue(null), + startTask: vi.fn(), })); vi.mock('react-router-dom', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useNavigate: () => vi.fn() }; -}); - -function makeTask(id: string, status: string, scan_phase?: string) { return { - task_id: id, - plugin_id: 'nmap', - tool: 'Nmap', - target: '127.0.0.1', - status, - scan_phase: scan_phase || null, - created_at: '2026-01-01T00:00:00', - started_at: status === 'running' ? '2026-01-01T00:00:01' : null, - completed_at: null, - duration_seconds: null, - preset: null, - inputs: { target: '127.0.0.1' }, + ...actual, + useParams: () => ({ taskId: 'test-task-123' }), + useNavigate: () => vi.fn(), }; -} +}); -const RUNNING_WITH_PHASE_RESPONSE = { - tasks: [makeTask('task-1', 'running', 'running_command')], - pagination: { total_items: 1 }, +vi.mock('../../../src/components/ToastContext', () => ({ + useToast: () => ({ addToast: vi.fn() }), +})); + +const RUNNING_TASK = { + task_id: 'test-task-123', + plugin_id: 'nmap', + tool: 'Nmap', + target: '127.0.0.1', + status: 'running', + scan_phase: 'running_command', + created_at: '2026-01-01T00:00:00', + started_at: '2026-01-01T00:00:01', }; -const QUEUED_RESPONSE = { - tasks: [makeTask('task-2', 'queued')], - pagination: { total_items: 1 }, +const RESULT_EMPTY = { + task_id: 'test-task-123', + plugin_id: 'nmap', + tool: 'Nmap', + target: '127.0.0.1', + timestamp: '2026-01-01T00:00:00', + status: 'running', + summary: [], + findings: [], + structured: {}, }; -let fetchSpy: ReturnType; +function renderTaskDetails() { + return render( + + + , + ); +} async function flush() { await act(async () => { @@ -60,94 +64,85 @@ async function flush() { }); } -async function tickTime(ms: number) { - await act(async () => { - vi.advanceTimersByTime(ms); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - }); -} +describe('TaskDetails — scan phase display', () => { + let currentEventSource: any; -function renderScans() { - return render( - - - , - ); -} - -describe('Scans — phase display', () => { beforeEach(() => { vi.useFakeTimers(); - fetchSpy = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(RUNNING_WITH_PHASE_RESPONSE), - }); - vi.stubGlobal('fetch', fetchSpy); - - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: () => 'visible', - }); + currentEventSource = undefined; + // Mock EventSource before rendering + class MockEventSource { + url: string; + _onerror: any; + listeners: Record = {}; + constructor(url: string) { this.url = url; currentEventSource = this; } + addEventListener(event: string, handler: Function) { + if (!this.listeners[event]) this.listeners[event] = []; + this.listeners[event].push(handler); + } + set onerror(handler: any) { this._onerror = handler; } + close() {} + } + vi.stubGlobal('EventSource', MockEventSource); + + vi.mocked(api.getTaskStatus).mockResolvedValue(RUNNING_TASK); + vi.mocked(api.getTaskResult).mockResolvedValue(RESULT_EMPTY); }); afterEach(() => { - vi.runOnlyPendingTimers(); vi.useRealTimers(); vi.restoreAllMocks(); }); - function clickExpand() { - const card = screen.getByText('Nmap').closest('[class*="cursor-pointer"]') as HTMLElement; - act(() => { card.click(); }); - } + it('shows loading screen with phase label while task is loading', async () => { + vi.mocked(api.getTaskStatus).mockImplementation(() => new Promise(() => {})); - it('shows phase for a running task in expanded details', async () => { - renderScans(); + renderTaskDetails(); await flush(); - clickExpand(); - await flush(); - - expect(screen.getByText(/RUNNING COMMAND/i)).toBeTruthy(); + expect(document.body.textContent).toContain('DECRYPTING_BRIEFING'); }); - it('does not show phase for queued task', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(QUEUED_RESPONSE), - }); - - renderScans(); + it('shows phase progress for running_command phase', async () => { + renderTaskDetails(); await flush(); - await tickTime(5000); - - clickExpand(); await flush(); - expect(screen.queryByText(/PHASE/i)).toBeNull(); + expect(document.body.textContent).toContain('Executing security scan'); }); - it('updates phase when polling returns a running task with phase', async () => { - renderScans(); + it('shows phase progress when phase changes via SSE', async () => { + renderTaskDetails(); + await flush(); await flush(); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - tasks: [makeTask('task-1', 'running', 'parsing')], - pagination: { total_items: 1 }, - }), + expect(currentEventSource).toBeTruthy(); + expect(currentEventSource.listeners['phase']).toBeTruthy(); + act(() => { + currentEventSource.listeners['phase'].forEach((fn: Function) => + fn({ data: JSON.stringify({ scan_phase: 'parsing' }) }), + ); }); - await tickTime(5000); + await flush(); + expect(document.body.textContent).toContain('Parsing scan results'); + }); - clickExpand(); + it('shows reporting phase label', async () => { + renderTaskDetails(); await flush(); + await flush(); + + expect(currentEventSource).toBeTruthy(); + expect(currentEventSource.listeners['phase']).toBeTruthy(); + act(() => { + currentEventSource.listeners['phase'].forEach((fn: Function) => + fn({ data: JSON.stringify({ scan_phase: 'reporting' }) }), + ); + }); - expect(screen.getByText(/PARSING/i)).toBeTruthy(); + await flush(); + expect(document.body.textContent).toContain('Generating reports'); }); -}); \ No newline at end of file +}); diff --git a/frontend/testing/unit/pages/Scans.polling.test.tsx b/frontend/testing/unit/pages/Scans.polling.test.tsx index 9a4f30f0c..b63e59892 100644 --- a/frontend/testing/unit/pages/Scans.polling.test.tsx +++ b/frontend/testing/unit/pages/Scans.polling.test.tsx @@ -4,6 +4,13 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import Scans from '../../../src/pages/Scans'; import { ToastProvider } from '../../../src/components/ToastContext' +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../../src/components/ToastContext', () => ({ + useToast: () => ({ addToast: vi.fn(), removeToast: vi.fn() }), + ToastProvider: ({ children }: any) => children, +})); + vi.mock('../../../src/api', () => ({ API_BASE: 'http://localhost', deleteTask: vi.fn(), @@ -11,14 +18,6 @@ vi.mock('../../../src/api', () => ({ bulkDeleteTasks: vi.fn(), })); -vi.mock('../../../src/components/ToastContext', () => ({ - useToast: () => ({ - addToast: vi.fn(), - removeToast: vi.fn(), - }), - ToastProvider: ({ children }: any) => children, -})); - vi.mock('react-router-dom', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useNavigate: () => vi.fn() }; @@ -56,7 +55,10 @@ beforeEach(() => { json: () => Promise.resolve(EMPTY_RESPONSE), }); vi.stubGlobal('fetch', fetchSpy); + + // Use fake timers; microtasks drained via Promise.resolve() chains in flush()/tickTime() vi.useFakeTimers(); + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible', @@ -87,6 +89,7 @@ function setVisibility(state: 'visible' | 'hidden') { document.dispatchEvent(new Event('visibilitychange')); } +// Drain pending microtasks — works with Vitest 2.1.x. async function flush() { await act(async () => { await Promise.resolve(); @@ -95,6 +98,7 @@ async function flush() { }); } +// Advance fake timers then drain microtasks so fetch callbacks settle. async function tickTime(ms: number) { await act(async () => { vi.advanceTimersByTime(ms); @@ -118,6 +122,8 @@ function deferredResponse(body: unknown) { }; } +// ── Tests ──────────────────────────────────────────────────────────────────── + describe('Scans — visibility-aware polling', () => { it('fires one fetch on mount', async () => { renderScans(); @@ -201,13 +207,13 @@ describe('Scans — visibility-aware polling', () => { setVisibility('hidden'); await tickTime(15_000); - expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledTimes(1); // still paused setVisibility('visible'); - await flush(); + await flush(); // immediate fetch on resume expect(fetchSpy).toHaveBeenCalledTimes(2); - await tickTime(5_000); + await tickTime(5_000); // interval restarts expect(fetchSpy).toHaveBeenCalledTimes(3); }); @@ -219,6 +225,7 @@ describe('Scans — visibility-aware polling', () => { await tickTime(5_000); await tickTime(5_000); await tickTime(5_000); + // 1 mount + 3 ticks = exactly 4 expect(fetchSpy).toHaveBeenCalledTimes(4); }); @@ -232,6 +239,7 @@ describe('Scans — visibility-aware polling', () => { unmount(); await tickTime(15_000); + // No extra fetches after unmount expect(fetchSpy).toHaveBeenCalledTimes(callsAfterMount); expect(removeSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); }); @@ -270,4 +278,4 @@ describe('Scans — visibility-aware polling', () => { expect(screen.getByText('Latest Tool')).toBeInTheDocument(); expect(screen.queryByText('Stale Tool')).not.toBeInTheDocument(); }); -}); \ No newline at end of file +}); diff --git a/frontend/testing/unit/pages/ScansPhases.test.tsx b/frontend/testing/unit/pages/ScansPhases.test.tsx index 89a04efb8..8bf970e00 100644 --- a/frontend/testing/unit/pages/ScansPhases.test.tsx +++ b/frontend/testing/unit/pages/ScansPhases.test.tsx @@ -4,6 +4,11 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import Scans from '../../../src/pages/Scans'; import { ToastProvider } from '../../../src/components/ToastContext' +vi.mock('../../../src/components/ToastContext', () => ({ + useToast: () => ({ addToast: vi.fn(), removeToast: vi.fn() }), + ToastProvider: ({ children }: any) => children, +})) + vi.mock('../../../src/api', () => ({ API_BASE: 'http://localhost', deleteTask: vi.fn(), @@ -11,14 +16,6 @@ vi.mock('../../../src/api', () => ({ bulkDeleteTasks: vi.fn(), })); -vi.mock('../../../src/components/ToastContext', () => ({ - useToast: () => ({ - addToast: vi.fn(), - removeToast: vi.fn(), - }), - ToastProvider: ({ children }: any) => children, -})); - vi.mock('react-router-dom', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useNavigate: () => vi.fn() }; @@ -83,11 +80,13 @@ function renderScans() { describe('Scans — phase display', () => { beforeEach(() => { vi.useFakeTimers(); + fetchSpy = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(RUNNING_WITH_PHASE_RESPONSE), }); vi.stubGlobal('fetch', fetchSpy); + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible', @@ -108,8 +107,10 @@ describe('Scans — phase display', () => { it('shows phase for a running task in expanded details', async () => { renderScans(); await flush(); + clickExpand(); await flush(); + expect(screen.getByText(/RUNNING COMMAND/i)).toBeTruthy(); }); @@ -118,17 +119,21 @@ describe('Scans — phase display', () => { ok: true, json: () => Promise.resolve(QUEUED_RESPONSE), }); + renderScans(); await flush(); await tickTime(5000); + clickExpand(); await flush(); + expect(screen.queryByText(/PHASE/i)).toBeNull(); }); it('updates phase when polling returns a running task with phase', async () => { renderScans(); await flush(); + fetchSpy.mockResolvedValueOnce({ ok: true, json: () => @@ -137,9 +142,12 @@ describe('Scans — phase display', () => { pagination: { total_items: 1 }, }), }); + await tickTime(5000); + clickExpand(); await flush(); + expect(screen.getByText(/PARSING/i)).toBeTruthy(); }); }); From 96fae059ae42932ab7fc0e9a4efcde14f18e97a5 Mon Sep 17 00:00:00 2001 From: Pranav Karthik Date: Thu, 9 Jul 2026 11:13:06 +0530 Subject: [PATCH 5/5] fix(frontend): remove duplicate useToast import in Scans.tsx --- frontend/src/pages/Scans.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/pages/Scans.tsx b/frontend/src/pages/Scans.tsx index 0f948f7cb..f8850f2d7 100644 --- a/frontend/src/pages/Scans.tsx +++ b/frontend/src/pages/Scans.tsx @@ -10,7 +10,6 @@ import { } from "../utils/date"; import { ConfirmModal } from "../components/ConfirmModal"; import { useToast } from "../components/ToastContext"; -import { useToast } from "../components/ToastContext"; import Pagination from "../components/Pagination"; interface Task { @@ -61,7 +60,6 @@ const itemVariants: Variants = { export default function Scans() { const navigate = useNavigate(); const { addToast } = useToast(); - const { addToast } = useToast(); const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); const [filter, setFilter] = useState("all");