MAESTRO: add report component tests

This commit is contained in:
Mariusz Banach
2026-02-18 02:46:20 +01:00
parent 1109334eb3
commit 4c82945484
7 changed files with 841 additions and 0 deletions

View File

@@ -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 78)
- **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

View File

@@ -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(<HopChainVisualisation hopChain={hopChain} />);
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(<HopChainVisualisation hopChain={hopChain} />);
const connectors = container.querySelectorAll(
'[data-testid^="hop-chain-connector-"]',
);
expect(connectors.length).toBe(hopChain.length - 1);
});
});

View File

@@ -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(<ReportContainer report={report} />);
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}`);
});
});
});

View File

@@ -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<typeof vi.spyOn> | null = null;
let revokeObjectUrlSpy: ReturnType<typeof vi.spyOn> | null = null;
let clickSpy: ReturnType<typeof vi.spyOn> | 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(<ReportExport report={report} />);
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(<ReportExport report={report} />);
const htmlButton = getByTestId(container, "report-export-html");
act(() => {
htmlButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(createObjectUrlSpy).toHaveBeenCalled();
expect(clickSpy).toHaveBeenCalled();
});
});

View File

@@ -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 (
<div>
<ReportSearchBar
query={query}
matchCount={filtered.length}
totalCount={sampleResults.length}
onQueryChange={setQuery}
/>
<ul data-testid="filtered-results">
{filtered.map((result) => (
<li key={result.testId}>{result.testName}</li>
))}
</ul>
</div>
);
};
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(<ReportSearchHarness />);
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(<ReportSearchHarness />);
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");
});
});

View File

@@ -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(
<SecurityAppliancesSummary appliances={sampleAppliances} />,
);
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(<SecurityAppliancesSummary appliances={[]} />);
const emptyState = getByTestId(container, "security-appliances-empty");
expect(emptyState.textContent ?? "").toMatch(/No security appliances detected/i);
});
});

View File

@@ -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>): 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(<TestResultCard result={result} />);
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(<TestResultCard result={result} />);
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(<TestResultCard result={result} />);
const errorIndicator = getByTestId(container, `test-result-error-${result.testId}`);
expect(errorIndicator.textContent ?? "").toMatch(/SpamAssassin database timeout/);
});
});