diff --git a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-05-Analysis-Execution.md b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-05-Analysis-Execution.md
index 7a6e035..71357be 100644
--- a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-05-Analysis-Execution.md
+++ b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-05-Analysis-Execution.md
@@ -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] 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
- [ ] Countdown timer counts down from 30 seconds
- [ ] Partial failures show inline error indicators per FR-25
diff --git a/frontend/src/__tests__/HomePage.test.tsx b/frontend/src/__tests__/HomePage.test.tsx
new file mode 100644
index 0000000..498b938
--- /dev/null
+++ b/frontend/src/__tests__/HomePage.test.tsx
@@ -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();
+ 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 },
+ });
+ });
+});
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 6c91a15..5c608f4 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -1,41 +1,35 @@
"use client";
-import { useEffect, useRef, useState } from "react";
+import { useCallback, useState } from "react";
import AnalyseButton from "../components/AnalyseButton";
import FileDropZone from "../components/FileDropZone";
import HeaderInput from "../components/HeaderInput";
+import useAnalysis from "../hooks/useAnalysis";
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() {
const [headerInput, setHeaderInput] = useState("");
- const [isAnalyzing, setIsAnalyzing] = useState(false);
+ const { status, submit } = useAnalysis();
const hasHeaderInput = headerInput.trim().length > 0;
const isOversized = headerInput.length > MAX_HEADER_INPUT_BYTES;
const canAnalyse = hasHeaderInput && !isOversized;
- const analyseTimeoutRef = useRef(null);
+ const isLoading = status === "submitting" || status === "analysing";
- useEffect(() => {
- return () => {
- if (analyseTimeoutRef.current !== null) {
- window.clearTimeout(analyseTimeoutRef.current);
- }
- };
- }, []);
-
- const handleAnalyse = () => {
+ const handleAnalyse = useCallback(() => {
if (!canAnalyse) {
return;
}
- setIsAnalyzing(true);
- if (analyseTimeoutRef.current !== null) {
- window.clearTimeout(analyseTimeoutRef.current);
- }
- analyseTimeoutRef.current = window.setTimeout(() => {
- setIsAnalyzing(false);
- }, 800);
- };
+ void submit({ headers: headerInput, config: defaultConfig });
+ }, [canAnalyse, headerInput, submit]);
return (
@@ -70,7 +64,7 @@ export default function Home() {