diff --git a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-06-Interactive-Report.md b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-06-Interactive-Report.md
new file mode 100644
index 0000000..7d30165
--- /dev/null
+++ b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-06-Interactive-Report.md
@@ -0,0 +1,60 @@
+# Phase 06: US4 — Interactive Report Rendering
+
+This phase implements the full interactive report: collapsible test result cards with colour-coded severity, hop chain flow visualisation, security appliances summary, search/filter bar, and export to HTML/JSON. By the end of this phase, users receive a complete, interactive analysis report. TDD Red-Green: write all failing component tests first, then implement each component.
+
+## Spec Kit Context
+
+- **Feature:** 1-web-header-analyzer
+- **Specification:** .specify/specs/1-web-header-analyzer/spec.md (FR-07, FR-08, FR-09, FR-20, FR-21, FR-25)
+- **Plan:** .specify/specs/1-web-header-analyzer/plan.md
+- **Tasks:** .specify/specs/1-web-header-analyzer/tasks.md
+- **Data Model:** .specify/specs/1-web-header-analyzer/data-model.md (TestResult, HopChainNode, SecurityAppliance, AnalysisReport)
+- **User Story:** US4 — Interactive Report Rendering (Scenario 1, steps 7–8)
+- **Constitution:** .specify/memory/constitution.md (TDD: P6, UX: P7)
+
+## Dependencies
+
+- **Requires Phase 05** completed (analysis results available to render)
+
+## Severity Colour Mapping
+
+- **Spam:** `#ff5555` (red) — FontAwesome warning/ban icon
+- **Suspicious:** `#ffb86c` (amber) — FontAwesome exclamation icon
+- **Clean:** `#50fa7b` (green) — FontAwesome check icon
+- **Info:** `#bd93f9` (purple) — FontAwesome info icon
+- **Error:** error indicator with failure explanation (FR-25)
+
+## Component Architecture
+
+```
+ReportContainer
+├── Summary Stats (total tests, passed, failed, severity breakdown)
+├── ReportSearchBar (filter by text match)
+├── ReportExport (HTML / JSON download buttons)
+├── SecurityAppliancesSummary (detected security products as badges)
+├── HopChainVisualisation (vertical flow diagram of mail server hops)
+└── TestResultCard[] (one per test result, collapsible, severity-coloured)
+```
+
+## Tasks
+
+- [x] T030 [US4] Write failing tests (TDD Red) in `frontend/src/__tests__/report/TestResultCard.test.tsx` (each severity level, expand/collapse, error indicator), `HopChainVisualisation.test.tsx` (render with sample hops), `ReportSearchBar.test.tsx` (filter simulation), `ReportExport.test.tsx` (export trigger), `SecurityAppliancesSummary.test.tsx` (render with sample appliances, empty state), `ReportContainer.test.tsx` (full report with mixed results)
+- [ ] T031 [P] [US4] Create `frontend/src/components/report/TestResultCard.tsx` — collapsible card per test result. Severity-coloured indicator (red=spam, amber=suspicious, green=clean per FR-09), header name, monospace value, analysis text. Failed tests show error indicator (FR-25). Expand/collapse with animation, keyboard accessible (NFR-02). Verify `TestResultCard.test.tsx` passes (TDD Green)
+- [ ] T032 [P] [US4] Create `frontend/src/components/report/HopChainVisualisation.tsx` — vertical flow diagram of mail server hop chain (FR-08): hostname, IP, timestamp, server version, connecting arrows. FontAwesome server/network icons. Responsive. Verify `HopChainVisualisation.test.tsx` passes (TDD Green)
+- [ ] T033 [P] [US4] Create `frontend/src/components/report/ReportSearchBar.tsx` — search/filter bar above report (FR-20). Filters by text match against test name, header name, or analysis text. Highlights matches, shows count. FontAwesome search icon, Escape to clear. Verify `ReportSearchBar.test.tsx` passes (TDD Green)
+- [ ] T034 [P] [US4] Create `frontend/src/components/report/ReportExport.tsx` — export as HTML (styled standalone page) or JSON (raw data) per FR-21. FontAwesome download icons, triggers browser download. Verify `ReportExport.test.tsx` passes (TDD Green)
+- [ ] T035 [US4] Create `frontend/src/components/report/SecurityAppliancesSummary.tsx` — summary listing detected email security products as badges/tags with FontAwesome shield icons. Handle empty state (no appliances detected). Verify `SecurityAppliancesSummary.test.tsx` passes (TDD Green)
+- [ ] T036 [US4] Create `frontend/src/components/report/ReportContainer.tsx` — top-level wrapper receiving `AnalysisReport`. Renders: summary stats (total tests, passed, failed, severity breakdown), `TestResultCard` list, `HopChainVisualisation`, `SecurityAppliancesSummary`, `ReportSearchBar`, `ReportExport`. FontAwesome summary icons. Verify `ReportContainer.test.tsx` passes (TDD Green)
+
+## Completion
+
+- [ ] All vitest tests pass: `npx vitest run src/__tests__/report/`
+- [ ] Report renders all test results as collapsible cards with correct severity colours
+- [ ] Hop chain displays as a vertical visual flow with server details and connecting arrows
+- [ ] Security appliances show as badges; empty state handled gracefully
+- [ ] Search filters results in real-time across test name, header name, and analysis text
+- [ ] Export JSON produces a valid JSON file containing all results
+- [ ] Export HTML produces a styled standalone page viewable in any browser
+- [ ] All report components are keyboard accessible
+- [ ] Linting passes (`npx eslint src/`, `npx prettier --check src/`)
+- [ ] Run `/speckit.analyze` to verify consistency
diff --git a/frontend/src/__tests__/report/HopChainVisualisation.test.tsx b/frontend/src/__tests__/report/HopChainVisualisation.test.tsx
new file mode 100644
index 0000000..3790ad1
--- /dev/null
+++ b/frontend/src/__tests__/report/HopChainVisualisation.test.tsx
@@ -0,0 +1,93 @@
+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 HopChainVisualisation from "../../components/report/HopChainVisualisation";
+import type { HopChainNode } 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 hopChain: HopChainNode[] = [
+ {
+ index: 0,
+ hostname: "mail.sender.example",
+ ip: "192.0.2.10",
+ timestamp: "2026-02-17 10:00:01",
+ serverInfo: "Postfix",
+ },
+ {
+ index: 1,
+ hostname: "mx.receiver.example",
+ ip: "203.0.113.5",
+ timestamp: "2026-02-17 10:00:04",
+ serverInfo: "Exchange 2019",
+ },
+];
+
+afterEach(() => {
+ while (cleanups.length > 0) {
+ const cleanup = cleanups.pop();
+ if (cleanup) {
+ cleanup();
+ }
+ }
+});
+
+describe("HopChainVisualisation", () => {
+ it("renders hop nodes with server details", () => {
+ const { container } = render();
+
+ const root = getByTestId(container, "hop-chain-visualisation");
+ expect(root).toBeTruthy();
+
+ const firstHop = getByTestId(container, "hop-chain-node-0");
+ expect(firstHop.textContent ?? "").toContain("mail.sender.example");
+ expect(firstHop.textContent ?? "").toContain("192.0.2.10");
+
+ const secondHop = getByTestId(container, "hop-chain-node-1");
+ expect(secondHop.textContent ?? "").toContain("mx.receiver.example");
+ expect(secondHop.textContent ?? "").toContain("203.0.113.5");
+ });
+
+ it("renders connectors between hop nodes", () => {
+ const { container } = render();
+
+ const connectors = container.querySelectorAll(
+ '[data-testid^="hop-chain-connector-"]',
+ );
+
+ expect(connectors.length).toBe(hopChain.length - 1);
+ });
+});
diff --git a/frontend/src/__tests__/report/ReportContainer.test.tsx b/frontend/src/__tests__/report/ReportContainer.test.tsx
new file mode 100644
index 0000000..6a2f402
--- /dev/null
+++ b/frontend/src/__tests__/report/ReportContainer.test.tsx
@@ -0,0 +1,163 @@
+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 ReportContainer from "../../components/report/ReportContainer";
+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 report: AnalysisReport = {
+ results: [
+ {
+ testId: 101,
+ testName: "SpamAssassin Rule Hits",
+ headerName: "X-Spam-Flag",
+ headerValue: "YES",
+ analysis: "Flagged by local rules.",
+ description: "SpamAssassin rules matched.",
+ severity: "spam",
+ status: "error",
+ error: "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,
+ },
+ {
+ testId: 303,
+ testName: "Barracuda Reputation",
+ headerName: "X-Barracuda",
+ headerValue: "neutral",
+ analysis: "Reputation check pending.",
+ description: "Awaiting response.",
+ severity: "suspicious",
+ status: "success",
+ error: null,
+ },
+ {
+ testId: 404,
+ testName: "Custom Header Check",
+ headerName: "X-Custom",
+ headerValue: "ok",
+ analysis: "Custom info.",
+ description: "Informational.",
+ severity: "info",
+ status: "success",
+ error: null,
+ },
+ ],
+ hopChain: [
+ {
+ index: 0,
+ hostname: "mail.sender.example",
+ ip: "192.0.2.10",
+ timestamp: "2026-02-17 10:00:01",
+ serverInfo: "Postfix",
+ },
+ {
+ index: 1,
+ hostname: "mx.receiver.example",
+ ip: "203.0.113.5",
+ timestamp: "2026-02-17 10:00:04",
+ serverInfo: "Exchange 2019",
+ },
+ ],
+ securityAppliances: [
+ {
+ name: "Mimecast Email Security",
+ vendor: "Mimecast",
+ headers: ["X-Mimecast-Spam-Info"],
+ },
+ ],
+ metadata: {
+ totalTests: 4,
+ passedTests: 3,
+ failedTests: 1,
+ skippedTests: 0,
+ elapsedMs: 4200,
+ timedOut: false,
+ incompleteTests: [],
+ },
+};
+
+afterEach(() => {
+ while (cleanups.length > 0) {
+ const cleanup = cleanups.pop();
+ if (cleanup) {
+ cleanup();
+ }
+ }
+});
+
+describe("ReportContainer", () => {
+ it("renders the full report with summary stats and sections", () => {
+ const { container } = render();
+
+ getByTestId(container, "report-container");
+
+ const total = getByTestId(container, "report-summary-total");
+ const passed = getByTestId(container, "report-summary-passed");
+ const failed = getByTestId(container, "report-summary-failed");
+ const spam = getByTestId(container, "report-summary-severity-spam");
+ const suspicious = getByTestId(container, "report-summary-severity-suspicious");
+ const clean = getByTestId(container, "report-summary-severity-clean");
+ const info = getByTestId(container, "report-summary-severity-info");
+
+ expect(total.textContent ?? "").toContain("4");
+ expect(passed.textContent ?? "").toContain("3");
+ expect(failed.textContent ?? "").toContain("1");
+ expect(spam.textContent ?? "").toContain("1");
+ expect(suspicious.textContent ?? "").toContain("1");
+ expect(clean.textContent ?? "").toContain("1");
+ expect(info.textContent ?? "").toContain("1");
+
+ getByTestId(container, "report-search-bar");
+ getByTestId(container, "report-export");
+ getByTestId(container, "security-appliances-summary");
+ getByTestId(container, "hop-chain-visualisation");
+
+ report.results.forEach((result) => {
+ getByTestId(container, `test-result-card-${result.testId}`);
+ });
+ });
+});
diff --git a/frontend/src/__tests__/report/ReportExport.test.tsx b/frontend/src/__tests__/report/ReportExport.test.tsx
new file mode 100644
index 0000000..d317f94
--- /dev/null
+++ b/frontend/src/__tests__/report/ReportExport.test.tsx
@@ -0,0 +1,147 @@
+import type { ReactElement } from "react";
+import { act } from "react-dom/test-utils";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import ReportExport from "../../components/report/ReportExport";
+import type { AnalysisReport } from "../../types/analysis";
+
+type RenderResult = {
+ container: HTMLDivElement;
+};
+
+type UrlStatics = {
+ createObjectURL?: (blob: Blob) => string;
+ revokeObjectURL?: (url: string) => void;
+};
+
+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 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: "Timeout",
+ },
+ ],
+ hopChain: [],
+ securityAppliances: [],
+ metadata: {
+ totalTests: 1,
+ passedTests: 0,
+ failedTests: 1,
+ skippedTests: 0,
+ elapsedMs: 1200,
+ timedOut: false,
+ incompleteTests: [],
+ },
+};
+
+let createObjectUrlSpy: ReturnType | null = null;
+let revokeObjectUrlSpy: ReturnType | null = null;
+let clickSpy: ReturnType | null = null;
+
+const originalUrlStatics: UrlStatics = {
+ createObjectURL: (URL as unknown as UrlStatics).createObjectURL,
+ revokeObjectURL: (URL as unknown as UrlStatics).revokeObjectURL,
+};
+
+beforeEach(() => {
+ const urlStatics = URL as unknown as UrlStatics;
+
+ if (!urlStatics.createObjectURL) {
+ urlStatics.createObjectURL = () => "blob:report";
+ }
+ if (!urlStatics.revokeObjectURL) {
+ urlStatics.revokeObjectURL = () => undefined;
+ }
+
+ createObjectUrlSpy = vi
+ .spyOn(URL, "createObjectURL")
+ .mockReturnValue("blob:report");
+ revokeObjectUrlSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {
+ return undefined;
+ });
+ clickSpy = vi
+ .spyOn(HTMLAnchorElement.prototype, "click")
+ .mockImplementation(() => undefined);
+});
+
+afterEach(() => {
+ while (cleanups.length > 0) {
+ const cleanup = cleanups.pop();
+ if (cleanup) {
+ cleanup();
+ }
+ }
+
+ createObjectUrlSpy?.mockRestore();
+ revokeObjectUrlSpy?.mockRestore();
+ clickSpy?.mockRestore();
+
+ const urlStatics = URL as unknown as UrlStatics;
+ urlStatics.createObjectURL = originalUrlStatics.createObjectURL;
+ urlStatics.revokeObjectURL = originalUrlStatics.revokeObjectURL;
+});
+
+describe("ReportExport", () => {
+ it("triggers a JSON download", () => {
+ const { container } = render();
+
+ const jsonButton = getByTestId(container, "report-export-json");
+
+ act(() => {
+ jsonButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(createObjectUrlSpy).toHaveBeenCalled();
+ expect(clickSpy).toHaveBeenCalled();
+ });
+
+ it("triggers an HTML download", () => {
+ const { container } = render();
+
+ const htmlButton = getByTestId(container, "report-export-html");
+
+ act(() => {
+ htmlButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(createObjectUrlSpy).toHaveBeenCalled();
+ expect(clickSpy).toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/__tests__/report/ReportSearchBar.test.tsx b/frontend/src/__tests__/report/ReportSearchBar.test.tsx
new file mode 100644
index 0000000..01ee0ec
--- /dev/null
+++ b/frontend/src/__tests__/report/ReportSearchBar.test.tsx
@@ -0,0 +1,161 @@
+import type { ReactElement } from "react";
+import { useState } from "react";
+import { act } from "react-dom/test-utils";
+import { createRoot } from "react-dom/client";
+import { afterEach, describe, expect, it } from "vitest";
+
+import ReportSearchBar from "../../components/report/ReportSearchBar";
+import type { TestResult } 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 sampleResults: TestResult[] = [
+ {
+ testId: 101,
+ testName: "SpamAssassin Rule Hits",
+ headerName: "X-Spam-Flag",
+ headerValue: "YES",
+ analysis: "Flagged by local rules.",
+ description: "SpamAssassin rules matched.",
+ severity: "spam",
+ status: "error",
+ error: "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,
+ },
+ {
+ testId: 303,
+ testName: "Barracuda Reputation",
+ headerName: "X-Barracuda",
+ headerValue: "neutral",
+ analysis: "Reputation check pending.",
+ description: "Awaiting response.",
+ severity: "suspicious",
+ status: "success",
+ error: null,
+ },
+];
+
+const filterResults = (query: string, results: TestResult[]): TestResult[] => {
+ const normalizedQuery = query.trim().toLowerCase();
+ if (!normalizedQuery) {
+ return results;
+ }
+ return results.filter((result) => {
+ return (
+ result.testName.toLowerCase().includes(normalizedQuery) ||
+ result.headerName.toLowerCase().includes(normalizedQuery) ||
+ result.analysis.toLowerCase().includes(normalizedQuery)
+ );
+ });
+};
+
+const ReportSearchHarness = () => {
+ const [query, setQuery] = useState("");
+ const filtered = filterResults(query, sampleResults);
+
+ return (
+
+
+
+ {filtered.map((result) => (
+ - {result.testName}
+ ))}
+
+
+ );
+};
+
+afterEach(() => {
+ while (cleanups.length > 0) {
+ const cleanup = cleanups.pop();
+ if (cleanup) {
+ cleanup();
+ }
+ }
+});
+
+describe("ReportSearchBar", () => {
+ it("filters results when the search query changes", () => {
+ const { container } = render();
+
+ const input = getByTestId(container, "report-search-input") as HTMLInputElement;
+
+ act(() => {
+ input.value = "mime";
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+
+ const filteredList = getByTestId(container, "filtered-results");
+ expect(filteredList.textContent ?? "").toContain("Mimecast Fingerprint");
+ expect(filteredList.textContent ?? "").not.toContain("SpamAssassin Rule Hits");
+
+ const count = getByTestId(container, "report-search-count");
+ expect(count.textContent ?? "").toMatch(/1/);
+ });
+
+ it("clears the query on Escape", () => {
+ const { container } = render();
+
+ const input = getByTestId(container, "report-search-input") as HTMLInputElement;
+
+ act(() => {
+ input.value = "spam";
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+
+ act(() => {
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+ });
+
+ expect(input.value).toBe("");
+ const filteredList = getByTestId(container, "filtered-results");
+ expect(filteredList.textContent ?? "").toContain("SpamAssassin Rule Hits");
+ expect(filteredList.textContent ?? "").toContain("Mimecast Fingerprint");
+ });
+});
diff --git a/frontend/src/__tests__/report/SecurityAppliancesSummary.test.tsx b/frontend/src/__tests__/report/SecurityAppliancesSummary.test.tsx
new file mode 100644
index 0000000..e6ddd00
--- /dev/null
+++ b/frontend/src/__tests__/report/SecurityAppliancesSummary.test.tsx
@@ -0,0 +1,84 @@
+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 SecurityAppliancesSummary from "../../components/report/SecurityAppliancesSummary";
+import type { SecurityAppliance } 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 sampleAppliances: SecurityAppliance[] = [
+ {
+ name: "Mimecast Email Security",
+ vendor: "Mimecast",
+ headers: ["X-Mimecast-Spam-Info"],
+ },
+ {
+ name: "Proofpoint Protection",
+ vendor: "Proofpoint",
+ headers: ["X-Proofpoint-Verdict"],
+ },
+];
+
+afterEach(() => {
+ while (cleanups.length > 0) {
+ const cleanup = cleanups.pop();
+ if (cleanup) {
+ cleanup();
+ }
+ }
+});
+
+describe("SecurityAppliancesSummary", () => {
+ it("renders detected security appliances as badges", () => {
+ const { container } = render(
+ ,
+ );
+
+ const summary = getByTestId(container, "security-appliances-summary");
+ expect(summary.textContent ?? "").toContain("Mimecast Email Security");
+ expect(summary.textContent ?? "").toContain("Proofpoint Protection");
+
+ const firstBadge = getByTestId(container, "security-appliance-0");
+ expect(firstBadge.textContent ?? "").toContain("Mimecast");
+ });
+
+ it("renders an empty state when no appliances are detected", () => {
+ const { container } = render();
+
+ const emptyState = getByTestId(container, "security-appliances-empty");
+ expect(emptyState.textContent ?? "").toMatch(/No security appliances detected/i);
+ });
+});
diff --git a/frontend/src/__tests__/report/TestResultCard.test.tsx b/frontend/src/__tests__/report/TestResultCard.test.tsx
new file mode 100644
index 0000000..c5f8ae5
--- /dev/null
+++ b/frontend/src/__tests__/report/TestResultCard.test.tsx
@@ -0,0 +1,133 @@
+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 TestResultCard from "../../components/report/TestResultCard";
+import type { TestResult, TestSeverity } 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 buildResult = (overrides: Partial): TestResult => ({
+ 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: "success",
+ error: null,
+ ...overrides,
+});
+
+afterEach(() => {
+ while (cleanups.length > 0) {
+ const cleanup = cleanups.pop();
+ if (cleanup) {
+ cleanup();
+ }
+ }
+});
+
+describe("TestResultCard", () => {
+ it("renders severity labels with mapped styles", () => {
+ const severityCases: Array<{
+ severity: TestSeverity;
+ label: string;
+ className: string;
+ }> = [
+ { severity: "spam", label: "Spam", className: "text-spam" },
+ { severity: "suspicious", label: "Suspicious", className: "text-suspicious" },
+ { severity: "clean", label: "Clean", className: "text-clean" },
+ { severity: "info", label: "Info", className: "text-accent" },
+ ];
+
+ severityCases.forEach((severityCase, index) => {
+ const result = buildResult({
+ testId: 200 + index,
+ testName: `Severity ${severityCase.label}`,
+ severity: severityCase.severity,
+ });
+
+ const { container } = render();
+
+ const severityBadge = getByTestId(
+ container,
+ `test-result-severity-${result.testId}`,
+ );
+
+ expect(severityBadge.textContent ?? "").toContain(severityCase.label);
+ expect(severityBadge.className).toContain(severityCase.className);
+ });
+ });
+
+ it("expands and collapses on click and keyboard", () => {
+ const result = buildResult({ testId: 311, testName: "Header Anomaly" });
+ const { container } = render();
+
+ const toggle = getByTestId(container, `test-result-toggle-${result.testId}`);
+ expect(toggle.getAttribute("aria-expanded")).toBe("false");
+
+ act(() => {
+ toggle.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(toggle.getAttribute("aria-expanded")).toBe("true");
+
+ act(() => {
+ toggle.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
+ });
+
+ expect(toggle.getAttribute("aria-expanded")).toBe("false");
+
+ act(() => {
+ toggle.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true }));
+ });
+
+ expect(toggle.getAttribute("aria-expanded")).toBe("true");
+ });
+
+ it("shows error indicators for failed tests", () => {
+ const result = buildResult({
+ testId: 404,
+ status: "error",
+ error: "SpamAssassin database timeout.",
+ });
+ const { container } = render();
+
+ const errorIndicator = getByTestId(container, `test-result-error-${result.testId}`);
+ expect(errorIndicator.textContent ?? "").toMatch(/SpamAssassin database timeout/);
+ });
+});