MAESTRO: wire analyse submit to SSE stream

This commit is contained in:
Mariusz Banach
2026-02-18 02:20:42 +01:00
parent 247ac7d27d
commit 1a041a0d59
3 changed files with 117 additions and 22 deletions

View File

@@ -41,7 +41,7 @@ Frontend uses `fetch` with `ReadableStream` reader (not native `EventSource`, wh
- [x] `pytest backend/tests/api/test_analysis_router.py` passes (all paths: happy, error, oversized, partial failure, timeout) - [x] `pytest backend/tests/api/test_analysis_router.py` passes (all paths: happy, error, oversized, partial failure, timeout)
- [x] All vitest tests pass: `npx vitest run src/__tests__/ProgressIndicator.test.tsx src/__tests__/useAnalysis.test.ts` - [x] All vitest tests pass: `npx vitest run src/__tests__/ProgressIndicator.test.tsx src/__tests__/useAnalysis.test.ts`
- [ ] Submitting headers triggers backend analysis with SSE streaming - [x] Submitting headers triggers backend analysis with SSE streaming
- [ ] Progress bar updates in real-time showing current test name and percentage - [ ] Progress bar updates in real-time showing current test name and percentage
- [ ] Countdown timer counts down from 30 seconds - [ ] Countdown timer counts down from 30 seconds
- [ ] Partial failures show inline error indicators per FR-25 - [ ] Partial failures show inline error indicators per FR-25

View File

@@ -0,0 +1,101 @@
import type { ReactElement } from "react";
import { act } from "react-dom/test-utils";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import Home from "../app/page";
const { submitSpy, cancelSpy } = vi.hoisted(() => ({
submitSpy: vi.fn().mockResolvedValue(undefined),
cancelSpy: vi.fn(),
}));
vi.mock("../hooks/useAnalysis", () => ({
__esModule: true,
default: () => ({
status: "idle",
progress: null,
result: null,
error: null,
submit: submitSpy,
cancel: cancelSpy,
}),
}));
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 getTextarea = (container: HTMLElement): HTMLTextAreaElement => {
const textarea = container.querySelector("textarea");
if (!textarea) {
throw new Error("Expected header textarea to be rendered.");
}
return textarea as HTMLTextAreaElement;
};
const getAnalyseButton = (container: HTMLElement): HTMLButtonElement => {
const buttons = Array.from(container.querySelectorAll("button"));
const button = buttons.find((candidate) =>
(candidate.textContent ?? "").toLowerCase().includes("analyse"),
);
if (!button) {
throw new Error("Expected analyse button to be rendered.");
}
return button as HTMLButtonElement;
};
afterEach(() => {
while (cleanups.length > 0) {
const cleanup = cleanups.pop();
if (cleanup) {
cleanup();
}
}
submitSpy.mockClear();
cancelSpy.mockClear();
});
describe("Home page", () => {
it("submits analysis when analyse is clicked", () => {
const { container } = render(<Home />);
const textarea = getTextarea(container);
act(() => {
textarea.value = "Received: from mail.example.com";
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
const button = getAnalyseButton(container);
act(() => {
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(submitSpy).toHaveBeenCalledTimes(1);
expect(submitSpy).toHaveBeenCalledWith({
headers: "Received: from mail.example.com",
config: { testIds: [], resolve: false, decodeAll: false },
});
});
});

View File

@@ -1,41 +1,35 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useCallback, useState } from "react";
import AnalyseButton from "../components/AnalyseButton"; import AnalyseButton from "../components/AnalyseButton";
import FileDropZone from "../components/FileDropZone"; import FileDropZone from "../components/FileDropZone";
import HeaderInput from "../components/HeaderInput"; import HeaderInput from "../components/HeaderInput";
import useAnalysis from "../hooks/useAnalysis";
import { MAX_HEADER_INPUT_BYTES } from "../lib/header-validation"; import { MAX_HEADER_INPUT_BYTES } from "../lib/header-validation";
import type { AnalysisConfig } from "../types/analysis";
const defaultConfig: AnalysisConfig = {
testIds: [],
resolve: false,
decodeAll: false,
};
export default function Home() { export default function Home() {
const [headerInput, setHeaderInput] = useState(""); const [headerInput, setHeaderInput] = useState("");
const [isAnalyzing, setIsAnalyzing] = useState(false); const { status, submit } = useAnalysis();
const hasHeaderInput = headerInput.trim().length > 0; const hasHeaderInput = headerInput.trim().length > 0;
const isOversized = headerInput.length > MAX_HEADER_INPUT_BYTES; const isOversized = headerInput.length > MAX_HEADER_INPUT_BYTES;
const canAnalyse = hasHeaderInput && !isOversized; const canAnalyse = hasHeaderInput && !isOversized;
const analyseTimeoutRef = useRef<number | null>(null); const isLoading = status === "submitting" || status === "analysing";
useEffect(() => { const handleAnalyse = useCallback(() => {
return () => {
if (analyseTimeoutRef.current !== null) {
window.clearTimeout(analyseTimeoutRef.current);
}
};
}, []);
const handleAnalyse = () => {
if (!canAnalyse) { if (!canAnalyse) {
return; return;
} }
setIsAnalyzing(true); void submit({ headers: headerInput, config: defaultConfig });
if (analyseTimeoutRef.current !== null) { }, [canAnalyse, headerInput, submit]);
window.clearTimeout(analyseTimeoutRef.current);
}
analyseTimeoutRef.current = window.setTimeout(() => {
setIsAnalyzing(false);
}, 800);
};
return ( return (
<main className="min-h-screen bg-background text-text"> <main className="min-h-screen bg-background text-text">
@@ -70,7 +64,7 @@ export default function Home() {
<AnalyseButton <AnalyseButton
hasInput={canAnalyse} hasInput={canAnalyse}
onAnalyse={handleAnalyse} onAnalyse={handleAnalyse}
isLoading={isAnalyzing} isLoading={isLoading}
/> />
<div className="flex items-center gap-2 text-xs text-text/60"> <div className="flex items-center gap-2 text-xs text-text/60">
<kbd className="rounded-md border border-info/30 bg-background/40 px-2 py-1 font-mono"> <kbd className="rounded-md border border-info/30 bg-background/40 px-2 py-1 font-mono">