=> {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+};
+
+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 baseConfig: AnalysisConfig = {
+ testIds: [101],
+ resolve: true,
+ decodeAll: false,
+};
+
+const baseRequest: AnalysisRequest = {
+ headers: "X-Test: value",
+ config: baseConfig,
+};
+
+const progressEvent: AnalysisProgress = {
+ currentIndex: 0,
+ totalTests: 3,
+ currentTest: "SpamAssassin Rule Hits",
+ elapsedMs: 500,
+ percentage: 33,
+};
+
+const completeReport: AnalysisReport = {
+ results: [],
+ hopChain: [],
+ securityAppliances: [],
+ metadata: {
+ totalTests: 3,
+ passedTests: 3,
+ failedTests: 0,
+ skippedTests: 0,
+ elapsedMs: 1500,
+ timedOut: false,
+ incompleteTests: [],
+ },
+};
+
+const timeoutReport: AnalysisReport = {
+ results: [],
+ hopChain: [],
+ securityAppliances: [],
+ metadata: {
+ totalTests: 5,
+ passedTests: 2,
+ failedTests: 1,
+ skippedTests: 0,
+ elapsedMs: 30000,
+ timedOut: true,
+ incompleteTests: ["Mimecast Fingerprint", "Proofpoint TAP"],
+ },
+};
+
+const AnalysisHarness = ({ request, onStatusChange }: HarnessProps) => {
+ const { status, progress, result, error, submit, cancel } = useAnalysis();
+
+ useEffect(() => {
+ onStatusChange?.(status);
+ }, [status, onStatusChange]);
+
+ return (
+
+ {status}
+ {progress?.currentTest ?? ""}
+ {progress?.percentage ?? ""}
+ {result?.metadata.totalTests ?? ""}
+ {error ?? ""}
+
+
+
+ );
+};
+
+afterEach(() => {
+ while (cleanups.length > 0) {
+ const cleanup = cleanups.pop();
+ if (cleanup) {
+ cleanup();
+ }
+ }
+ vi.restoreAllMocks();
+});
+
+describe("useAnalysis", () => {
+ it("starts in the idle state", () => {
+ const { container } = render();
+
+ expect(getByTestId(container, "status").textContent).toBe("idle");
+ expect(getByTestId(container, "current-test").textContent).toBe("");
+ expect(getByTestId(container, "error").textContent).toBe("");
+ });
+
+ it("submits analysis and handles SSE progress + result", async () => {
+ const streamSpy = vi.spyOn(apiClient, "stream").mockImplementation(
+ async (_path, options) => {
+ options.onEvent({ event: "progress", data: progressEvent, raw: "" });
+ options.onEvent({ event: "result", data: completeReport, raw: "" });
+ },
+ );
+
+ const statuses: string[] = [];
+ const { container } = render(
+ statuses.push(status)} />,
+ );
+
+ act(() => {
+ getByTestId(container, "submit").dispatchEvent(
+ new MouseEvent("click", { bubbles: true }),
+ );
+ });
+
+ await act(async () => {
+ await flushPromises();
+ });
+
+ expect(streamSpy).toHaveBeenCalledWith(
+ "/api/analyse",
+ expect.objectContaining({
+ body: baseRequest,
+ onEvent: expect.any(Function),
+ signal: expect.any(AbortSignal),
+ }),
+ );
+
+ expect(statuses).toEqual(["idle", "submitting", "analysing", "complete"]);
+ expect(getByTestId(container, "current-test").textContent).toMatch(
+ /SpamAssassin Rule Hits/,
+ );
+ expect(getByTestId(container, "percentage").textContent).toBe("33");
+ expect(getByTestId(container, "result-total").textContent).toBe("3");
+ });
+
+ it("marks the analysis as timed out when the report indicates timeout", async () => {
+ vi.spyOn(apiClient, "stream").mockImplementation(async (_path, options) => {
+ options.onEvent({ event: "result", data: timeoutReport, raw: "" });
+ });
+
+ const statuses: string[] = [];
+ const { container } = render(
+ statuses.push(status)} />,
+ );
+
+ act(() => {
+ getByTestId(container, "submit").dispatchEvent(
+ new MouseEvent("click", { bubbles: true }),
+ );
+ });
+
+ await act(async () => {
+ await flushPromises();
+ });
+
+ expect(statuses).toEqual(["idle", "submitting", "timeout"]);
+ expect(getByTestId(container, "result-total").textContent).toBe("5");
+ });
+
+ it("surfaces stream errors and moves to error state", async () => {
+ vi.spyOn(apiClient, "stream").mockRejectedValue(new Error("Network error"));
+
+ const statuses: string[] = [];
+ const { container } = render(
+ statuses.push(status)} />,
+ );
+
+ act(() => {
+ getByTestId(container, "submit").dispatchEvent(
+ new MouseEvent("click", { bubbles: true }),
+ );
+ });
+
+ await act(async () => {
+ await flushPromises();
+ });
+
+ expect(statuses).toEqual(["idle", "submitting", "error"]);
+ expect(getByTestId(container, "error").textContent ?? "").toMatch(/Network error/);
+ });
+
+ it("cancels an in-flight analysis request", async () => {
+ let abortSignal: AbortSignal | undefined;
+ vi.spyOn(apiClient, "stream").mockImplementation(async (_path, options) => {
+ abortSignal = options.signal;
+ await new Promise((resolve) => {
+ if (abortSignal?.aborted) {
+ resolve();
+ return;
+ }
+ abortSignal?.addEventListener("abort", () => resolve(), { once: true });
+ });
+ });
+
+ const statuses: string[] = [];
+ const { container } = render(
+ statuses.push(status)} />,
+ );
+
+ act(() => {
+ getByTestId(container, "submit").dispatchEvent(
+ new MouseEvent("click", { bubbles: true }),
+ );
+ });
+
+ await act(async () => {
+ await flushPromises();
+ });
+
+ act(() => {
+ getByTestId(container, "cancel").dispatchEvent(
+ new MouseEvent("click", { bubbles: true }),
+ );
+ });
+
+ await act(async () => {
+ await flushPromises();
+ });
+
+ expect(abortSignal?.aborted).toBe(true);
+ expect(statuses).toContain("idle");
+ });
+});