diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a1b1372a..49ba9a6d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "secuscan-frontend", "version": "0.1.0", "dependencies": { + "@tanstack/react-virtual": "^3.14.2", "framer-motion": "^12.38.0", "html2canvas": "^1.4.1", "jspdf": "^4.2.1", @@ -1585,6 +1586,33 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", + "integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", + "integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 036794f1..2f2060a2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "e2e:ui": "playwright test --ui" }, "dependencies": { + "@tanstack/react-virtual": "^3.14.2", "framer-motion": "^12.38.0", "html2canvas": "^1.4.1", "jspdf": "^4.2.1", diff --git a/frontend/src/hooks/useVirtualList.ts b/frontend/src/hooks/useVirtualList.ts new file mode 100644 index 00000000..b11c1fe8 --- /dev/null +++ b/frontend/src/hooks/useVirtualList.ts @@ -0,0 +1,50 @@ +import { useRef } from 'react' +import { useVirtualizer, type VirtualizerOptions } from '@tanstack/react-virtual' + +export interface UseVirtualListOptions + extends Partial< + Omit< + VirtualizerOptions, + 'count' | 'getScrollElement' | 'estimateSize' + > + > { + items: T[] + estimateSize: number | ((index: number) => number) + overscan?: number +} + +/** + * Thin wrapper around @tanstack/react-virtual's useVirtualizer. + * + * Returns: + * - `parentRef` — attach to the scrollable container div + * - `virtualizer` — the virtualizer instance + * - `virtualItems` — the currently visible virtual items + * - `totalSize` — total scrollable height in px + */ +export function useVirtualList({ + items, + estimateSize, + overscan = 5, + ...rest +}: UseVirtualListOptions) { + const parentRef = useRef(null) + + const estimateFn = + typeof estimateSize === 'number' ? () => estimateSize : estimateSize + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: estimateFn, + overscan, + ...rest, + }) + + return { + parentRef, + virtualizer, + virtualItems: virtualizer.getVirtualItems(), + totalSize: virtualizer.getTotalSize(), + } +} \ No newline at end of file diff --git a/frontend/src/pages/Findings.tsx b/frontend/src/pages/Findings.tsx index 74e7e811..ea229aef 100644 --- a/frontend/src/pages/Findings.tsx +++ b/frontend/src/pages/Findings.tsx @@ -1,5 +1,6 @@ -import React, { useEffect, useMemo, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' import { motion } from 'framer-motion' +import { useVirtualizer } from '@tanstack/react-virtual' import { getFindings } from '../api' import { formatLocaleDate } from '../utils/date' @@ -84,6 +85,18 @@ function filterPillClasses(isActive: boolean) { : 'border-silver-bright/10 bg-charcoal-dark text-silver/65 hover:border-silver-bright/30 hover:text-silver-bright' } +// ─── Virtual row types ──────────────────────────────────────────────────────── + +type HeaderRow = { kind: 'header'; severity: string; count: number } +type FindingRow = { kind: 'finding'; finding: Finding & { status: FindingStatus }; isLastInGroup: boolean } +type VirtualRow = HeaderRow | FindingRow + +// Estimated heights for virtualizer +const ROW_HEIGHTS: Record = { + header: 72, + finding: 140, +} + export default function Findings() { const [findings, setFindings] = useState([]) const [loading, setLoading] = useState(true) @@ -124,7 +137,7 @@ export default function Findings() { findings.map((finding) => ({ ...finding, severity: normalizeSeverity(finding.severity), - status: reviewState[finding.id] || 'new', + status: reviewState[finding.id] || ('new' as FindingStatus), })), [findings, reviewState], ) @@ -150,30 +163,23 @@ export default function Findings() { }) }, [enrichedFindings, filterSeverity, searchQuery]) - const groupedFindings = useMemo( - () => - severityOrder.map((severity) => ({ - severity, - items: filteredFindings.filter((finding) => finding.severity === severity), - })), - [filteredFindings], - ) - - const selectedFinding = - filteredFindings.find((finding) => finding.id === selectedFindingId) ?? - filteredFindings[0] ?? - null - - useEffect(() => { - if (!selectedFinding) { - setSelectedFindingId(null) - return - } - - if (!filteredFindings.some((finding) => finding.id === selectedFinding.id)) { - setSelectedFindingId(filteredFindings[0]?.id ?? null) + // Build the flat virtual row list: header + findings per severity group + const virtualRows = useMemo(() => { + const rows: VirtualRow[] = [] + for (const severity of severityOrder) { + const items = filteredFindings.filter((f) => f.severity === severity) + if (items.length === 0) continue + rows.push({ kind: 'header', severity, count: items.length }) + items.forEach((finding, idx) => { + rows.push({ + kind: 'finding', + finding, + isLastInGroup: idx === items.length - 1, + }) + }) } - }, [filteredFindings, selectedFinding]) + return rows + }, [filteredFindings]) const countsBySeverity = useMemo(() => { return severityOrder.reduce>((acc, severity) => { @@ -192,6 +198,21 @@ export default function Findings() { [enrichedFindings, filteredFindings, countsBySeverity], ) + const selectedFinding = + filteredFindings.find((finding) => finding.id === selectedFindingId) ?? + filteredFindings[0] ?? + null + + useEffect(() => { + if (!selectedFinding) { + setSelectedFindingId(null) + return + } + if (!filteredFindings.some((finding) => finding.id === selectedFinding.id)) { + setSelectedFindingId(filteredFindings[0]?.id ?? null) + } + }, [filteredFindings, selectedFinding]) + function updateFindingStatus(id: string, status: FindingStatus) { setReviewState((current) => ({ ...current, [id]: status })) } @@ -219,9 +240,51 @@ export default function Findings() { } } + // ─── Keyboard navigation ──────────────────────────────────────────────────── + const listRef = useRef(null) + + function handleListKeyDown(e: React.KeyboardEvent) { + if (!filteredFindings.length) return + const currentIdx = selectedFinding + ? filteredFindings.findIndex((f) => f.id === selectedFinding.id) + : -1 + + if (e.key === 'ArrowDown') { + e.preventDefault() + const next = filteredFindings[Math.min(currentIdx + 1, filteredFindings.length - 1)] + if (next) setSelectedFindingId(next.id) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + const prev = filteredFindings[Math.max(currentIdx - 1, 0)] + if (prev) setSelectedFindingId(prev.id) + } + } + + // ─── Virtualizer ──────────────────────────────────────────────────────────── + const parentRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: virtualRows.length, + getScrollElement: () => parentRef.current, + estimateSize: (index) => ROW_HEIGHTS[virtualRows[index]?.kind ?? 'finding'], + overscan: 6, + }) + + // Scroll selected finding into view when it changes + useEffect(() => { + if (!selectedFinding) return + const rowIdx = virtualRows.findIndex( + (row) => row.kind === 'finding' && row.finding.id === selectedFinding.id, + ) + if (rowIdx !== -1) { + virtualizer.scrollToIndex(rowIdx, { align: 'auto', behavior: 'smooth' }) + } + }, [selectedFindingId]) // eslint-disable-line react-hooks/exhaustive-deps + return (
+ {/* Header */}
Triage Workspace v5.1 @@ -255,6 +318,7 @@ export default function Findings() {
+ {/* Filter Bar */}
@@ -312,8 +376,10 @@ export default function Findings() {
+ {/* Main Split Layout */}
- + {/* ── Virtualized Findings List ── */} + {loading ? (

Synchronizing findings feed...

@@ -324,94 +390,128 @@ export default function Findings() {

Adjust filters to reopen the queue.

) : ( - groupedFindings.map(({ severity, items }) => { - if (items.length === 0) return null - - const config = severityConfig[severity] - - return ( -
-
-
- -
-

{config.label}

-

{items.length} visible in queue

-
-
-
- -
- {items.map((finding) => { - const isSelected = selectedFinding?.id === finding.id - const config = severityConfig[finding.severity] - - return ( -
- - ) - })} -
-
- ) - }) +
+ {typeof finding.cvss === 'number' ? ( +
+

CVSS

+

= 9 ? 'text-rag-red' : 'text-silver-bright'}`}> + {finding.cvss.toFixed(1)} +

+
+ ) : null} + + + east + +
+
+ + ) + })() + )} +
+ ) + })} + + )} + {/* ── Detail Panel (unchanged) ── */}
{selectedFinding ? ( @@ -526,4 +626,4 @@ export default function Findings() {
) -} +} \ No newline at end of file diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index d789018d..1f35bf05 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -1,6 +1,7 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useRef, useState, useMemo } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { useNavigate } from 'react-router-dom' +import { useVirtualizer } from '@tanstack/react-virtual' import { getDashboardSummary, getReports, API_BASE } from '../api' import { formatDateLong } from '../utils/date' @@ -16,23 +17,10 @@ type Report = { pages: number } -const containerVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { staggerChildren: 0.05 } - } -} +// Pair reports into rows of 2 for the 2-col grid +type ReportRow = [Report, Report | null] -const itemVariants = { - hidden: { opacity: 0, scale: 0.95, y: 20 }, - visible: { - opacity: 1, - scale: 1, - y: 0, - transition: { duration: 0.4 } - } -} +const CARD_HEIGHT = 380 // px — estimated height per grid row export default function Reports() { const navigate = useNavigate() @@ -53,9 +41,28 @@ export default function Reports() { const filteredReports = reports.filter((report) => selectedType === 'all' || report.type === selectedType) + // Chunk filtered reports into rows of 2 for the virtual grid + const reportRows = useMemo(() => { + const rows: ReportRow[] = [] + for (let i = 0; i < filteredReports.length; i += 2) { + rows.push([filteredReports[i], filteredReports[i + 1] ?? null]) + } + return rows + }, [filteredReports]) + + // ─── Virtualizer ──────────────────────────────────────────────────────────── + const parentRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: reportRows.length, + getScrollElement: () => parentRef.current, + estimateSize: () => CARD_HEIGHT, + overscan: 3, + }) + return (
- + {/* Neo-Brutalist Header */}
@@ -70,199 +77,240 @@ export default function Reports() {

-
- - -
+
+ + +
{/* Metrics Row */}
- {[ - { label: 'Archived_Briefings', val: reports.length, color: 'bg-rag-blue', unit: 'FILES' }, - { label: 'Surface_Nodes', val: summary.total_assets || 0, color: 'bg-rag-green', unit: 'NODES' }, - { label: 'Aggregate_Anomalies', val: summary.total_findings || 0, color: 'bg-rag-red', unit: 'TRIGGERS' }, - { label: 'Archive_Volume', val: '12.4', color: 'bg-rag-amber', unit: 'GB' }, - ].map((m, i) => ( -
-
- {m.label} - folder_zip -
-
- {m.val} - {m.unit} -
-
- ))} + {[ + { label: 'Archived_Briefings', val: reports.length, color: 'bg-rag-blue', unit: 'FILES' }, + { label: 'Surface_Nodes', val: summary.total_assets || 0, color: 'bg-rag-green', unit: 'NODES' }, + { label: 'Aggregate_Anomalies', val: summary.total_findings || 0, color: 'bg-rag-red', unit: 'TRIGGERS' }, + { label: 'Archive_Volume', val: '12.4', color: 'bg-rag-amber', unit: 'GB' }, + ].map((m, i) => ( +
+
+ {m.label} + folder_zip +
+
+ {m.val} + {m.unit} +
+
+ ))}
- {/* Filtration Sidebar */} - + + {/* Virtualized Ledger Section */} +
+
+

Historical_Ledger

+
+ {filteredReports.length} ENTRIES_LOCATED +
- {/* Ledger Section */} -
-
-

Historical_Ledger

-
- {filteredReports.length} ENTRIES_LOCATED + {filteredReports.length === 0 ? ( +
+ folder_off +
+

Archive Isolated

+

System buffer awaiting briefing generation protocols

+
+ ) : ( + /* Scrollable virtual window */ +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const [left, right] = reportRows[virtualItem.index] - - - {filteredReports.map((report) => ( - +
+ {[left, right].map((report, colIdx) => + report ? ( + - {/* Status Top Bar */} -
+ report={report} + onNavigate={() => navigate(`/task/${report.task_id}`)} + onDownload={() => window.open(`${API_BASE}/task/${report.task_id}/report/pdf`, '_blank')} + /> + ) : ( + // Empty cell to keep grid alignment +
+ ) + )} +
+
+ ) + })} +
+
+ )} +
+
+ + {/* Tactical Footer */} +
+
+
+ RESTRICTED_ACCESS_ENCLAVE // SYSTEM_ARCHIVE_DAEMON // {new Date().getFullYear()} +
+
+ {[1, 2, 3, 4, 5, 6, 7, 8].map(i =>
)} +
+
+
+ ) +} -
-
- - {report.type}_TYPE - - description -
- -
-

- {report.name} -

-
-
+// ─── Extracted ReportCard to keep virtualizer render lean ────────────────── -
-
- Findings - {report.findings.toString().padStart(3, '0')} -
-
- Assets - {report.assets.toString().padStart(3, '0')} -
-
- Pages - {report.pages.toString().padStart(3, '0')} -
-
- -
-
-

TIMESTAMP

-

{formatDateLong(report.generated_at)}

-
-
- - -
-
-
+interface ReportCardProps { + report: Report + onNavigate: () => void + onDownload: () => void +} - {/* Background Hover Icon */} -
- - {report.type === 'executive' ? 'leaderboard' : report.type === 'compliance' ? 'verified_user' : 'terminal'} - -
- - ))} +function ReportCard({ report, onNavigate, onDownload }: ReportCardProps) { + return ( + + {/* Status Top Bar */} +
- {reports.length === 0 && ( -
- folder_off -
-

Archive Isolated

-

System buffer awaiting briefing generation protocols

-
-
- )} -
- - - +
+
+ + {report.type}_TYPE + + description +
- {/* Tactical Footer */} -
-
-
- RESTRICTED_ACCESS_ENCLAVE // SYSTEM_ARCHIVE_DAEMON // {new Date().getFullYear()} +
+

+ {report.name} +

+
+
+ +
+
+ Findings + {report.findings.toString().padStart(3, '0')} +
+
+ Assets + {report.assets.toString().padStart(3, '0')} +
+
+ Pages + {report.pages.toString().padStart(3, '0')} +
+
+ +
+
+

TIMESTAMP

+

{formatDateLong(report.generated_at)}

- {[1,2,3,4,5,6,7,8].map(i =>
)} + +
-
-
+ + + + {/* Background Hover Icon */} +
+ + {report.type === 'executive' ? 'leaderboard' : report.type === 'compliance' ? 'verified_user' : 'terminal'} + +
+ ) -} +} \ No newline at end of file diff --git a/frontend/src/pages/Scans.tsx b/frontend/src/pages/Scans.tsx index 6328a061..fbdcb90b 100644 --- a/frontend/src/pages/Scans.tsx +++ b/frontend/src/pages/Scans.tsx @@ -1,6 +1,7 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useRef, useCallback } from 'react' import { useNavigate } from 'react-router-dom' import { motion, AnimatePresence } from 'framer-motion' +import { useVirtualizer } from '@tanstack/react-virtual' import { API_BASE, deleteTask, clearAllTasks, bulkDeleteTasks } from '../api' import { routePath } from '../routes' import { parseDateSafe, formatLocaleDate, formatLocaleTime } from '../utils/date' @@ -27,24 +28,6 @@ const statusFilters = [ { value: 'cancelled', label: 'MANUAL_ABORT' } ] -const containerVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { staggerChildren: 0.1 } - } -} as const - -const itemVariants = { - hidden: { opacity: 0, scale: 0.95, y: 20 }, - visible: { - opacity: 1, - scale: 1, - y: 0, - transition: { type: 'spring', stiffness: 200, damping: 20 } as any - } -} as const - export default function Scans() { const navigate = useNavigate() const [tasks, setTasks] = useState([]) @@ -100,7 +83,6 @@ export default function Scans() { if (!window.confirm('Are you sure you want to delete this scan record? This will also remove associated findings and reports.')) { return } - try { await deleteTask(taskId) setTasks(prev => prev.filter(t => t.task_id !== taskId)) @@ -115,7 +97,6 @@ export default function Scans() { if (!window.confirm('CRITICAL: Are you sure you want to PURGE ALL RECORDS? This will wipe all scan history, findings, assets, and reports. This action is irreversible.')) { return } - try { await clearAllTasks() setTasks([]) @@ -132,7 +113,6 @@ export default function Scans() { if (!window.confirm(`Are you sure you want to delete ${selectedIds.length} selected scan records?`)) { return } - try { await bulkDeleteTasks(selectedIds) setTasks(prev => prev.filter(t => !selectedIds.includes(t.task_id))) @@ -145,10 +125,10 @@ export default function Scans() { function toggleSelection(taskId: string, e: React.MouseEvent) { e.stopPropagation() - setSelectedIds(prev => - prev.includes(taskId) - ? prev.filter(id => id !== taskId) - : [...prev, taskId] + setSelectedIds(prev => + prev.includes(taskId) + ? prev.filter(id => id !== taskId) + : [...prev, taskId] ) } @@ -167,22 +147,43 @@ export default function Scans() { return `${Math.round(seconds / 3600)}h` } + // ─── Virtualizer ──────────────────────────────────────────────────────────── + // We use a windowed scrollable container rather than window-scroll to keep the + // timeline cable and sticky header in place. + const parentRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: tasks.length, + getScrollElement: () => parentRef.current, + // Collapsed cards ~160px; expanded adds ~300px. measureElement handles truth. + estimateSize: useCallback( + (index: number) => tasks[index]?.task_id === expandedId ? 460 : 160, + [tasks, expandedId] + ), + overscan: 4, + }) + + // Re-measure all items when expandedId changes (height changes) + useEffect(() => { + virtualizer.measure() + }, [expandedId]) // eslint-disable-line react-hooks/exhaustive-deps + return (
- + {/* Neo-Brutalist Header */}
-
- Operational_Registry_v10.1 -
-

- Operational Registry -

-

- Total_Registry_Keys: {tasks.length} // SYSTEM_STATUS: {loading ? 'SYNCING...' : 'SYNCED'} - -

+
+ Operational_Registry_v10.1 +
+

+ Operational Registry +

+

+ Total_Registry_Keys: {tasks.length} // SYSTEM_STATUS: {loading ? 'SYNCING...' : 'SYNCED'} + +

@@ -198,11 +199,10 @@ export default function Scans() {
)}
- Isolation_Protocol_Active // v4_stable + Isolation_Protocol_Active // v4_stable
- {/* Timeline Operations Feed */} + {/* ── Timeline Operations Feed (Virtualized) ── */}
- {/* Vertical Timeline Cable */} -
- - - {tasks.length > 0 ? ( - - {tasks.map((task) => { - const createDate = parseDateSafe(task.created_at); - const startDate = task.started_at ? parseDateSafe(task.started_at) : null; - const endDate = task.completed_at ? parseDateSafe(task.completed_at) : null; + {/* Vertical Timeline Cable — decorative, stays fixed alongside the scroller */} +
+ + {loading ? ( +
+ hourglass_empty +

Syncing Registry...

+
+ ) : tasks.length === 0 ? ( +
+ inventory_2 +
+

Archive Isolated

+

No historical signal streams available for current selection

+
+
+ ) : ( + /* Scrollable virtual window */ +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const task = tasks[virtualItem.index] + if (!task) return null + + const createDate = parseDateSafe(task.created_at) + const startDate = task.started_at ? parseDateSafe(task.started_at) : null + const endDate = task.completed_at ? parseDateSafe(task.completed_at) : null + const isExpanded = expandedId === task.task_id return ( - - {/* Timeline Node */} - - -
setExpandedId(expandedId === task.task_id ? null : task.task_id)} - > -
-
-
-
toggleSelection(task.task_id, e)} - className={`w-10 h-10 border-4 border-black flex items-center justify-center transition-all ${ - selectedIds.includes(task.task_id) - ? 'bg-rag-blue text-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] -translate-x-1 -translate-y-1' - : 'bg-charcoal-dark text-silver/10 hover:border-rag-blue/40' - }`} - > - - {selectedIds.includes(task.task_id) ? 'check' : 'add'} +
+ {/* Timeline Node */} + + +
setExpandedId(isExpanded ? null : task.task_id)} + > +
+
+
+
toggleSelection(task.task_id, e)} + role="checkbox" + aria-checked={selectedIds.includes(task.task_id)} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault() + toggleSelection(task.task_id, e as any) + } + }} + className={`w-10 h-10 border-4 border-black flex items-center justify-center transition-all cursor-pointer ${selectedIds.includes(task.task_id) + ? 'bg-rag-blue text-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] -translate-x-1 -translate-y-1' + : 'bg-charcoal-dark text-silver/10 hover:border-rag-blue/40' + }`} + > + + {selectedIds.includes(task.task_id) ? 'check' : 'add'} + +
+ + {task.status} + + + OP_ID_{task.task_id.split('-')[0].toUpperCase()}
- - {task.status} - - - OP_ID_{task.task_id.split('-')[0].toUpperCase()} - -
-
-

- {task.tool} -

-

- target - {task.target} -

+
+

+ {task.tool} +

+

+ target + {task.target} +

+
-
-
-
-

Historical_Execution

-

- {formatLocaleDate(createDate)} // {formatLocaleTime(createDate)} -

-
- {task.duration_seconds && ( -
-

{formatDuration(task.duration_seconds)?.toUpperCase()}

+
+
+

Historical_Execution

+

+ {formatLocaleDate(createDate)} // {formatLocaleTime(createDate)} +

- )} + {task.duration_seconds && ( +
+

{formatDuration(task.duration_seconds)?.toUpperCase()}

+
+ )} +
-
- {/* Expandable Details Block */} - - {expandedId === task.task_id && ( - -
-
-
- Signal_Metadata -
-
-

PLUGIN: {task.plugin_id}

-

SESSION: ENCRYPTED_VTX

+ {/* Expandable Details Block */} + + {isExpanded && ( + +
+
+
+ Target_Packet +
+
+

TARGET: {task.target}

+

TOOL: {task.tool}

+ {task.preset && ( +

PRESET: {task.preset}

+ )} +
-
-
-
- Time_Matrix -
-
-
- In_Lock - {startDate ? formatLocaleTime(startDate) : 'PENDING'} +
+
+ Signal_Metadata +
+
+

PLUGIN: {task.plugin_id}

+

SESSION: ENCRYPTED_VTX

-
- Release - {endDate ? formatLocaleTime(endDate) : 'N/A'} +
+ +
+
+ Time_Matrix +
+
+
+ In_Lock + {startDate ? formatLocaleTime(startDate) : 'PENDING'} +
+
+ Release + {endDate ? formatLocaleTime(endDate) : 'N/A'} +
-
-
- {(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && ( - - )} - {(task.status === 'completed' || task.status === 'failed') && ( - + )} + {(task.status === 'completed' || task.status === 'failed') && ( + + )} + - )} - +
-
- - )} - + + )} + +
- - ); +
+ ) })} - - ) : ( -
- inventory_2 -
-

Archive Isolated

-

No historical signal streams available for current selection

-
- )} - +
+ )}
{/* Floating Bulk Action Bar */} {selectedIds.length > 0 && ( -
- -
- {[1,2,3,4,5,6,7,8,9,10,11,12].map(i =>
)} + {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map(i =>
)}
) -} +} \ No newline at end of file diff --git a/frontend/testing/unit/pages/Findings.test.tsx b/frontend/testing/unit/pages/Findings.test.tsx new file mode 100644 index 00000000..ac66360c --- /dev/null +++ b/frontend/testing/unit/pages/Findings.test.tsx @@ -0,0 +1,222 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Findings from '../../../src/pages/Findings' + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../../src/api', () => ({ + getFindings: vi.fn(), +})) + +vi.mock('../../../src/utils/date', () => ({ + formatLocaleDate: (d: any) => (d ? '2024-01-01' : ''), +})) + +// @tanstack/react-virtual needs ResizeObserver + scrollHeight in jsdom +global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})) + +Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { configurable: true, value: 800 }) +Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 600 }) + +import { getFindings } from '../../../src/api' + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +function makeFinding(overrides: Partial> = {}) { + return baseFinding(overrides) +} + +function baseFinding(overrides: any = {}) { + return { + id: `f-${Math.random().toString(36).slice(2)}`, + severity: 'high', + category: 'Network', + title: 'Test Finding', + target: 'example.com', + description: 'A test description', + remediation: 'Fix it', + discovered_at: '2024-01-01T00:00:00Z', + cvss: 7.5, + cve: undefined, + ...overrides, + } +} + +function makeLargeDataset(count: number) { + return Array.from({ length: count }, (_, i) => + makeFinding({ + id: `finding-${i}`, + severity: ['critical', 'high', 'medium', 'low', 'info'][i % 5], + title: `Finding ${i}`, + }), + ) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('Findings — virtualized list', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + it('renders the page header', async () => { + vi.mocked(getFindings).mockResolvedValue({ findings: [] }) + render() + expect(screen.getByRole('heading', { name: /Findings/i })).toBeInTheDocument() + }) + + it('shows loading state then renders findings', async () => { + const findings = [makeFinding({ title: 'SQL Injection' })] + vi.mocked(getFindings).mockResolvedValue({ findings }) + + render() + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) + expect(screen.getAllByText('SQL Injection').length).toBeGreaterThan(0) + }) + + it('shows empty state when no findings match', async () => { + vi.mocked(getFindings).mockResolvedValue({ findings: [] }) + render() + await waitFor(() => expect(screen.getByText(/No Findings Match/i)).toBeInTheDocument()) + }) + + it('does not mount all rows to DOM with 500 findings (DOM bloat test)', async () => { + const findings = makeLargeDataset(500) + vi.mocked(getFindings).mockResolvedValue({ findings }) + + const { container } = render() + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) + + // Only a small window of rows should be in the DOM — far fewer than 500 + const rows = container.querySelectorAll('[role="option"]') + expect(rows.length).toBeLessThan(60) + }) + + it('filters findings by severity pill', async () => { + const findings = [ + makeFinding({ id: 'c1', severity: 'critical', title: 'Critical Finding' }), + makeFinding({ id: 'h1', severity: 'high', title: 'High Finding' }), + ] + vi.mocked(getFindings).mockResolvedValue({ findings }) + + render() + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) + + // Click the Critical pill — use first match (the filter pill, not any row label) + const criticalPill = screen.getAllByRole('button', { name: /Critical/i })[0] + await userEvent.click(criticalPill) + + // Query within the virtual list only to avoid detail panel duplicates + const list = screen.getByRole('listbox') + expect(list.querySelector('[role="option"]')).toBeInTheDocument() + expect(screen.getAllByText('Critical Finding').length).toBeGreaterThan(0) + expect(screen.queryByText('High Finding')).not.toBeInTheDocument() + }) + + it('filters findings by search query', async () => { + const findings = [ + makeFinding({ title: 'XSS Attack Vector', description: 'Cross site scripting' }), + makeFinding({ title: 'SQL Injection', description: 'Database attack' }), + ] + vi.mocked(getFindings).mockResolvedValue({ findings }) + + render() + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) + + const searchInput = screen.getByPlaceholderText(/Title, target, CVE/i) + await userEvent.type(searchInput, 'XSS') + + // Use getAllByText since title appears in both list row and detail panel + expect(screen.getAllByText('XSS Attack Vector').length).toBeGreaterThan(0) + expect(screen.queryByText('SQL Injection')).not.toBeInTheDocument() + }) + + it('selects a finding when clicked and shows it in the detail panel', async () => { + const findings = [ + makeFinding({ id: 'f1', title: 'Finding Alpha', severity: 'critical' }), + makeFinding({ id: 'f2', title: 'Finding Beta', severity: 'critical' }), + ] + vi.mocked(getFindings).mockResolvedValue({ findings }) + + render() + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) + + // Click within the list to select a different finding + const listbox = screen.getByRole('listbox') + const betaOption = listbox.querySelector('[role="option"][aria-label*="Finding Beta"], [role="option"]') + if (betaOption) await userEvent.click(betaOption) + + expect(screen.getByText('Selected Finding')).toBeInTheDocument() + }) + + it('keyboard ArrowDown moves selection forward', async () => { + const findings = [ + makeFinding({ id: 'f1', title: 'First Finding', severity: 'critical' }), + makeFinding({ id: 'f2', title: 'Second Finding', severity: 'critical' }), + ] + vi.mocked(getFindings).mockResolvedValue({ findings }) + + render() + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) + + const listbox = screen.getByRole('listbox') + listbox.focus() + fireEvent.keyDown(listbox, { key: 'ArrowDown' }) + + await waitFor(() => { + const selected = screen.getByRole('option', { name: /Second Finding/i }) + expect(selected).toHaveAttribute('aria-selected', 'true') + }) + }) + + it('workflow actions (mark reviewed, suppress, reopen) update status chip', async () => { + const findings = [makeFinding({ id: 'f1', title: 'Actionable Finding', severity: 'high' })] + vi.mocked(getFindings).mockResolvedValue({ findings }) + + render() + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) + + await userEvent.click(screen.getByRole('button', { name: /Mark Reviewed/i })) + + await waitFor(() => { + const reviewedChips = screen.getAllByText('reviewed') + expect(reviewedChips.length).toBeGreaterThan(0) + }) + }) + + it('persists review state to localStorage', async () => { + const findings = [makeFinding({ id: 'f1', title: 'Persist Test', severity: 'high' })] + vi.mocked(getFindings).mockResolvedValue({ findings }) + + render() + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) + + await userEvent.click(screen.getByRole('button', { name: /Mark Reviewed/i })) + + const stored = JSON.parse(localStorage.getItem('secuscan-finding-review-state') ?? '{}') + expect(stored['f1']).toBe('reviewed') + }) + + it('negative: suppressed finding shows suppressed status chip', async () => { + const findings = [ + makeFinding({ id: 's1', title: 'Suppressed Finding', severity: 'low' }), + makeFinding({ id: 'a1', title: 'Active Finding', severity: 'high' }), + ] + vi.mocked(getFindings).mockResolvedValue({ findings }) + + localStorage.setItem('secuscan-finding-review-state', JSON.stringify({ s1: 'suppressed' })) + + render() + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) + + const suppressedChips = screen.queryAllByText('suppressed') + expect(suppressedChips.length).toBeGreaterThan(0) + }) +}) diff --git a/frontend/testing/unit/pages/Reports.test.tsx b/frontend/testing/unit/pages/Reports.test.tsx new file mode 100644 index 00000000..713bcc09 --- /dev/null +++ b/frontend/testing/unit/pages/Reports.test.tsx @@ -0,0 +1,206 @@ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import Reports from '../../../src/pages/Reports' + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../../src/api', () => ({ + getReports: vi.fn(), + getDashboardSummary: vi.fn(), + API_BASE: 'http://localhost:5000', +})) + +vi.mock('../../../src/utils/date', () => ({ + formatDateLong: (d: any) => d ?? '', +})) + +global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})) + +Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { configurable: true, value: 800 }) +Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 600 }) + +import { getReports, getDashboardSummary } from '../../../src/api' + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +function makeReport(overrides: any = {}) { + return { + id: `r-${Math.random().toString(36).slice(2)}`, + task_id: 'task-001', + name: 'Test Report', + type: 'technical' as const, + generated_at: '2024-01-01T00:00:00Z', + status: 'ready' as const, + findings: 12, + assets: 3, + pages: 8, + ...overrides, + } +} + +function makeLargeDataset(count: number) { + return Array.from({ length: count }, (_, i) => + makeReport({ + id: `r-${i}`, + name: `Report ${i}`, + type: (['executive', 'technical', 'compliance'] as const)[i % 3], + }), + ) +} + +function mockApis(reports: ReturnType[], summary: any = {}) { + vi.mocked(getReports).mockResolvedValue({ reports }) + vi.mocked(getDashboardSummary).mockResolvedValue({ + total_findings: 42, + total_assets: 7, + ...summary, + }) +} + +function renderReports() { + return render( + + + + ) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('Reports — virtualized grid', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders the page header', () => { + mockApis([]) + renderReports() + expect(screen.getByText(/Analytics/i)).toBeInTheDocument() + }) + + it('shows empty state when no reports', async () => { + mockApis([]) + renderReports() + await waitFor(() => expect(screen.getByText(/Archive Isolated/i)).toBeInTheDocument()) + }) + + it('renders report cards for loaded reports', async () => { + const reports = [makeReport({ name: 'Q1 Security Audit' })] + mockApis(reports) + renderReports() + await waitFor(() => expect(screen.getByText('Q1 Security Audit')).toBeInTheDocument()) + }) + + it('does not mount all cards to DOM with 200 reports (DOM bloat test)', async () => { + const reports = makeLargeDataset(200) + mockApis(reports) + const { container } = renderReports() + + await waitFor(() => expect(screen.queryByText(/Archive Isolated/i)).not.toBeInTheDocument(), { timeout: 3000 }) + + // Each virtual row holds 2 cards; overscan(3) = ~8 rows visible max + // 200 reports = 100 rows; only a small window should render + const cards = container.querySelectorAll('.group') + expect(cards.length).toBeLessThan(30) + }) + + it('filters by type using sidebar buttons', async () => { + const reports = [ + makeReport({ id: 'r1', name: 'Exec Report', type: 'executive' }), + makeReport({ id: 'r2', name: 'Tech Report', type: 'technical' }), + ] + mockApis(reports) + renderReports() + + await waitFor(() => expect(screen.getByText('Exec Report')).toBeInTheDocument()) + + // Click 'executive BRIEFINGS' + await userEvent.click(screen.getByRole('button', { name: /executive BRIEFINGS/i })) + + expect(screen.getByText('Exec Report')).toBeInTheDocument() + expect(screen.queryByText('Tech Report')).not.toBeInTheDocument() + }) + + it('resets to all types when "all BRIEFINGS" is clicked', async () => { + const reports = [ + makeReport({ id: 'r1', name: 'Exec Report', type: 'executive' }), + makeReport({ id: 'r2', name: 'Tech Report', type: 'technical' }), + ] + mockApis(reports) + renderReports() + + await waitFor(() => expect(screen.getByText('Exec Report')).toBeInTheDocument()) + + await userEvent.click(screen.getByRole('button', { name: /executive BRIEFINGS/i })) + await userEvent.click(screen.getByRole('button', { name: /all BRIEFINGS/i })) + + expect(screen.getByText('Exec Report')).toBeInTheDocument() + expect(screen.getByText('Tech Report')).toBeInTheDocument() + }) + + it('displays metrics from summary API', async () => { + mockApis([], { total_findings: 99, total_assets: 15 }) + renderReports() + + await waitFor(() => { + expect(screen.getByText('99')).toBeInTheDocument() + expect(screen.getByText('15')).toBeInTheDocument() + }) + }) + + it('refresh button re-fetches reports', async () => { + mockApis([makeReport({ name: 'Initial Report' })]) + renderReports() + + await waitFor(() => expect(screen.getByText('Initial Report')).toBeInTheDocument()) + + mockApis([makeReport({ name: 'Initial Report' }), makeReport({ name: 'Refreshed Report' })]) + await userEvent.click(screen.getByTitle('Refresh Archive')) + + await waitFor(() => expect(screen.getByText('Refreshed Report')).toBeInTheDocument()) + }) + + it('report card shows correct status bar colour for ready/failed/generating', async () => { + const reports = [ + makeReport({ id: 'r1', name: 'Ready Report', status: 'ready' }), + makeReport({ id: 'r2', name: 'Failed Report', status: 'failed' }), + ] + mockApis(reports) + const { container } = renderReports() + + await waitFor(() => expect(screen.getByText('Ready Report')).toBeInTheDocument()) + + // Green bar for ready + const greenBars = container.querySelectorAll('.bg-rag-green.w-full') + expect(greenBars.length).toBeGreaterThan(0) + + // Red bar for failed + const redBars = container.querySelectorAll('.bg-rag-red.w-full') + expect(redBars.length).toBeGreaterThan(0) + }) + + it('negative: compliance type filter hides executive and technical reports', async () => { + const reports = [ + makeReport({ id: 'r1', name: 'Executive Dossier', type: 'executive' }), + makeReport({ id: 'r2', name: 'Technical Intel', type: 'technical' }), + makeReport({ id: 'r3', name: 'Compliance Audit', type: 'compliance' }), + ] + mockApis(reports) + renderReports() + + await waitFor(() => expect(screen.getByText('Compliance Audit')).toBeInTheDocument()) + + await userEvent.click(screen.getByRole('button', { name: /compliance BRIEFINGS/i })) + + expect(screen.getByText('Compliance Audit')).toBeInTheDocument() + expect(screen.queryByText('Executive Dossier')).not.toBeInTheDocument() + expect(screen.queryByText('Technical Intel')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/testing/unit/pages/Scans.test.tsx b/frontend/testing/unit/pages/Scans.test.tsx new file mode 100644 index 00000000..3bb08428 --- /dev/null +++ b/frontend/testing/unit/pages/Scans.test.tsx @@ -0,0 +1,200 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import Scans from '../../../src/pages/Scans' + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../../src/api', () => ({ + API_BASE: 'http://localhost:5000', + deleteTask: vi.fn().mockResolvedValue({}), + clearAllTasks: vi.fn().mockResolvedValue({}), + bulkDeleteTasks: vi.fn().mockResolvedValue({}), +})) + +vi.mock('../../../src/routes', () => ({ + routePath: { task: (id: string) => `/task/${id}` }, +})) + +vi.mock('../../../src/utils/date', () => ({ + parseDateSafe: (d: any) => new Date(d || Date.now()), + formatLocaleDate: () => '2024-01-01', + formatLocaleTime: () => '12:00', +})) + +global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})) + +Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { configurable: true, value: 800 }) +Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 600 }) + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +function makeTask(overrides: any = {}) { + const id = overrides.task_id ?? `task-${Math.random().toString(36).slice(2)}` + return { + task_id: id, + plugin_id: 'nmap', + tool: 'nmap', + target: 'example.com', + status: 'completed' as const, + created_at: '2024-01-01T00:00:00Z', + duration_seconds: 30, + ...overrides, + } +} + +function makeLargeFetch(count: number) { + return Array.from({ length: count }, (_, i) => makeTask({ task_id: `task-${i}`, tool: `tool-${i}` })) +} + +function mockFetch(tasks: ReturnType[]) { + global.fetch = vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ tasks }), + } as any) +} + +function renderScans() { + return render( + + + + ) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('Scans — virtualized task list', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers({ shouldAdvanceTime: true }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('renders the page header', () => { + mockFetch([]) + renderScans() + // Use heading role to avoid matching both the badge and the h1 + expect(screen.getByRole('heading', { name: /Operational/i })).toBeInTheDocument() + }) + + it('shows empty state when there are no tasks', async () => { + mockFetch([]) + renderScans() + await waitFor(() => expect(screen.getByText(/Archive Isolated/i)).toBeInTheDocument()) + }) + + it('renders task cards for loaded tasks', async () => { + const tasks = [makeTask({ tool: 'nmap', target: 'target.com' })] + mockFetch(tasks) + renderScans() + await waitFor(() => expect(screen.getByText('nmap')).toBeInTheDocument()) + expect(screen.getByText('target.com')).toBeInTheDocument() + }) + + it('does not mount all cards to DOM with 300 tasks (DOM bloat test)', async () => { + const tasks = makeLargeFetch(300) + mockFetch(tasks) + const { container } = renderScans() + + await waitFor(() => expect(screen.queryByText(/Archive Isolated/i)).not.toBeInTheDocument(), { timeout: 3000 }) + + const cards = container.querySelectorAll('.group\\/card') + expect(cards.length).toBeLessThan(40) + }) + + it('status filter buttons are rendered and clickable', async () => { + mockFetch([]) + renderScans() + const allBtn = screen.getByRole('button', { name: /ALL_OPERATIONS/i }) + expect(allBtn).toBeInTheDocument() + await userEvent.click(allBtn) + }) + + it('selecting a task adds it to selectedIds (checkbox toggles)', async () => { + const tasks = [makeTask({ task_id: 'task-1', tool: 'nuclei' })] + mockFetch(tasks) + renderScans() + + await waitFor(() => expect(screen.getByText('nuclei')).toBeInTheDocument()) + + // Checkbox has aria-label="add" (material icon name) — use getAllByRole and pick first + const checkboxes = screen.getAllByRole('checkbox') + await userEvent.click(checkboxes[0]) + + await waitFor(() => expect(screen.getByText(/Records_Selected_For_Pruning/i)).toBeInTheDocument()) + }) + + it('select-all selects all tasks', async () => { + const tasks = [makeTask({ task_id: 'task-1' }), makeTask({ task_id: 'task-2' })] + mockFetch(tasks) + renderScans() + + await waitFor(() => expect(screen.getAllByText(/nmap/i).length).toBeGreaterThan(0)) + + await userEvent.click(screen.getByRole('button', { name: /Select_All/i })) + + await waitFor(() => { + const count = screen.getByText('2') + expect(count).toBeInTheDocument() + }) + }) + + it('cancel clears selection', async () => { + const tasks = [makeTask({ task_id: 'task-1' })] + mockFetch(tasks) + renderScans() + + await waitFor(() => expect(screen.getByText('nmap')).toBeInTheDocument()) + + // Select the task via checkbox + const checkboxes = screen.getAllByRole('checkbox') + await userEvent.click(checkboxes[0]) + await waitFor(() => expect(screen.getByText(/Records_Selected_For_Pruning/i)).toBeInTheDocument()) + + // Cancel — selectedIds should clear, checkbox returns to unchecked + await userEvent.click(screen.getByRole('button', { name: /Cancel/i })) + await waitFor(() => + expect(checkboxes[0]).toHaveAttribute('aria-checked', 'false') + ) + }) + + it('polls every 5 seconds', async () => { + mockFetch([]) + renderScans() + + expect(global.fetch).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(5000) + await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(2)) + }) + + it('negative: Delete_Record button NOT shown for running tasks', async () => { + const tasks = [makeTask({ status: 'running' })] + mockFetch(tasks) + renderScans() + + await waitFor(() => expect(screen.getByText('nmap')).toBeInTheDocument()) + + await userEvent.click(screen.getByText('nmap').closest('[class*="cursor-pointer"]')!) + + expect(screen.queryByText('Delete_Record')).not.toBeInTheDocument() + }) + + it('negative: bulk delete not triggered with empty selection', async () => { + mockFetch([makeTask()]) + renderScans() + + await waitFor(() => expect(screen.getByText('nmap')).toBeInTheDocument()) + + const { bulkDeleteTasks } = await import('../../../src/api') + expect(bulkDeleteTasks).not.toHaveBeenCalled() + }) +})