MAESTRO: add inline error indicators to results

This commit is contained in:
Mariusz Banach
2026-02-18 02:29:46 +01:00
parent c2cb756eeb
commit bb6b84b470
4 changed files with 228 additions and 2 deletions

View File

@@ -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(<AnalysisResults report={report} />);
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(<AnalysisResults report={report} />);
expect(queryByTestId(container, "analysis-error-202")).toBeNull();
});
});

View File

@@ -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}
</div>
</section>
{result ? (
<AnalysisResults report={result} />
) : null}
</div>
</div>
</main>

View File

@@ -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 (
<section
data-testid="analysis-results"
className="rounded-2xl border border-info/10 bg-surface p-6 shadow-[0_0_40px_rgba(15,23,42,0.25)]"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-xs uppercase tracking-[0.2em] text-info/90">
Analysis Report
</span>
<span className="font-mono text-[10px] text-text/50">
{report.metadata.passedTests} passed / {report.metadata.failedTests} failed
</span>
</div>
<ul className="mt-4 space-y-4">
{report.results.map((result) => {
const statusStyle = statusStyles[result.status];
const severityStyle = severityStyles[result.severity];
return (
<li
key={result.testId}
data-testid={`analysis-result-${result.testId}`}
className="rounded-2xl border border-info/10 bg-background/40 p-4"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-col">
<span className="text-sm font-semibold text-text/90">
{result.testName}
</span>
<span className="text-xs text-text/50">
Test #{result.testId} · {result.headerName}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 text-[11px] uppercase tracking-[0.2em]">
<span
className={`rounded-full border px-2 py-1 ${statusStyle.className}`}
>
{statusStyle.label}
</span>
<span
className={`rounded-full border px-2 py-1 ${severityStyle.className}`}
>
{severityStyle.label}
</span>
</div>
</div>
{result.analysis ? (
<p className="mt-3 text-sm text-text/70">{result.analysis}</p>
) : null}
{result.description ? (
<p className="mt-1 text-xs text-text/50">{result.description}</p>
) : null}
{result.status === "error" ? (
<div
role="alert"
data-testid={`analysis-error-${result.testId}`}
className="mt-3 flex items-start gap-2 rounded-xl border border-spam/30 bg-spam/10 p-3 text-xs text-spam"
>
<FontAwesomeIcon icon={faTriangleExclamation} className="mt-0.5" />
<span>
<span className="font-semibold">Error:</span>{" "}
{getErrorMessage(result)}
</span>
</div>
) : null}
</li>
);
})}
</ul>
</section>
);
}