Skip to content

Commit 7e57143

Browse files
committed
validation
1 parent ddcf235 commit 7e57143

8 files changed

Lines changed: 645 additions & 8 deletions

File tree

web-report/src-e2e/App.test.tsx

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {render, waitFor, screen, act, fireEvent} from '@testing-library/react';
1+
import {render, waitFor, screen, act, fireEvent, within} from '@testing-library/react';
22
import '@testing-library/jest-dom';
33
import {resolve} from "path";
44
import {readFileSync} from "fs";
@@ -18,7 +18,7 @@ vi.mock('@/lib/utils.tsx', async (importOriginal) => {
1818
fetchFileContent: vi.fn(async (filePath: string) => {
1919
const folderPath = resolve(__dirname, './static/');
2020
const fullPath = resolve(folderPath, filePath);
21-
if (fullPath.endsWith('.json')) {
21+
if (fullPath.endsWith('report.json')) {
2222
return reportData;
2323
} else if (filePath.endsWith('.java')) {
2424
return readFileSync(fullPath, 'utf-8');
@@ -134,6 +134,83 @@ describe('App test', () => {
134134
});
135135
});
136136

137+
it('changing a review state marks it dirty and updates counts', async () => {
138+
render(<App/>);
139+
await waitFor(() => {
140+
expect(screen.getByTestId('tab-tests')).toBeInTheDocument();
141+
});
142+
143+
act(() => {
144+
fireEvent.focus(screen.getByTestId('tab-tests'));
145+
});
146+
147+
await waitFor(() => {
148+
expect(screen.getByTestId('test-file-0')).toBeInTheDocument();
149+
});
150+
151+
act(() => {
152+
fireEvent.click(within(screen.getByTestId('test-file-0')).getByRole('button'));
153+
});
154+
155+
const firstTestId = reportData.testCases[0].id as string;
156+
await waitFor(() => {
157+
expect(screen.getByTestId(`test-review-state-${firstTestId}`)).toBeInTheDocument();
158+
});
159+
160+
act(() => {
161+
fireEvent.change(screen.getByTestId(`test-review-state-${firstTestId}`), {
162+
target: {value: 'ACCEPTED'},
163+
});
164+
});
165+
166+
await waitFor(() => {
167+
expect(screen.getByTestId('reviews-unsaved-banner')).toBeInTheDocument();
168+
expect(screen.getByTestId('reviews-filter-ACCEPTED')).toHaveTextContent('ACCEPTED (1)');
169+
expect(screen.getByTestId('reviews-filter-NOT-REVIEWED'))
170+
.toHaveTextContent(`NOT-REVIEWED (${reportData.testCases.length - 1})`);
171+
});
172+
});
173+
174+
it('clicking a test opens the detail dialog and close dismisses it', async () => {
175+
render(<App/>);
176+
await waitFor(() => {
177+
expect(screen.getByTestId('tab-tests')).toBeInTheDocument();
178+
});
179+
180+
act(() => {
181+
fireEvent.focus(screen.getByTestId('tab-tests'));
182+
});
183+
184+
await waitFor(() => {
185+
expect(screen.getByTestId('test-file-0')).toBeInTheDocument();
186+
});
187+
188+
act(() => {
189+
fireEvent.click(within(screen.getByTestId('test-file-0')).getByRole('button'));
190+
});
191+
192+
const firstTestId = reportData.testCases[0].id as string;
193+
await waitFor(() => {
194+
expect(screen.getByTestId(`test-review-open-${firstTestId}`)).toBeInTheDocument();
195+
});
196+
197+
act(() => {
198+
fireEvent.click(screen.getByTestId(`test-review-open-${firstTestId}`));
199+
});
200+
201+
await waitFor(() => {
202+
expect(screen.getByTestId('test-detail-dialog')).toBeInTheDocument();
203+
});
204+
205+
act(() => {
206+
fireEvent.click(screen.getByTestId('test-detail-dialog-close'));
207+
});
208+
209+
await waitFor(() => {
210+
expect(screen.queryByTestId('test-detail-dialog')).toBeNull();
211+
});
212+
});
213+
137214
it('check faults component', async () => {
138215
render(<App />);
139216
expect(screen.getByText(/Please wait, files are loading.../)).toBeInTheDocument();

web-report/src/AppProvider.tsx

Lines changed: 158 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
1-
import {createContext, useContext, useState, ReactNode, useEffect} from 'react';
1+
import {createContext, useContext, useState, ReactNode, useEffect, useRef, useCallback} from 'react';
22
import {WebFuzzingCommonsReport} from "@/types/GeneratedTypes.tsx";
33
import {ITestFiles} from "@/types/General.tsx";
44
import {fetchFileContent, ITransformedReport, transformWebFuzzingReport} from "@/lib/utils.tsx";
55
import {webFuzzingCommonsReportSchema} from "@/types/GeneratedTypesZod.ts";
66
import {ZodIssue} from "zod";
7+
import {
8+
DEFAULT_REVIEW,
9+
parseReviewFile,
10+
REVIEW_FILE_NAME,
11+
REVIEW_SCHEMA_VERSION,
12+
ReviewFile,
13+
ReviewState,
14+
reviewsEqual,
15+
TestReview,
16+
} from "@/types/Review.ts";
717

818
type AppContextType = {
919
data: WebFuzzingCommonsReport | null;
@@ -16,6 +26,15 @@ type AppContextType = {
1626
invalidReportErrors: ZodIssue[] | null;
1727
lowCodeMode: boolean;
1828
setLowCodeMode: (v: boolean) => void;
29+
reviews: Record<string, TestReview>;
30+
getReview: (testId: string) => TestReview;
31+
setReviewState: (testId: string, state: ReviewState) => void;
32+
setReviewComment: (testId: string, comment: string) => void;
33+
isDirty: boolean;
34+
saveReviews: () => void;
35+
loadReviews: (file: File) => Promise<void>;
36+
reviewMessage: {type: "info" | "error"; text: string} | null;
37+
clearReviewMessage: () => void;
1938
};
2039

2140
const AppContext = createContext<AppContextType | undefined>(undefined);
@@ -36,6 +55,15 @@ export const AppProvider = ({ children }: AppProviderProps) => {
3655
const [lowCodeMode, setLowCodeMode] = useState<boolean>(initialLowCode);
3756
const transformedReport = transformWebFuzzingReport(data);
3857

58+
const [reviews, setReviews] = useState<Record<string, TestReview>>({});
59+
const baselineRef = useRef<Record<string, TestReview>>({});
60+
const [isDirty, setIsDirty] = useState(false);
61+
const [reviewMessage, setReviewMessage] = useState<{type: "info" | "error"; text: string} | null>(null);
62+
63+
useEffect(() => {
64+
setIsDirty(!reviewsEqual(reviews, baselineRef.current));
65+
}, [reviews]);
66+
3967
useEffect(() => {
4068
const fetchData = async () => {
4169
try {
@@ -106,6 +134,113 @@ export const AppProvider = ({ children }: AppProviderProps) => {
106134
}
107135
}, [data]);
108136

137+
useEffect(() => {
138+
if (!data) return;
139+
if (window.location.protocol === 'file:') {
140+
setReviewMessage({
141+
type: "info",
142+
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.`,
143+
});
144+
return;
145+
}
146+
let cancelled = false;
147+
fetchFileContent('./' + REVIEW_FILE_NAME)
148+
.then(parsed => {
149+
if (cancelled || !parsed) return;
150+
try {
151+
const loaded = parseReviewFile(parsed);
152+
setReviews(loaded);
153+
baselineRef.current = loaded;
154+
setIsDirty(false);
155+
setReviewMessage({
156+
type: "info",
157+
text: `Auto-loaded ${Object.keys(loaded).length} review(s) from ${REVIEW_FILE_NAME}.`,
158+
});
159+
} catch (e) {
160+
console.warn("Auto-load of reviews failed:", e);
161+
}
162+
})
163+
.catch(() => { /* silent — expected when no review file is present or under file:// */ });
164+
return () => { cancelled = true; };
165+
}, [data]);
166+
167+
useEffect(() => {
168+
if (!isDirty) return;
169+
const handler = (e: BeforeUnloadEvent) => {
170+
e.preventDefault();
171+
e.returnValue = "";
172+
};
173+
window.addEventListener("beforeunload", handler);
174+
return () => window.removeEventListener("beforeunload", handler);
175+
}, [isDirty]);
176+
177+
const getReview = useCallback(
178+
(testId: string): TestReview => reviews[testId] ?? DEFAULT_REVIEW,
179+
[reviews],
180+
);
181+
182+
const setReviewState = useCallback((testId: string, state: ReviewState) => {
183+
setReviews(prev => {
184+
const existing = prev[testId] ?? DEFAULT_REVIEW;
185+
return {...prev, [testId]: {...existing, state}};
186+
});
187+
}, []);
188+
189+
const setReviewComment = useCallback((testId: string, comment: string) => {
190+
setReviews(prev => {
191+
const existing = prev[testId] ?? DEFAULT_REVIEW;
192+
return {...prev, [testId]: {...existing, comment}};
193+
});
194+
}, []);
195+
196+
const saveReviews = useCallback(() => {
197+
const file: ReviewFile = {
198+
schemaVersion: REVIEW_SCHEMA_VERSION,
199+
reviews: {},
200+
};
201+
for (const [id, r] of Object.entries(reviews)) {
202+
const comment = (r.comment ?? "").trim();
203+
if (r.state !== "NOT-REVIEWED" || comment !== "") {
204+
file.reviews[id] = {state: r.state, comment: r.comment ?? ""};
205+
}
206+
}
207+
const blob = new Blob([JSON.stringify(file, null, 2)], {type: "application/json"});
208+
const url = URL.createObjectURL(blob);
209+
const a = document.createElement("a");
210+
a.href = url;
211+
a.download = REVIEW_FILE_NAME;
212+
document.body.appendChild(a);
213+
a.click();
214+
document.body.removeChild(a);
215+
URL.revokeObjectURL(url);
216+
baselineRef.current = {...reviews};
217+
setIsDirty(false);
218+
setReviewMessage({
219+
type: "info",
220+
text: `Saved ${Object.keys(file.reviews).length} review(s). Place the downloaded ${REVIEW_FILE_NAME} next to report.json.`,
221+
});
222+
}, [reviews]);
223+
224+
const loadReviews = useCallback(async (file: File) => {
225+
try {
226+
const text = await file.text();
227+
const parsed = JSON.parse(text);
228+
const loaded = parseReviewFile(parsed);
229+
setReviews(loaded);
230+
baselineRef.current = loaded;
231+
setIsDirty(false);
232+
setReviewMessage({
233+
type: "info",
234+
text: `Loaded ${Object.keys(loaded).length} review(s) from ${file.name}.`,
235+
});
236+
} catch (e) {
237+
const msg = e instanceof Error ? e.message : String(e);
238+
setReviewMessage({type: "error", text: `Failed to load reviews: ${msg}`});
239+
}
240+
}, []);
241+
242+
const clearReviewMessage = useCallback(() => setReviewMessage(null), []);
243+
109244
const [filteredEndpoints, setFilteredEndpoints] = useState(transformedReport);
110245

111246
useEffect(() => {
@@ -175,7 +310,27 @@ export const AppProvider = ({ children }: AppProviderProps) => {
175310
return filtered;
176311
}
177312

178-
const value: AppContextType = { data, loading, error, testFiles, transformedReport, filterEndpoints, filteredEndpoints, invalidReportErrors, lowCodeMode, setLowCodeMode };
313+
const value: AppContextType = {
314+
data,
315+
loading,
316+
error,
317+
testFiles,
318+
transformedReport,
319+
filterEndpoints,
320+
filteredEndpoints,
321+
invalidReportErrors,
322+
lowCodeMode,
323+
setLowCodeMode,
324+
reviews,
325+
getReview,
326+
setReviewState,
327+
setReviewComment,
328+
isDirty,
329+
saveReviews,
330+
loadReviews,
331+
reviewMessage,
332+
clearReviewMessage,
333+
};
179334

180335
return (
181336
<AppContext.Provider value={value}>
@@ -190,4 +345,4 @@ export const useAppContext = (): AppContextType => {
190345
throw new Error('useAppContext must be used within AppProvider');
191346
}
192347
return context;
193-
};
348+
};

web-report/src/components/Dashboard.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {Header} from "@/components/Header.tsx";
66
import {Overview} from "@/pages/Overview.tsx";
77
import {Endpoints} from "@/pages/Endpoints.tsx";
88
import {TestResults} from "@/pages/TestResults.tsx";
9+
import {Tests} from "@/pages/Tests.tsx";
910

1011
import {ScrollArea, ScrollBar} from "@/components/ui/scroll-area.tsx";
1112
import {useAppContext} from "@/AppProvider.tsx";
@@ -16,7 +17,7 @@ export interface ITestTabs {
1617
}
1718

1819
export const Dashboard: React.FC = () => {
19-
const {data} = useAppContext();
20+
const {data, isDirty} = useAppContext();
2021

2122
const [activeTab, setActiveTab] = useState("overview")
2223

@@ -79,6 +80,13 @@ export const Dashboard: React.FC = () => {
7980
>
8081
Endpoints
8182
</TabsTrigger>
83+
<TabsTrigger
84+
value="tests"
85+
className="min-w-[150px] py-3 border border-gray-500 data-[state=active]:bg-blue-100 data-[state=active]:border-2 data-[state=active]:border-black data-[state=active]:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]"
86+
data-testid="tab-tests"
87+
>
88+
Tests{isDirty && <span className="ml-1 text-orange-600" title="Unsaved review changes"></span>}
89+
</TabsTrigger>
8290
</TabsList>
8391
</div>
8492
<div className="border-t border-black my-2"></div>
@@ -123,6 +131,10 @@ export const Dashboard: React.FC = () => {
123131
<Endpoints addTestTab={addTestTab}/>
124132
</TabsContent>
125133

134+
<TabsContent value="tests">
135+
<Tests/>
136+
</TabsContent>
137+
126138
{
127139
testTabs.map((test, index) => (
128140
<TabsContent value={`${test.value}`} key={index}>

0 commit comments

Comments
 (0)