diff --git a/web-report/src-e2e/App.test.tsx b/web-report/src-e2e/App.test.tsx index e55b676..f0bf882 100644 --- a/web-report/src-e2e/App.test.tsx +++ b/web-report/src-e2e/App.test.tsx @@ -1,4 +1,4 @@ -import {render, waitFor, screen, act, fireEvent} from '@testing-library/react'; +import {render, waitFor, screen, act, fireEvent, within} from '@testing-library/react'; import '@testing-library/jest-dom'; import {resolve} from "path"; import {readFileSync} from "fs"; @@ -18,7 +18,7 @@ vi.mock('@/lib/utils.tsx', async (importOriginal) => { fetchFileContent: vi.fn(async (filePath: string) => { const folderPath = resolve(__dirname, './static/'); const fullPath = resolve(folderPath, filePath); - if (fullPath.endsWith('.json')) { + if (fullPath.endsWith('report.json')) { return reportData; } else if (filePath.endsWith('.java')) { return readFileSync(fullPath, 'utf-8'); @@ -134,6 +134,83 @@ describe('App test', () => { }); }); + it('changing a review state marks it dirty and updates counts', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('tab-tests')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.focus(screen.getByTestId('tab-tests')); + }); + + await waitFor(() => { + expect(screen.getByTestId('test-file-0')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(within(screen.getByTestId('test-file-0')).getByRole('button')); + }); + + const firstTestId = reportData.testCases[0].id as string; + await waitFor(() => { + expect(screen.getByTestId(`test-review-state-${firstTestId}`)).toBeInTheDocument(); + }); + + act(() => { + fireEvent.change(screen.getByTestId(`test-review-state-${firstTestId}`), { + target: {value: 'ACCEPTED'}, + }); + }); + + await waitFor(() => { + expect(screen.getByTestId('reviews-unsaved-banner')).toBeInTheDocument(); + expect(screen.getByTestId('reviews-filter-ACCEPTED')).toHaveTextContent('ACCEPTED (1)'); + expect(screen.getByTestId('reviews-filter-NOT-REVIEWED')) + .toHaveTextContent(`NOT-REVIEWED (${reportData.testCases.length - 1})`); + }); + }); + + it('clicking a test opens the detail dialog and close dismisses it', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('tab-tests')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.focus(screen.getByTestId('tab-tests')); + }); + + await waitFor(() => { + expect(screen.getByTestId('test-file-0')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(within(screen.getByTestId('test-file-0')).getByRole('button')); + }); + + const firstTestId = reportData.testCases[0].id as string; + await waitFor(() => { + expect(screen.getByTestId(`test-review-open-${firstTestId}`)).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(screen.getByTestId(`test-review-open-${firstTestId}`)); + }); + + await waitFor(() => { + expect(screen.getByTestId('test-detail-dialog')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(screen.getByTestId('test-detail-dialog-close')); + }); + + await waitFor(() => { + expect(screen.queryByTestId('test-detail-dialog')).toBeNull(); + }); + }); + it('check faults component', async () => { render(); expect(screen.getByText(/Please wait, files are loading.../)).toBeInTheDocument(); diff --git a/web-report/src/AppProvider.tsx b/web-report/src/AppProvider.tsx index a84f0f6..a73695c 100644 --- a/web-report/src/AppProvider.tsx +++ b/web-report/src/AppProvider.tsx @@ -1,21 +1,54 @@ -import {createContext, useContext, useState, ReactNode, useEffect} from 'react'; +import {createContext, useContext, useState, ReactNode, useEffect, useRef, useCallback, useMemo} from 'react'; import {WebFuzzingCommonsReport} from "@/types/GeneratedTypes.tsx"; -import {ITestFiles} from "@/types/General.tsx"; import {fetchFileContent, ITransformedReport, transformWebFuzzingReport} from "@/lib/utils.tsx"; import {webFuzzingCommonsReportSchema} from "@/types/GeneratedTypesZod.ts"; import {ZodIssue} from "zod"; +import { + DEFAULT_REVIEW, + parseReviewFile, + REVIEW_FILE_NAME, + REVIEW_SCHEMA_VERSION, + REVIEW_STATE, + ReviewFile, + ReviewState, + reviewsEqual, + TestReview, +} from "@/types/Review.ts"; + +type FileSystemApiHandle = { + createWritable: () => Promise<{write: (data: string) => Promise; close: () => Promise}>; + queryPermission?: (opts: {mode: 'read' | 'readwrite'}) => Promise<'granted' | 'denied' | 'prompt'>; + requestPermission?: (opts: {mode: 'read' | 'readwrite'}) => Promise<'granted' | 'denied' | 'prompt'>; +}; + +type WindowWithFileSystemApi = Window & { + showSaveFilePicker?: (opts: { + suggestedName?: string; + types?: Array<{description: string; accept: Record}>; + }) => Promise; +}; type AppContextType = { data: WebFuzzingCommonsReport | null; loading: boolean; error: string | null; - testFiles: ITestFiles[]; + testFiles: Record; + loadTestFile: (path: string) => Promise; transformedReport: ITransformedReport[]; filterEndpoints: (activeFilters: Record) => ITransformedReport[]; filteredEndpoints: ITransformedReport[]; invalidReportErrors: ZodIssue[] | null; lowCodeMode: boolean; setLowCodeMode: (v: boolean) => void; + reviews: Record; + getReview: (testId: string) => TestReview; + setReviewState: (testId: string, state: ReviewState) => void; + setReviewComment: (testId: string, comment: string) => void; + isDirty: boolean; + saveReviews: () => void; + loadReviews: (file: File) => Promise; + reviewMessage: {type: "info" | "error"; text: string} | null; + clearReviewMessage: () => void; }; const AppContext = createContext(undefined); @@ -32,9 +65,22 @@ export const AppProvider = ({ children }: AppProviderProps) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [invalidReportErrors, setInvalidReportErrors] = useState(null); - const [testFiles, setTestFiles] = useState([]); + const [testFiles, setTestFiles] = useState>({}); + const inFlightFilesRef = useRef>>(new Map()); const [lowCodeMode, setLowCodeMode] = useState(initialLowCode); - const transformedReport = transformWebFuzzingReport(data); + const transformedReport = useMemo(() => transformWebFuzzingReport(data), [data]); + + const [reviews, setReviews] = useState>({}); + const reviewsRef = useRef>({}); + const baselineRef = useRef>({}); + const [isDirty, setIsDirty] = useState(false); + const [reviewMessage, setReviewMessage] = useState<{type: "info" | "error"; text: string} | null>(null); + + const applyReviews = useCallback((next: Record) => { + reviewsRef.current = next; + setReviews(next); + setIsDirty(!reviewsEqual(next, baselineRef.current)); + }, []); useEffect(() => { const fetchData = async () => { @@ -86,35 +132,203 @@ export const AppProvider = ({ children }: AppProviderProps) => { fetchData(); }, []); - useEffect(() => { - if(data?.testFilePaths){ - data.testFilePaths.map(file => { - fetchFileContent(file).then((content) => { - if (typeof content === "string") { - setTestFiles(prev => [...prev, { - name: file, - code: content - }]); - } else { - setError("Could not load the test file. Please check if the file exists and is accessible."); - } - }).catch((error) => { - console.error(error); + const loadTestFile = useCallback(async (path: string): Promise => { + if (!path) return; + if (testFiles[path] !== undefined) return; + const existing = inFlightFilesRef.current.get(path); + if (existing) return existing; + + const promise = (async () => { + try { + const content = await fetchFileContent(path); + if (typeof content === "string") { + setTestFiles(prev => (prev[path] !== undefined ? prev : {...prev, [path]: content})); + } else { setError("Could not load the test file. Please check if the file exists and is accessible."); - }) - }) + } + } catch (e) { + console.error(e); + setError("Could not load the test file. Please check if the file exists and is accessible."); + } finally { + inFlightFilesRef.current.delete(path); + } + })(); + + inFlightFilesRef.current.set(path, promise); + return promise; + }, [testFiles]); + + useEffect(() => { + if (!data) return; + if (window.location.protocol === 'file:') { + setReviewMessage({ + type: "info", + text: `Auto-loading ${REVIEW_FILE_NAME} is not possible when opening this page directly from disk. Either launch the report via webreport.py, or click "Load reviews" to import the file manually.`, + }); + return; } + let cancelled = false; + fetchFileContent('./' + REVIEW_FILE_NAME) + .then(parsed => { + if (cancelled || !parsed) return; + try { + const loaded = parseReviewFile(parsed); + reviewsRef.current = loaded; + baselineRef.current = loaded; + setReviews(loaded); + setIsDirty(false); + setReviewMessage({ + type: "info", + text: `Auto-loaded ${Object.keys(loaded).length} review(s) from ${REVIEW_FILE_NAME}.`, + }); + } catch (e) { + console.warn("Auto-load of reviews failed:", e); + } + }) + .catch(() => { /* silent — expected when no review file is present or under file:// */ }); + return () => { cancelled = true; }; }, [data]); + useEffect(() => { + if (!isDirty) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [isDirty]); + + const getReview = useCallback( + (testId: string): TestReview => reviewsRef.current[testId] ?? DEFAULT_REVIEW, + [], + ); + + const setReviewState = useCallback((testId: string, state: ReviewState) => { + const prev = reviewsRef.current; + const existing = prev[testId] ?? DEFAULT_REVIEW; + if (existing.state === state) return; + applyReviews({...prev, [testId]: {...existing, state}}); + }, [applyReviews]); + + const setReviewComment = useCallback((testId: string, comment: string) => { + const prev = reviewsRef.current; + const existing = prev[testId] ?? DEFAULT_REVIEW; + if (existing.comment === comment) return; + applyReviews({...prev, [testId]: {...existing, comment}}); + }, [applyReviews]); + + const fileHandleRef = useRef(null); + + const saveReviews = useCallback(async () => { + // Flush any pending textarea edits (local-state rows commit on blur) + const active = document.activeElement; + if (active instanceof HTMLElement && (active.tagName === 'TEXTAREA' || active.tagName === 'INPUT')) { + active.blur(); + } + + const source = reviewsRef.current; + const file: ReviewFile = { + schemaVersion: REVIEW_SCHEMA_VERSION, + reviews: {}, + }; + for (const [id, r] of Object.entries(source)) { + const comment = (r.comment ?? "").trim(); + if (r.state !== REVIEW_STATE.NOT_REVIEWED || comment !== "") { + file.reviews[id] = {state: r.state, comment: r.comment ?? ""}; + } + } + const json = JSON.stringify(file, null, 2); + const reviewCount = Object.keys(file.reviews).length; + + const commitBaseline = () => { + baselineRef.current = {...source}; + setIsDirty(false); + }; + + // Preferred path: File System Access API (Chrome/Edge, secure context). + // Lets subsequent saves overwrite the same file without the OS appending (1), (2)... + const showSaveFilePicker = (window as WindowWithFileSystemApi).showSaveFilePicker; + if (typeof showSaveFilePicker === 'function') { + try { + let handle = fileHandleRef.current; + if (handle) { + const perm = (await handle.queryPermission?.({mode: 'readwrite'})) ?? 'granted'; + if (perm !== 'granted') { + const req = (await handle.requestPermission?.({mode: 'readwrite'})) ?? 'denied'; + if (req !== 'granted') handle = null; + } + } + if (!handle) { + handle = await showSaveFilePicker({ + suggestedName: REVIEW_FILE_NAME, + types: [{description: 'JSON', accept: {'application/json': ['.json']}}], + }); + fileHandleRef.current = handle; + } + const writable = await handle.createWritable(); + await writable.write(json); + await writable.close(); + commitBaseline(); + setReviewMessage({ + type: "info", + text: `Saved ${reviewCount} review(s). Subsequent saves will overwrite this file silently.`, + }); + return; + } catch (e) { + // User cancelled the picker — bail without downloading. + if (e instanceof DOMException && e.name === 'AbortError') return; + console.warn('File System Access API failed, falling back to download:', e); + fileHandleRef.current = null; + } + } + + // Fallback: anchor download. On Windows this may auto-rename to report-review(1).json + // when "Ask where to save each file" is disabled — unavoidable without the API above. + const blob = new Blob([json], {type: "application/json"}); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = REVIEW_FILE_NAME; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + commitBaseline(); + setReviewMessage({ + type: "info", + text: `Saved ${reviewCount} review(s). Place the downloaded ${REVIEW_FILE_NAME} next to report.json.`, + }); + }, []); + + const loadReviews = useCallback(async (file: File) => { + try { + const text = await file.text(); + const parsed = JSON.parse(text); + const loaded = parseReviewFile(parsed); + reviewsRef.current = loaded; + baselineRef.current = loaded; + setReviews(loaded); + setIsDirty(false); + setReviewMessage({ + type: "info", + text: `Loaded ${Object.keys(loaded).length} review(s) from ${file.name}.`, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setReviewMessage({type: "error", text: `Failed to load reviews: ${msg}`}); + } + }, []); + + const clearReviewMessage = useCallback(() => setReviewMessage(null), []); + const [filteredEndpoints, setFilteredEndpoints] = useState(transformedReport); useEffect(() => { - // Transform the report data into a format suitable for filtering if (data) { - const transformed = transformWebFuzzingReport(data); - setFilteredEndpoints(transformed); + setFilteredEndpoints(transformedReport); } - }, [data]); + }, [data, transformedReport]); const filterEndpoints = (activeFilters: Record) => { // Filter the endpoints based on the active filters @@ -175,7 +389,28 @@ export const AppProvider = ({ children }: AppProviderProps) => { return filtered; } - const value: AppContextType = { data, loading, error, testFiles, transformedReport, filterEndpoints, filteredEndpoints, invalidReportErrors, lowCodeMode, setLowCodeMode }; + const value: AppContextType = { + data, + loading, + error, + testFiles, + transformedReport, + filterEndpoints, + filteredEndpoints, + invalidReportErrors, + lowCodeMode, + setLowCodeMode, + reviews, + getReview, + setReviewState, + setReviewComment, + isDirty, + saveReviews, + loadReviews, + reviewMessage, + clearReviewMessage, + loadTestFile, + }; return ( @@ -190,4 +425,4 @@ export const useAppContext = (): AppContextType => { throw new Error('useAppContext must be used within AppProvider'); } return context; -}; \ No newline at end of file +}; diff --git a/web-report/src/components/Dashboard.tsx b/web-report/src/components/Dashboard.tsx index f5b1f2e..d7a66d6 100644 --- a/web-report/src/components/Dashboard.tsx +++ b/web-report/src/components/Dashboard.tsx @@ -1,14 +1,16 @@ import type React from "react" -import {useState} from "react" +import {useMemo, useState} from "react" import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs" import {X} from "lucide-react" import {Header} from "@/components/Header.tsx"; import {Overview} from "@/pages/Overview.tsx"; import {Endpoints} from "@/pages/Endpoints.tsx"; import {TestResults} from "@/pages/TestResults.tsx"; +import {Tests} from "@/pages/Tests.tsx"; import {ScrollArea, ScrollBar} from "@/components/ui/scroll-area.tsx"; import {useAppContext} from "@/AppProvider.tsx"; +import {REVIEW_STATE} from "@/types/Review.ts"; export interface ITestTabs { @@ -16,7 +18,20 @@ export interface ITestTabs { } export const Dashboard: React.FC = () => { - const {data} = useAppContext(); + const {data, isDirty, reviews} = useAppContext(); + + const reviewRatio = useMemo(() => { + if (!data) return null; + const total = data.testCases.length; + if (total === 0) return null; + let reviewed = 0; + for (const tc of data.testCases) { + if (!tc.id) continue; + const state = reviews[tc.id]?.state ?? REVIEW_STATE.NOT_REVIEWED; + if (state !== REVIEW_STATE.NOT_REVIEWED) reviewed++; + } + return {reviewed, total}; + }, [data, reviews]); const [activeTab, setActiveTab] = useState("overview") @@ -58,34 +73,47 @@ export const Dashboard: React.FC = () => { }); return ( -
+
- + Overview Endpoints + + Tests + {reviewRatio && ( + + {reviewRatio.reviewed}/{reviewRatio.total} + + )} + {isDirty && } +
{ - + { testTabs.map((test, index) => ( @@ -123,6 +151,10 @@ export const Dashboard: React.FC = () => { + + + + { testTabs.map((test, index) => ( diff --git a/web-report/src/components/EndpointAccordion.tsx b/web-report/src/components/EndpointAccordion.tsx index 03227ef..25d0518 100644 --- a/web-report/src/components/EndpointAccordion.tsx +++ b/web-report/src/components/EndpointAccordion.tsx @@ -61,24 +61,24 @@ export const EndpointAccordion: React.FC = ({ const faultColors = ["bg-red-300", "bg-red-500", "bg-red-700"]; return ( - -
{endpoint}
-
+ +
{endpoint}
+
{sortedStatusCodes.map((code, idx) => ( - + {code.code == -1 ? "NO-RESPONSE" : `H${code.code}`} ))} {sortedFaults.map((code, idx) => ( - + F{code.code} ))}
- -
-
HTTP CODES:
+ +
+
HTTP CODES:
{ sortedStatusCodes.map((code, index) => ( @@ -98,8 +98,8 @@ export const EndpointAccordion: React.FC = ({
-
-
FAULT CODES:
+
+
FAULT CODES:
{ sortedFaults.map((fault, index) => ( diff --git a/web-report/src/components/Header.tsx b/web-report/src/components/Header.tsx index 99cc941..d57eddc 100644 --- a/web-report/src/components/Header.tsx +++ b/web-report/src/components/Header.tsx @@ -34,12 +34,12 @@ export const Header: React.FC = ({date, toolNameVersion, schemaVer
-
+
-
Creation Date: {new Date(date).toUTCString()}
+
Creation Date: {new Date(date).toUTCString()}
-
+
Tool: {toolNameVersion} = ({date, toolNameVersion, schemaVer
-
Schema Version: {schemaVersion}
+
Schema Version: {schemaVersion}
diff --git a/web-report/src/components/TestDetailDialog.tsx b/web-report/src/components/TestDetailDialog.tsx new file mode 100644 index 0000000..9824f9c --- /dev/null +++ b/web-report/src/components/TestDetailDialog.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import {Dialog} from "radix-ui"; +import {X} from "lucide-react"; +import {TestResults} from "@/pages/TestResults.tsx"; + +interface IProps { + testId: string | null; + onClose: () => void; +} + +export const TestDetailDialog: React.FC = ({testId, onClose}) => { + return ( + { if (!open) onClose(); }} + > + + + +
+ + {testId ?? ""} + + + + +
+
+ {testId && } +
+
+
+
+ ); +}; diff --git a/web-report/src/components/TestReviewRow.tsx b/web-report/src/components/TestReviewRow.tsx new file mode 100644 index 0000000..ae33c77 --- /dev/null +++ b/web-report/src/components/TestReviewRow.tsx @@ -0,0 +1,87 @@ +import React, {useEffect, useState} from "react"; +import {Code, ChevronRight} from "lucide-react"; +import {useAppContext} from "@/AppProvider.tsx"; +import {REVIEW_STATE, ReviewState, SELECTABLE_REVIEW_STATES} from "@/types/Review.ts"; + +interface IProps { + testId: string; + testName: string; + onOpen: (testId: string) => void; +} + +const stateBadgeClass = (state: ReviewState): string => { + switch (state) { + case REVIEW_STATE.ACCEPTED: + return "bg-green-100 border-green-500 text-green-800"; + case REVIEW_STATE.REJECTED: + return "bg-red-100 border-red-500 text-red-800"; + case REVIEW_STATE.PREVIOUSLY_ACCEPTED: + return "bg-green-50 border-green-300 text-green-700"; + case REVIEW_STATE.PREVIOUSLY_REJECTED: + return "bg-red-50 border-red-300 text-red-700"; + case REVIEW_STATE.NOT_REVIEWED: + default: + return "bg-gray-100 border-gray-400 text-gray-700"; + } +}; + +const TestReviewRowInner: React.FC = ({testId, testName, onOpen}) => { + const {getReview, setReviewState, setReviewComment} = useAppContext(); + const review = getReview(testId); + + // Local state for the comment textarea keeps typing from re-rendering the entire + // list on every keystroke; we only commit to the shared context on blur. + const [localComment, setLocalComment] = useState(review.comment); + useEffect(() => { + setLocalComment(review.comment); + }, [review.comment]); + + return ( +
+ + + + +