From 94848c6f33e80de7d6b4352436e9cd33dad33b0f Mon Sep 17 00:00:00 2001 From: Mariusz Banach Date: Wed, 18 Feb 2026 02:07:59 +0100 Subject: [PATCH] MAESTRO: implement analysis hook --- ...er-analyzer-Phase-05-Analysis-Execution.md | 2 +- ...eAnalysis.test.ts => useAnalysis.test.tsx} | 0 frontend/src/hooks/useAnalysis.ts | 160 ++++++++++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) rename frontend/src/__tests__/{useAnalysis.test.ts => useAnalysis.test.tsx} (100%) create mode 100644 frontend/src/hooks/useAnalysis.ts 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 cfb797c..95b17a6 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 @@ -34,7 +34,7 @@ Frontend uses `fetch` with `ReadableStream` reader (not native `EventSource`, wh - [x] T025 [US3] Write failing tests (TDD Red) in `backend/tests/api/test_analysis_router.py` — happy path (valid headers → 200 with SSE progress + result), error path (empty → 400), oversized (>1MB → 413), partial failure (some tests error → mixed results per FR-25), timeout (30s limit per NFR-13, partial results per NFR-14) - [x] T026 [US3] Create `backend/app/schemas/analysis.py` (request/response schemas) and `backend/app/routers/analysis.py` — FastAPI router with `POST /api/analyse` using SSE for progress streaming. Accepts headers string + config (test IDs, resolve, decode-all). Invokes `HeaderAnalyzer` with 30s timeout (NFR-13). Streams progress events then final result. Sanitises input (NFR-09), validates size ≤1MB (NFR-10). Stateless — no job_id, no in-memory state (Assumption 3). Register router in `backend/app/main.py`. Verify `test_analysis_router.py` passes (TDD Green) - [x] T027 [US3] Write failing tests (TDD Red) in `frontend/src/__tests__/ProgressIndicator.test.tsx` (render at various states, timeout display) and `frontend/src/__tests__/useAnalysis.test.ts` (hook state transitions, SSE handling) -- [ ] T028 [P] [US3] Create `frontend/src/hooks/useAnalysis.ts` — custom hook managing analysis lifecycle. Submits to `POST /api/analyse` via API client, consumes SSE stream for real-time progress (no polling). States: idle, submitting, analysing (with progress), complete, error, timeout. Returns: `submit()`, `cancel()`, `progress`, `result`, `error`, `status`. Verify `useAnalysis.test.ts` passes (TDD Green) +- [x] T028 [P] [US3] Create `frontend/src/hooks/useAnalysis.ts` — custom hook managing analysis lifecycle. Submits to `POST /api/analyse` via API client, consumes SSE stream for real-time progress (no polling). States: idle, submitting, analysing (with progress), complete, error, timeout. Returns: `submit()`, `cancel()`, `progress`, `result`, `error`, `status`. Verify `useAnalysis.test.ts` passes (TDD Green) - [ ] T029 [P] [US3] Create `frontend/src/components/ProgressIndicator.tsx` — progress bar with percentage, current test name (FR-22), countdown timer from 30s (NFR-13), elapsed time. Colour-coded: green progressing, amber near timeout, red on timeout. FontAwesome spinner. Timeout notification listing incomplete tests (NFR-14). Verify `ProgressIndicator.test.tsx` passes (TDD Green) ## Completion diff --git a/frontend/src/__tests__/useAnalysis.test.ts b/frontend/src/__tests__/useAnalysis.test.tsx similarity index 100% rename from frontend/src/__tests__/useAnalysis.test.ts rename to frontend/src/__tests__/useAnalysis.test.tsx diff --git a/frontend/src/hooks/useAnalysis.ts b/frontend/src/hooks/useAnalysis.ts new file mode 100644 index 0000000..a2d4c08 --- /dev/null +++ b/frontend/src/hooks/useAnalysis.ts @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { flushSync } from "react-dom"; + +import { apiClient, type SseEvent } from "../lib/api-client"; +import type { AnalysisConfig, AnalysisProgress, AnalysisReport } from "../types/analysis"; + +export type AnalysisStatus = + | "idle" + | "submitting" + | "analysing" + | "complete" + | "error" + | "timeout"; + +export interface AnalysisRequest { + headers: string; + config: AnalysisConfig; +} + +export interface UseAnalysisState { + status: AnalysisStatus; + progress: AnalysisProgress | null; + result: AnalysisReport | null; + error: string | null; + submit: (request: AnalysisRequest) => Promise; + cancel: () => void; +} + +const scheduleTask = (handler: () => void): void => { + setTimeout(handler, 0); +}; + +const useAnalysis = (): UseAnalysisState => { + const [status, setStatus] = useState("idle"); + const [progress, setProgress] = useState(null); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + const abortRef = useRef(null); + const requestIdRef = useRef(0); + const inFlightRef = useRef(false); + const mountedRef = useRef(true); + const hasProgressRef = useRef(false); + + useEffect(() => { + return () => { + mountedRef.current = false; + abortRef.current?.abort(); + }; + }, []); + + const resetState = useCallback(() => { + setProgress(null); + setResult(null); + setError(null); + }, []); + + const handleEvent = useCallback( + ( + event: SseEvent, + requestId: number, + signal: AbortSignal, + ) => { + scheduleTask(() => { + if (!mountedRef.current) { + return; + } + if (!inFlightRef.current || requestIdRef.current !== requestId) { + return; + } + if (signal.aborted) { + return; + } + + if (event.event === "progress") { + const payload = event.data as AnalysisProgress; + if (!hasProgressRef.current) { + hasProgressRef.current = true; + flushSync(() => { + setProgress(payload); + setStatus("analysing"); + }); + return; + } + setProgress(payload); + return; + } + + if (event.event === "result") { + const report = event.data as AnalysisReport; + setResult(report); + inFlightRef.current = false; + setStatus(report.metadata.timedOut ? "timeout" : "complete"); + } + }); + }, + [], + ); + + const submit = useCallback( + async (request: AnalysisRequest): Promise => { + requestIdRef.current += 1; + const requestId = requestIdRef.current; + + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + inFlightRef.current = true; + + setStatus("submitting"); + resetState(); + hasProgressRef.current = false; + + try { + await apiClient.stream( + "/api/analyse", + { + body: request, + signal: controller.signal, + onEvent: (event) => handleEvent(event, requestId, controller.signal), + }, + ); + } catch (err) { + if (!mountedRef.current || controller.signal.aborted) { + return; + } + if (requestIdRef.current !== requestId) { + return; + } + + inFlightRef.current = false; + const message = err instanceof Error ? err.message : "Unknown error"; + setError(message); + setStatus("error"); + } + }, + [handleEvent, resetState], + ); + + const cancel = useCallback(() => { + if (abortRef.current) { + abortRef.current.abort(); + } + inFlightRef.current = false; + setStatus("idle"); + resetState(); + hasProgressRef.current = false; + }, [resetState]); + + return { + status, + progress, + result, + error, + submit, + cancel, + }; +}; + +export default useAnalysis;