diff --git a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-05-Analysis-Execution.md b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-05-Analysis-Execution.md index a6c4d85..5e44e33 100644 --- a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-05-Analysis-Execution.md +++ b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-05-Analysis-Execution.md @@ -44,7 +44,7 @@ Frontend uses `fetch` with `ReadableStream` reader (not native `EventSource`, wh - [x] Submitting headers triggers backend analysis with SSE streaming - [x] Progress bar updates in real-time showing current test name and percentage - [x] Countdown timer counts down from 30 seconds -- [ ] Partial failures show inline error indicators per FR-25 +- [x] Partial failures show inline error indicators per FR-25 (Added AnalysisResults rendering with inline error badges.) - [ ] Timeout at 30s displays partial results with notification listing incomplete tests - [ ] Empty input returns 400, oversized >1MB returns 413 - [ ] Linting passes on both sides diff --git a/frontend/src/__tests__/AnalysisResults.test.tsx b/frontend/src/__tests__/AnalysisResults.test.tsx new file mode 100644 index 0000000..debf385 --- /dev/null +++ b/frontend/src/__tests__/AnalysisResults.test.tsx @@ -0,0 +1,105 @@ +import type { ReactElement } from "react"; +import { act } from "react-dom/test-utils"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; + +import AnalysisResults from "../components/AnalysisResults"; +import type { AnalysisReport } from "../types/analysis"; + +type RenderResult = { + container: HTMLDivElement; +}; + +const cleanups: Array<() => void> = []; + +const render = (ui: ReactElement): RenderResult => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(ui); + }); + + cleanups.push(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + return { container }; +}; + +const getByTestId = (container: HTMLElement, testId: string): HTMLElement => { + const element = container.querySelector(`[data-testid="${testId}"]`); + if (!element) { + throw new Error(`Expected element ${testId} to be rendered.`); + } + return element as HTMLElement; +}; + +const queryByTestId = (container: HTMLElement, testId: string): HTMLElement | null => + container.querySelector(`[data-testid="${testId}"]`); + +const report: AnalysisReport = { + results: [ + { + testId: 101, + testName: "SpamAssassin Rule Hits", + headerName: "X-Spam-Flag", + headerValue: "YES", + analysis: "Flagged by local rules.", + description: "SpamAssassin rules matched during analysis.", + severity: "spam", + status: "error", + error: "SpamAssassin database timeout.", + }, + { + testId: 202, + testName: "Mimecast Fingerprint", + headerName: "X-Mimecast-Spam-Info", + headerValue: "none", + analysis: "No fingerprint detected.", + description: "No known fingerprint found.", + severity: "clean", + status: "success", + error: null, + }, + ], + hopChain: [], + securityAppliances: [], + metadata: { + totalTests: 2, + passedTests: 1, + failedTests: 1, + skippedTests: 0, + elapsedMs: 2200, + timedOut: false, + incompleteTests: [], + }, +}; + +afterEach(() => { + while (cleanups.length > 0) { + const cleanup = cleanups.pop(); + if (cleanup) { + cleanup(); + } + } +}); + +describe("AnalysisResults", () => { + it("shows inline error indicators for failed tests", () => { + const { container } = render(); + + const errorIndicator = getByTestId(container, "analysis-error-101"); + expect(errorIndicator.textContent ?? "").toMatch(/SpamAssassin database timeout/); + }); + + it("does not render error indicators for successful tests", () => { + const { container } = render(); + + expect(queryByTestId(container, "analysis-error-202")).toBeNull(); + }); +}); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 9754172..7e14ef7 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useState } from "react"; import AnalyseButton from "../components/AnalyseButton"; +import AnalysisResults from "../components/AnalysisResults"; import FileDropZone from "../components/FileDropZone"; import HeaderInput from "../components/HeaderInput"; import ProgressIndicator from "../components/ProgressIndicator"; @@ -18,7 +19,7 @@ const defaultConfig: AnalysisConfig = { export default function Home() { const [headerInput, setHeaderInput] = useState(""); - const { status, progress, submit } = useAnalysis(); + const { status, progress, result, submit } = useAnalysis(); const hasHeaderInput = headerInput.trim().length > 0; const isOversized = headerInput.length > MAX_HEADER_INPUT_BYTES; const canAnalyse = hasHeaderInput && !isOversized; @@ -89,6 +90,10 @@ export default function Home() { ) : null} + + {result ? ( + + ) : null} diff --git a/frontend/src/components/AnalysisResults.tsx b/frontend/src/components/AnalysisResults.tsx new file mode 100644 index 0000000..285dd25 --- /dev/null +++ b/frontend/src/components/AnalysisResults.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; + +import type { + AnalysisReport, + TestResult, + TestSeverity, + TestStatus, +} from "../types/analysis"; + +type AnalysisResultsProps = { + report: AnalysisReport; +}; + +const severityStyles: Record< + TestSeverity, + { label: string; className: string } +> = { + spam: { label: "Spam", className: "text-spam border-spam/40" }, + suspicious: { label: "Suspicious", className: "text-suspicious border-suspicious/40" }, + clean: { label: "Clean", className: "text-clean border-clean/40" }, + info: { label: "Info", className: "text-accent border-accent/40" }, +}; + +const statusStyles: Record< + TestStatus, + { label: string; className: string } +> = { + success: { label: "Success", className: "text-clean border-clean/40" }, + error: { label: "Error", className: "text-spam border-spam/40" }, + skipped: { label: "Skipped", className: "text-suspicious border-suspicious/40" }, +}; + +const getErrorMessage = (result: TestResult): string => { + const message = result.error?.trim(); + return message && message.length > 0 ? message : "Unknown failure."; +}; + +export default function AnalysisResults({ report }: AnalysisResultsProps) { + return ( +
+
+ + Analysis Report + + + {report.metadata.passedTests} passed / {report.metadata.failedTests} failed + +
+ +
    + {report.results.map((result) => { + const statusStyle = statusStyles[result.status]; + const severityStyle = severityStyles[result.severity]; + + return ( +
  • +
    +
    + + {result.testName} + + + Test #{result.testId} ยท {result.headerName} + +
    +
    + + {statusStyle.label} + + + {severityStyle.label} + +
    +
    + + {result.analysis ? ( +

    {result.analysis}

    + ) : null} + {result.description ? ( +

    {result.description}

    + ) : null} + + {result.status === "error" ? ( +
    + + + Error:{" "} + {getErrorMessage(result)} + +
    + ) : null} +
  • + ); + })} +
+
+ ); +}