MAESTRO: fix formatting for report lint

This commit is contained in:
Mariusz Banach
2026-02-18 03:20:58 +01:00
parent 497f7cec7e
commit 48d4b2dd56
20 changed files with 72 additions and 194 deletions

View File

@@ -56,5 +56,5 @@ ReportContainer
- [x] Export JSON produces a valid JSON file containing all results - [x] Export JSON produces a valid JSON file containing all results
- [x] Export HTML produces a styled standalone page viewable in any browser - [x] Export HTML produces a styled standalone page viewable in any browser
- [x] All report components are keyboard accessible - [x] All report components are keyboard accessible
- [ ] Linting passes (`npx eslint src/`, `npx prettier --check src/`) - [x] Linting passes (`npx eslint src/`, `npx prettier --check src/`)
- [ ] Run `/speckit.analyze` to verify consistency - [ ] Run `/speckit.analyze` to verify consistency

View File

@@ -105,9 +105,7 @@ describe("AnalysisControls", () => {
getTestSelector(container); getTestSelector(container);
expect(getToggle(container, "toggle-resolve").getAttribute("aria-checked")).toBe("false"); expect(getToggle(container, "toggle-resolve").getAttribute("aria-checked")).toBe("false");
expect(getToggle(container, "toggle-decode-all").getAttribute("aria-checked")).toBe( expect(getToggle(container, "toggle-decode-all").getAttribute("aria-checked")).toBe("false");
"false",
);
}); });
it("updates toggles without a controlled config", async () => { it("updates toggles without a controlled config", async () => {
@@ -126,9 +124,7 @@ describe("AnalysisControls", () => {
}); });
expect(decodeToggle.getAttribute("aria-checked")).toBe("true"); expect(decodeToggle.getAttribute("aria-checked")).toBe("true");
expect(handleChange).toHaveBeenLastCalledWith( expect(handleChange).toHaveBeenLastCalledWith(expect.objectContaining({ decodeAll: true }));
expect.objectContaining({ decodeAll: true }),
);
}); });
it("updates toggles on click and keyboard", async () => { it("updates toggles on click and keyboard", async () => {
@@ -168,9 +164,7 @@ describe("AnalysisControls", () => {
const decodeToggle = getToggle(container, "toggle-decode-all"); const decodeToggle = getToggle(container, "toggle-decode-all");
act(() => { act(() => {
decodeToggle.dispatchEvent( decodeToggle.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
}); });
expect(decodeToggle.getAttribute("aria-checked")).toBe("true"); expect(decodeToggle.getAttribute("aria-checked")).toBe("true");
@@ -206,14 +200,10 @@ describe("AnalysisControls", () => {
const resolveToggle = getToggle(container, "toggle-resolve"); const resolveToggle = getToggle(container, "toggle-resolve");
act(() => { act(() => {
resolveToggle.dispatchEvent( resolveToggle.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true }));
new KeyboardEvent("keydown", { key: " ", bubbles: true }),
);
}); });
expect(resolveToggle.getAttribute("aria-checked")).toBe("true"); expect(resolveToggle.getAttribute("aria-checked")).toBe("true");
expect(handleChange).toHaveBeenLastCalledWith( expect(handleChange).toHaveBeenLastCalledWith(expect.objectContaining({ resolve: true }));
expect.objectContaining({ resolve: true }),
);
}); });
}); });

View File

@@ -66,9 +66,7 @@ describe("ProgressIndicator", () => {
expect(indicator.getAttribute("data-status")).toBe("analysing"); expect(indicator.getAttribute("data-status")).toBe("analysing");
expect(indicator.getAttribute("data-variant")).toBe("normal"); expect(indicator.getAttribute("data-variant")).toBe("normal");
expect(getByTestId(container, "progress-percentage").textContent ?? "").toMatch( expect(getByTestId(container, "progress-percentage").textContent ?? "").toMatch(/50%/);
/50%/,
);
expect(getByTestId(container, "progress-current-test").textContent ?? "").toMatch( expect(getByTestId(container, "progress-current-test").textContent ?? "").toMatch(
/SpamAssassin Rule Hits/, /SpamAssassin Rule Hits/,
); );
@@ -117,8 +115,6 @@ describe("ProgressIndicator", () => {
expect(getByTestId(container, "timeout-tests").textContent ?? "").toMatch( expect(getByTestId(container, "timeout-tests").textContent ?? "").toMatch(
/Mimecast Fingerprint/, /Mimecast Fingerprint/,
); );
expect(getByTestId(container, "timeout-tests").textContent ?? "").toMatch( expect(getByTestId(container, "timeout-tests").textContent ?? "").toMatch(/Proofpoint TAP/);
/Proofpoint TAP/,
);
}); });
}); });

View File

@@ -180,9 +180,7 @@ describe("TestSelector", () => {
const selectAllButton = getSelectAllButton(container); const selectAllButton = getSelectAllButton(container);
act(() => { act(() => {
selectAllButton.dispatchEvent( selectAllButton.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
}); });
sampleTests.forEach((test) => { sampleTests.forEach((test) => {
@@ -191,9 +189,7 @@ describe("TestSelector", () => {
const deselectAllButton = getDeselectAllButton(container); const deselectAllButton = getDeselectAllButton(container);
act(() => { act(() => {
deselectAllButton.dispatchEvent( deselectAllButton.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true }));
new KeyboardEvent("keydown", { key: " ", bubbles: true }),
);
}); });
sampleTests.forEach((test) => { sampleTests.forEach((test) => {

View File

@@ -84,9 +84,7 @@ describe("HopChainVisualisation", () => {
it("renders connectors between hop nodes", () => { it("renders connectors between hop nodes", () => {
const { container } = render(<HopChainVisualisation hopChain={hopChain} />); const { container } = render(<HopChainVisualisation hopChain={hopChain} />);
const connectors = container.querySelectorAll( const connectors = container.querySelectorAll('[data-testid^="hop-chain-connector-"]');
'[data-testid^="hop-chain-connector-"]',
);
expect(connectors.length).toBe(hopChain.length - 1); expect(connectors.length).toBe(hopChain.length - 1);
}); });

View File

@@ -92,18 +92,14 @@ beforeEach(() => {
} }
lastBlob = null; lastBlob = null;
createObjectUrlSpy = vi createObjectUrlSpy = vi.spyOn(URL, "createObjectURL").mockImplementation((blob: Blob) => {
.spyOn(URL, "createObjectURL") lastBlob = blob;
.mockImplementation((blob: Blob) => { return "blob:report";
lastBlob = blob; });
return "blob:report";
});
revokeObjectUrlSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => { revokeObjectUrlSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {
return undefined; return undefined;
}); });
clickSpy = vi clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(() => undefined);
}); });
afterEach(() => { afterEach(() => {

View File

@@ -63,9 +63,7 @@ afterEach(() => {
describe("SecurityAppliancesSummary", () => { describe("SecurityAppliancesSummary", () => {
it("renders detected security appliances as badges", () => { it("renders detected security appliances as badges", () => {
const { container } = render( const { container } = render(<SecurityAppliancesSummary appliances={sampleAppliances} />);
<SecurityAppliancesSummary appliances={sampleAppliances} />,
);
const summary = getByTestId(container, "security-appliances-summary"); const summary = getByTestId(container, "security-appliances-summary");
expect(summary.textContent ?? "").toContain("Mimecast Email Security"); expect(summary.textContent ?? "").toContain("Mimecast Email Security");

View File

@@ -83,10 +83,7 @@ describe("TestResultCard", () => {
const { container } = render(<TestResultCard result={result} />); const { container } = render(<TestResultCard result={result} />);
const severityBadge = getByTestId( const severityBadge = getByTestId(container, `test-result-severity-${result.testId}`);
container,
`test-result-severity-${result.testId}`,
);
expect(severityBadge.textContent ?? "").toContain(severityCase.label); expect(severityBadge.textContent ?? "").toContain(severityCase.label);
expect(severityBadge.className).toContain(severityCase.className); expect(severityBadge.className).toContain(severityCase.className);

View File

@@ -148,12 +148,10 @@ describe("useAnalysis", () => {
}); });
it("submits analysis and handles SSE progress + result", async () => { it("submits analysis and handles SSE progress + result", async () => {
const streamSpy = vi.spyOn(apiClient, "stream").mockImplementation( const streamSpy = vi.spyOn(apiClient, "stream").mockImplementation(async (_path, options) => {
async (_path, options) => { options.onEvent({ event: "progress", data: progressEvent, raw: "" });
options.onEvent({ event: "progress", data: progressEvent, raw: "" }); options.onEvent({ event: "result", data: completeReport, raw: "" });
options.onEvent({ event: "result", data: completeReport, raw: "" }); });
},
);
const statuses: string[] = []; const statuses: string[] = [];
const { container } = render( const { container } = render(
@@ -161,9 +159,7 @@ describe("useAnalysis", () => {
); );
act(() => { act(() => {
getByTestId(container, "submit").dispatchEvent( getByTestId(container, "submit").dispatchEvent(new MouseEvent("click", { bubbles: true }));
new MouseEvent("click", { bubbles: true }),
);
}); });
await act(async () => { await act(async () => {
@@ -180,9 +176,7 @@ describe("useAnalysis", () => {
); );
expect(statuses).toEqual(["idle", "submitting", "analysing", "complete"]); expect(statuses).toEqual(["idle", "submitting", "analysing", "complete"]);
expect(getByTestId(container, "current-test").textContent).toMatch( expect(getByTestId(container, "current-test").textContent).toMatch(/SpamAssassin Rule Hits/);
/SpamAssassin Rule Hits/,
);
expect(getByTestId(container, "percentage").textContent).toBe("33"); expect(getByTestId(container, "percentage").textContent).toBe("33");
expect(getByTestId(container, "result-total").textContent).toBe("3"); expect(getByTestId(container, "result-total").textContent).toBe("3");
}); });
@@ -198,9 +192,7 @@ describe("useAnalysis", () => {
); );
act(() => { act(() => {
getByTestId(container, "submit").dispatchEvent( getByTestId(container, "submit").dispatchEvent(new MouseEvent("click", { bubbles: true }));
new MouseEvent("click", { bubbles: true }),
);
}); });
await act(async () => { await act(async () => {
@@ -220,9 +212,7 @@ describe("useAnalysis", () => {
); );
act(() => { act(() => {
getByTestId(container, "submit").dispatchEvent( getByTestId(container, "submit").dispatchEvent(new MouseEvent("click", { bubbles: true }));
new MouseEvent("click", { bubbles: true }),
);
}); });
await act(async () => { await act(async () => {
@@ -252,9 +242,7 @@ describe("useAnalysis", () => {
); );
act(() => { act(() => {
getByTestId(container, "submit").dispatchEvent( getByTestId(container, "submit").dispatchEvent(new MouseEvent("click", { bubbles: true }));
new MouseEvent("click", { bubbles: true }),
);
}); });
await act(async () => { await act(async () => {
@@ -262,9 +250,7 @@ describe("useAnalysis", () => {
}); });
act(() => { act(() => {
getByTestId(container, "cancel").dispatchEvent( getByTestId(container, "cancel").dispatchEvent(new MouseEvent("click", { bubbles: true }));
new MouseEvent("click", { bubbles: true }),
);
}); });
await act(async () => { await act(async () => {

View File

@@ -93,9 +93,7 @@ export default function Home() {
</div> </div>
</section> </section>
{result ? ( {result ? <AnalysisResults report={result} /> : null}
<AnalysisResults report={result} />
) : null}
</div> </div>
</div> </div>
</main> </main>

View File

@@ -2,12 +2,7 @@
import { useState, type KeyboardEvent } from "react"; import { useState, type KeyboardEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import { faToggleOff, faToggleOn, faGlobe, faCode } from "@fortawesome/free-solid-svg-icons";
faToggleOff,
faToggleOn,
faGlobe,
faCode,
} from "@fortawesome/free-solid-svg-icons";
import type { AnalysisConfig } from "../types/analysis"; import type { AnalysisConfig } from "../types/analysis";
import TestSelector from "./TestSelector"; import TestSelector from "./TestSelector";
@@ -33,10 +28,7 @@ const handleToggleKeyDown = (
} }
}; };
export default function AnalysisControls({ export default function AnalysisControls({ config, onChange }: AnalysisControlsProps) {
config,
onChange,
}: AnalysisControlsProps) {
const [internalConfig, setInternalConfig] = useState<AnalysisConfig>(defaultConfig); const [internalConfig, setInternalConfig] = useState<AnalysisConfig>(defaultConfig);
const resolvedConfig = config ?? internalConfig; const resolvedConfig = config ?? internalConfig;

View File

@@ -3,31 +3,20 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import type { import type { AnalysisReport, TestResult, TestSeverity, TestStatus } from "../types/analysis";
AnalysisReport,
TestResult,
TestSeverity,
TestStatus,
} from "../types/analysis";
type AnalysisResultsProps = { type AnalysisResultsProps = {
report: AnalysisReport; report: AnalysisReport;
}; };
const severityStyles: Record< const severityStyles: Record<TestSeverity, { label: string; className: string }> = {
TestSeverity,
{ label: string; className: string }
> = {
spam: { label: "Spam", className: "text-spam border-spam/40" }, spam: { label: "Spam", className: "text-spam border-spam/40" },
suspicious: { label: "Suspicious", className: "text-suspicious border-suspicious/40" }, suspicious: { label: "Suspicious", className: "text-suspicious border-suspicious/40" },
clean: { label: "Clean", className: "text-clean border-clean/40" }, clean: { label: "Clean", className: "text-clean border-clean/40" },
info: { label: "Info", className: "text-accent border-accent/40" }, info: { label: "Info", className: "text-accent border-accent/40" },
}; };
const statusStyles: Record< const statusStyles: Record<TestStatus, { label: string; className: string }> = {
TestStatus,
{ label: string; className: string }
> = {
success: { label: "Success", className: "text-clean border-clean/40" }, success: { label: "Success", className: "text-clean border-clean/40" },
error: { label: "Error", className: "text-spam border-spam/40" }, error: { label: "Error", className: "text-spam border-spam/40" },
skipped: { label: "Skipped", className: "text-suspicious border-suspicious/40" }, skipped: { label: "Skipped", className: "text-suspicious border-suspicious/40" },
@@ -45,9 +34,7 @@ export default function AnalysisResults({ report }: AnalysisResultsProps) {
className="rounded-2xl border border-info/10 bg-surface p-6 shadow-[0_0_40px_rgba(15,23,42,0.25)]" 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"> <div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-xs uppercase tracking-[0.2em] text-info/90"> <span className="text-xs uppercase tracking-[0.2em] text-info/90">Analysis Report</span>
Analysis Report
</span>
<span className="font-mono text-[10px] text-text/50"> <span className="font-mono text-[10px] text-text/50">
{report.metadata.passedTests} passed / {report.metadata.failedTests} failed {report.metadata.passedTests} passed / {report.metadata.failedTests} failed
</span> </span>
@@ -66,22 +53,16 @@ export default function AnalysisResults({ report }: AnalysisResultsProps) {
> >
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm font-semibold text-text/90"> <span className="text-sm font-semibold text-text/90">{result.testName}</span>
{result.testName}
</span>
<span className="text-xs text-text/50"> <span className="text-xs text-text/50">
Test #{result.testId} · {result.headerName} Test #{result.testId} · {result.headerName}
</span> </span>
</div> </div>
<div className="flex flex-wrap items-center gap-2 text-[11px] uppercase tracking-[0.2em]"> <div className="flex flex-wrap items-center gap-2 text-[11px] uppercase tracking-[0.2em]">
<span <span className={`rounded-full border px-2 py-1 ${statusStyle.className}`}>
className={`rounded-full border px-2 py-1 ${statusStyle.className}`}
>
{statusStyle.label} {statusStyle.label}
</span> </span>
<span <span className={`rounded-full border px-2 py-1 ${severityStyle.className}`}>
className={`rounded-full border px-2 py-1 ${severityStyle.className}`}
>
{severityStyle.label} {severityStyle.label}
</span> </span>
</div> </div>
@@ -102,8 +83,7 @@ export default function AnalysisResults({ report }: AnalysisResultsProps) {
> >
<FontAwesomeIcon icon={faTriangleExclamation} className="mt-0.5" /> <FontAwesomeIcon icon={faTriangleExclamation} className="mt-0.5" />
<span> <span>
<span className="font-semibold">Error:</span>{" "} <span className="font-semibold">Error:</span> {getErrorMessage(result)}
{getErrorMessage(result)}
</span> </span>
</div> </div>
) : null} ) : null}

View File

@@ -35,11 +35,14 @@ const getVariant = (
return remainingSeconds <= warningThreshold ? "warning" : "normal"; return remainingSeconds <= warningThreshold ? "warning" : "normal";
}; };
const variantStyles: Record<ProgressVariant, { const variantStyles: Record<
bar: string; ProgressVariant,
badge: string; {
track: string; bar: string;
}> = { badge: string;
track: string;
}
> = {
normal: { normal: {
bar: "bg-clean", bar: "bg-clean",
badge: "text-clean border-clean/40", badge: "text-clean border-clean/40",
@@ -94,9 +97,7 @@ export default function ProgressIndicator({
? anchor.elapsedMs + Math.max(0, Date.now() - anchor.timestamp) ? anchor.elapsedMs + Math.max(0, Date.now() - anchor.timestamp)
: baseElapsedMs; : baseElapsedMs;
setElapsedMs((previous) => setElapsedMs((previous) => (previous === nextElapsedMs ? previous : nextElapsedMs));
previous === nextElapsedMs ? previous : nextElapsedMs,
);
}, 1000); }, 1000);
return () => { return () => {
@@ -141,16 +142,10 @@ export default function ProgressIndicator({
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<span <span data-testid="progress-current-test" className="text-sm font-semibold text-text/80">
data-testid="progress-current-test"
className="text-sm font-semibold text-text/80"
>
{progress?.currentTest ?? "Preparing analysis"} {progress?.currentTest ?? "Preparing analysis"}
</span> </span>
<span <span data-testid="progress-percentage" className="text-sm font-semibold text-text/70">
data-testid="progress-percentage"
className="text-sm font-semibold text-text/70"
>
{percentage}% {percentage}%
</span> </span>
</div> </div>
@@ -173,9 +168,7 @@ export default function ProgressIndicator({
role="alert" role="alert"
className="mt-4 rounded-xl border border-spam/30 bg-spam/10 p-4 text-xs text-text/80" className="mt-4 rounded-xl border border-spam/30 bg-spam/10 p-4 text-xs text-text/80"
> >
<p className="font-semibold text-spam"> <p className="font-semibold text-spam">Timeout reached at {timeoutSeconds} seconds.</p>
Timeout reached at {timeoutSeconds} seconds.
</p>
<p className="mt-2 text-text/70">Incomplete tests:</p> <p className="mt-2 text-text/70">Incomplete tests:</p>
<p data-testid="timeout-tests" className="mt-1 font-mono text-text/70"> <p data-testid="timeout-tests" className="mt-1 font-mono text-text/70">
{incompleteTests.length > 0 ? incompleteTests.join(", ") : "None"} {incompleteTests.length > 0 ? incompleteTests.join(", ") : "None"}

View File

@@ -180,9 +180,7 @@ export default function TestSelector({ selectedTestIds, onSelectionChange }: Tes
</button> </button>
</div> </div>
{isLoading ? ( {isLoading ? <p className="text-xs text-text/60">Loading tests...</p> : null}
<p className="text-xs text-text/60">Loading tests...</p>
) : null}
{error ? ( {error ? (
<p role="alert" className="text-xs text-spam"> <p role="alert" className="text-xs text-spam">
{error} {error}

View File

@@ -1,12 +1,7 @@
"use client"; "use client";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import { faArrowDown, faClock, faNetworkWired, faServer } from "@fortawesome/free-solid-svg-icons";
faArrowDown,
faClock,
faNetworkWired,
faServer,
} from "@fortawesome/free-solid-svg-icons";
import type { HopChainNode } from "../../types/analysis"; import type { HopChainNode } from "../../types/analysis";
@@ -21,9 +16,7 @@ const formatDelay = (delay?: number | null): string | null => {
return `${delay.toFixed(2)}s`; return `${delay.toFixed(2)}s`;
}; };
export default function HopChainVisualisation({ export default function HopChainVisualisation({ hopChain }: HopChainVisualisationProps) {
hopChain,
}: HopChainVisualisationProps) {
if (hopChain.length === 0) { if (hopChain.length === 0) {
return ( return (
<section <section
@@ -56,13 +49,9 @@ export default function HopChainVisualisation({
</span> </span>
<div className="flex-1"> <div className="flex-1">
<div className="flex flex-wrap items-baseline gap-2"> <div className="flex flex-wrap items-baseline gap-2">
<span className="text-sm font-semibold text-text/90"> <span className="text-sm font-semibold text-text/90">{node.hostname}</span>
{node.hostname}
</span>
{node.ip ? ( {node.ip ? (
<span className="font-mono text-xs text-text/60"> <span className="font-mono text-xs text-text/60">{node.ip}</span>
{node.ip}
</span>
) : null} ) : null}
</div> </div>
<div className="mt-2 flex flex-wrap gap-4 text-xs text-text/60"> <div className="mt-2 flex flex-wrap gap-4 text-xs text-text/60">
@@ -74,10 +63,7 @@ export default function HopChainVisualisation({
) : null} ) : null}
{node.serverInfo ? ( {node.serverInfo ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<FontAwesomeIcon <FontAwesomeIcon icon={faNetworkWired} className="text-[10px]" />
icon={faNetworkWired}
className="text-[10px]"
/>
{node.serverInfo} {node.serverInfo}
</span> </span>
) : null} ) : null}

View File

@@ -118,9 +118,7 @@ export default function ReportContainer({ report }: { report: AnalysisReport })
> >
<header className="flex flex-wrap items-start justify-between gap-4"> <header className="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<p className="text-xs uppercase tracking-[0.2em] text-info/80"> <p className="text-xs uppercase tracking-[0.2em] text-info/80">Interactive Report</p>
Interactive Report
</p>
<h2 className="text-lg font-semibold text-text/90">Header Analysis Summary</h2> <h2 className="text-lg font-semibold text-text/90">Header Analysis Summary</h2>
</div> </div>
<div className="rounded-full border border-info/20 bg-background/40 px-4 py-2 text-xs text-text/60"> <div className="rounded-full border border-info/20 bg-background/40 px-4 py-2 text-xs text-text/60">
@@ -131,9 +129,7 @@ export default function ReportContainer({ report }: { report: AnalysisReport })
<section className="rounded-2xl border border-info/10 bg-background/40 p-4"> <section className="rounded-2xl border border-info/10 bg-background/40 p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text/90">Summary Stats</h3> <h3 className="text-sm font-semibold text-text/90">Summary Stats</h3>
<span className="text-[10px] uppercase tracking-[0.2em] text-text/40"> <span className="text-[10px] uppercase tracking-[0.2em] text-text/40">Totals</span>
Totals
</span>
</div> </div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{summaryItems.map((item) => ( {summaryItems.map((item) => (
@@ -180,10 +176,7 @@ export default function ReportContainer({ report }: { report: AnalysisReport })
<div className="mt-4 flex flex-col gap-4"> <div className="mt-4 flex flex-col gap-4">
{filteredResults.map((result) => ( {filteredResults.map((result) => (
<div <div key={result.testId} data-testid={`test-result-card-${result.testId}`}>
key={result.testId}
data-testid={`test-result-card-${result.testId}`}
>
<TestResultCard result={result} highlightQuery={query} /> <TestResultCard result={result} highlightQuery={query} />
</div> </div>
))} ))}

View File

@@ -86,7 +86,7 @@ const buildHtmlReport = (report: AnalysisReport): string => {
const renderHopChain = (): string => { const renderHopChain = (): string => {
if (report.hopChain.length === 0) { if (report.hopChain.length === 0) {
return "<p class=\"muted\">No hop chain data available.</p>"; return '<p class="muted">No hop chain data available.</p>';
} }
const items = report.hopChain const items = report.hopChain
@@ -108,7 +108,7 @@ const buildHtmlReport = (report: AnalysisReport): string => {
const renderSecurityAppliances = (): string => { const renderSecurityAppliances = (): string => {
if (report.securityAppliances.length === 0) { if (report.securityAppliances.length === 0) {
return "<p class=\"muted\">No security appliances detected.</p>"; return '<p class="muted">No security appliances detected.</p>';
} }
const items = report.securityAppliances const items = report.securityAppliances

View File

@@ -9,9 +9,7 @@ type SecurityAppliancesSummaryProps = {
appliances: SecurityAppliance[]; appliances: SecurityAppliance[];
}; };
export default function SecurityAppliancesSummary({ export default function SecurityAppliancesSummary({ appliances }: SecurityAppliancesSummaryProps) {
appliances,
}: SecurityAppliancesSummaryProps) {
const hasAppliances = appliances.length > 0; const hasAppliances = appliances.length > 0;
return ( return (
@@ -46,10 +44,7 @@ export default function SecurityAppliancesSummary({
))} ))}
</div> </div>
) : ( ) : (
<p <p data-testid="security-appliances-empty" className="mt-4 text-sm text-text/60">
data-testid="security-appliances-empty"
className="mt-4 text-sm text-text/60"
>
No security appliances detected. No security appliances detected.
</p> </p>
)} )}

View File

@@ -58,10 +58,7 @@ const highlightText = (text: string, query: string): ReactNode => {
const matchText = text.slice(matchIndex, matchIndex + normalizedQuery.length); const matchText = text.slice(matchIndex, matchIndex + normalizedQuery.length);
parts.push( parts.push(
<mark <mark key={`${matchIndex}-${matchText}`} className="rounded bg-accent/20 px-1 text-accent">
key={`${matchIndex}-${matchText}`}
className="rounded bg-accent/20 px-1 text-accent"
>
{matchText} {matchText}
</mark>, </mark>,
); );
@@ -138,9 +135,7 @@ export default function TestResultCard({ result, highlightQuery = "" }: TestResu
<div className="overflow-hidden"> <div className="overflow-hidden">
<div className="rounded-xl border border-info/10 bg-background/40 p-3"> <div className="rounded-xl border border-info/10 bg-background/40 p-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-[0.2em] text-text/40"> <span className="text-[10px] uppercase tracking-[0.2em] text-text/40">Header</span>
Header
</span>
<span className="text-xs text-text/60"> <span className="text-xs text-text/60">
{highlightText(result.headerName, highlightQuery)} {highlightText(result.headerName, highlightQuery)}
</span> </span>

View File

@@ -4,13 +4,7 @@ import { flushSync } from "react-dom";
import { apiClient, type SseEvent } from "../lib/api-client"; import { apiClient, type SseEvent } from "../lib/api-client";
import type { AnalysisConfig, AnalysisProgress, AnalysisReport } from "../types/analysis"; import type { AnalysisConfig, AnalysisProgress, AnalysisReport } from "../types/analysis";
export type AnalysisStatus = export type AnalysisStatus = "idle" | "submitting" | "analysing" | "complete" | "error" | "timeout";
| "idle"
| "submitting"
| "analysing"
| "complete"
| "error"
| "timeout";
export interface AnalysisRequest { export interface AnalysisRequest {
headers: string; headers: string;
@@ -112,14 +106,11 @@ const useAnalysis = (): UseAnalysisState => {
hasProgressRef.current = false; hasProgressRef.current = false;
try { try {
await apiClient.stream<AnalysisRequest, AnalysisProgress | AnalysisReport>( await apiClient.stream<AnalysisRequest, AnalysisProgress | AnalysisReport>("/api/analyse", {
"/api/analyse", body: request,
{ signal: controller.signal,
body: request, onEvent: (event) => handleEvent(event, requestId, controller.signal),
signal: controller.signal, });
onEvent: (event) => handleEvent(event, requestId, controller.signal),
},
);
} catch (err) { } catch (err) {
if (!mountedRef.current || controller.signal.aborted) { if (!mountedRef.current || controller.signal.aborted) {
return; return;