diff --git a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-09-Polish.md b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-09-Polish.md new file mode 100644 index 0000000..c1d92c5 --- /dev/null +++ b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-09-Polish.md @@ -0,0 +1,46 @@ +# Phase 09: Polish & Cross-Cutting Concerns + +This phase performs final integration, accessibility audit, responsive testing, linting validation, coverage verification, performance benchmarking, and documentation. By the end of this phase, the complete application flow works end-to-end, meets all quality gates (WCAG 2.1 AA, responsive 320–2560px, coverage ≥80%, linting clean), and the README is updated. + +## Spec Kit Context + +- **Feature:** 1-web-header-analyzer +- **Specification:** .specify/specs/1-web-header-analyzer/spec.md (all NFRs) +- **Plan:** .specify/specs/1-web-header-analyzer/plan.md +- **Tasks:** .specify/specs/1-web-header-analyzer/tasks.md +- **Constitution:** .specify/memory/constitution.md (all principles P1–P8) + +## Dependencies + +- **Requires ALL previous phases (1–8)** completed +- All components exist, all backend endpoints operational, all tests passing + +## Tasks + +- [x] T046 Wire all components together in `frontend/src/app/page.tsx` — integrate HeaderInput, FileDropZone, AnalysisControls, AnalyseButton, ProgressIndicator, ReportContainer, CaptchaChallenge into the single-view application with correct data flow. Ensure: input feeds to analysis hook, progress hook drives progress indicator, result feeds to report container, 429 errors trigger CAPTCHA modal, cache hook restores state on mount. Notes: added AnalysisControls + CAPTCHA retry flow, extended analysis hook for bypass token handling, confirmed cache restore. +- [ ] T047 Verify WCAG 2.1 AA compliance across all components (NFR-03) — ARIA labels, keyboard nav order, focus indicators, colour contrast ratios (dark theme). Fix violations. Test with screen reader simulation. Ensure all interactive elements have visible focus states +- [ ] T048 [P] Verify responsive layout 320px–2560px (NFR-04) at breakpoints: 320px, 768px, 1024px, 1440px, 2560px. No horizontal scroll, no overlapping elements, readable text. Fix any layout issues discovered +- [ ] T049 [P] Run full linting pass — `ruff check backend/` and `ruff format backend/` zero errors; `npx eslint src/` and `npx prettier --check src/` zero errors; no `any` types in TypeScript. Fix all violations +- [ ] T050 [P] Run full test suites and verify coverage — `pytest backend/tests/ --cov` ≥80% new modules (NFR-06); `npx vitest run --coverage` ≥80% new components (NFR-07). Add missing tests if coverage is below threshold +- [ ] T051 [P] Verify initial page load <3s on simulated 4G (constitution P7). Use Lighthouse with Slow 4G preset. Target score ≥90. Fix blocking resources or missing lazy-loading if score is below target +- [ ] T052 [P] Benchmark analysis performance — full analysis of `backend/tests/fixtures/sample_headers.txt` completes within 10s (NFR-01). Profile slow scanners. Document results. Optimise if any scanner exceeds acceptable threshold +- [ ] T053 Update `README.md` with web interface section: description, local run instructions for backend (`uvicorn backend.app.main:app`) and frontend (`npm run dev`), environment variable documentation, test run commands (`pytest`, `vitest`, `playwright test`), screenshots placeholder + +## Completion + +- [ ] Complete flow works end-to-end: paste headers → configure tests → analyse → view report → export +- [ ] File drop flow works: drop EML → auto-populate → analyse → report +- [ ] Cache flow works: analyse → reload → see cached results → clear cache +- [ ] Rate limiting flow works: exceed limit → CAPTCHA modal → solve → retry succeeds +- [ ] `pytest backend/tests/` passes with ≥80% coverage on new modules +- [ ] `npx vitest run --coverage` passes with ≥80% coverage on new components +- [ ] `ruff check backend/` — zero errors +- [ ] `npx eslint src/` — zero errors +- [ ] `npx prettier --check src/` — zero errors +- [ ] No `any` types in TypeScript +- [ ] WCAG 2.1 AA compliant (ARIA labels, keyboard nav, contrast ratios) +- [ ] Responsive at 320px, 768px, 1024px, 1440px, 2560px — no layout issues +- [ ] Lighthouse score ≥90 on Slow 4G preset +- [ ] Analysis completes within 10s for sample headers +- [ ] README.md updated with web interface documentation +- [ ] Run `/speckit.analyze` to verify consistency diff --git a/frontend/src/__tests__/HomePage.test.tsx b/frontend/src/__tests__/HomePage.test.tsx index 111ae80..81a7e36 100644 --- a/frontend/src/__tests__/HomePage.test.tsx +++ b/frontend/src/__tests__/HomePage.test.tsx @@ -18,8 +18,10 @@ const { submitSpy, cancelSpy, useAnalysisState } = vi.hoisted(() => { progress: null, result: null, error: null, + captchaChallenge: null, submit: submitSpy, cancel: cancelSpy, + clearCaptchaChallenge: vi.fn(), }, }; }); @@ -86,6 +88,7 @@ const resetUseAnalysisState = (): void => { useAnalysisState.progress = null; useAnalysisState.result = null; useAnalysisState.error = null; + useAnalysisState.captchaChallenge = null; }; afterEach(() => { @@ -152,4 +155,15 @@ describe("Home page", () => { const results = container.querySelector('[data-testid="analysis-results"]'); expect(results).not.toBeNull(); }); + + it("renders the captcha modal when rate limited", () => { + useAnalysisState.captchaChallenge = { + challengeToken: "challenge-123", + imageBase64: "image-data", + }; + + const { container } = render(); + const modal = container.querySelector('[data-testid="captcha-challenge"]'); + expect(modal).not.toBeNull(); + }); }); diff --git a/frontend/src/__tests__/page.test.tsx b/frontend/src/__tests__/page.test.tsx index 9e34d91..11d1205 100644 --- a/frontend/src/__tests__/page.test.tsx +++ b/frontend/src/__tests__/page.test.tsx @@ -18,8 +18,10 @@ const { submitSpy, cancelSpy, useAnalysisState } = vi.hoisted(() => { progress: null, result: null, error: null, + captchaChallenge: null, submit: submitSpy, cancel: cancelSpy, + clearCaptchaChallenge: vi.fn(), }, }; }); @@ -118,6 +120,7 @@ const resetUseAnalysisState = (): void => { useAnalysisState.progress = null; useAnalysisState.result = null; useAnalysisState.error = null; + useAnalysisState.captchaChallenge = null; }; beforeEach(() => { diff --git a/frontend/src/__tests__/useAnalysis.test.tsx b/frontend/src/__tests__/useAnalysis.test.tsx index 8cc4bd3..e7b7b59 100644 --- a/frontend/src/__tests__/useAnalysis.test.tsx +++ b/frontend/src/__tests__/useAnalysis.test.tsx @@ -4,7 +4,7 @@ import { act } from "react-dom/test-utils"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../lib/api-client"; +import { ApiError, apiClient } from "../lib/api-client"; import type { AnalysisConfig, AnalysisProgress, AnalysisReport } from "../types/analysis"; import useAnalysis from "../hooks/useAnalysis"; @@ -105,7 +105,8 @@ const timeoutReport: AnalysisReport = { }; const AnalysisHarness = ({ request, onStatusChange }: HarnessProps) => { - const { status, progress, result, error, submit, cancel } = useAnalysis(); + const { status, progress, result, error, submit, cancel, captchaChallenge, clearCaptchaChallenge } = + useAnalysis(); useEffect(() => { onStatusChange?.(status); @@ -118,12 +119,16 @@ const AnalysisHarness = ({ request, onStatusChange }: HarnessProps) => { {progress?.percentage ?? ""} {result?.metadata.totalTests ?? ""} {error ?? ""} + {captchaChallenge?.challengeToken ?? ""} + ); }; @@ -260,4 +265,24 @@ describe("useAnalysis", () => { expect(abortSignal?.aborted).toBe(true); expect(statuses).toContain("idle"); }); + + it("captures captcha challenges on rate limit errors", async () => { + vi.spyOn(apiClient, "stream").mockRejectedValue( + new ApiError("Too many requests", 429, { + captchaChallenge: { challengeToken: "abc123", imageBase64: "image-data" }, + }), + ); + + const { container } = render(); + + act(() => { + getByTestId(container, "submit").dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await act(async () => { + await flushPromises(); + }); + + expect(getByTestId(container, "captcha-token").textContent).toBe("abc123"); + }); }); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index fe7d188..5a29bec 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -5,14 +5,18 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faTrash } from "@fortawesome/free-solid-svg-icons"; import AnalyseButton from "../components/AnalyseButton"; +import AnalysisControls from "../components/AnalysisControls"; +import CaptchaChallenge from "../components/CaptchaChallenge"; import FileDropZone from "../components/FileDropZone"; import HeaderInput from "../components/HeaderInput"; import ProgressIndicator from "../components/ProgressIndicator"; import ReportContainer from "../components/report/ReportContainer"; import useAnalysis from "../hooks/useAnalysis"; import useAnalysisCache from "../hooks/useAnalysisCache"; +import { apiClient } from "../lib/api-client"; import { MAX_HEADER_INPUT_BYTES } from "../lib/header-validation"; import type { AnalysisConfig, AnalysisReport } from "../types/analysis"; +import type { CaptchaVerifyPayload, CaptchaVerifyResponse } from "../types/captcha"; const defaultConfig: AnalysisConfig = { testIds: [], @@ -21,7 +25,8 @@ const defaultConfig: AnalysisConfig = { }; export default function Home() { - const { status, progress, result, submit, cancel } = useAnalysis(); + const { status, progress, result, submit, cancel, captchaChallenge, clearCaptchaChallenge } = + useAnalysis(); const { save, load, clear, isNearLimit } = useAnalysisCache(); const initialCache = useMemo(() => load(), [load]); const [headerInput, setHeaderInput] = useState(() => initialCache?.headers ?? ""); @@ -36,6 +41,7 @@ export default function Home() { ); const [isViewCleared, setIsViewCleared] = useState(false); const lastSubmissionRef = useRef<{ headers: string; config: AnalysisConfig } | null>(null); + const bypassTokenRef = useRef(null); useEffect(() => { if (!result) { @@ -80,6 +86,10 @@ export default function Home() { const payload = { headers: headerInput, config: analysisConfig }; lastSubmissionRef.current = payload; setIsViewCleared(false); + if (bypassTokenRef.current) { + void submit(payload, { bypassToken: bypassTokenRef.current }); + return; + } void submit(payload); }, [analysisConfig, canAnalyse, headerInput, submit]); @@ -92,7 +102,44 @@ export default function Home() { setCachedTimestamp(null); setIsViewCleared(true); lastSubmissionRef.current = null; - }, [cancel, clear]); + bypassTokenRef.current = null; + clearCaptchaChallenge(); + }, [cancel, clear, clearCaptchaChallenge]); + + const handleCaptchaVerify = useCallback( + async (payload: CaptchaVerifyPayload): Promise => { + const response = await apiClient.post( + "/api/captcha/verify", + payload, + ); + if (!response.bypassToken) { + throw new Error("Captcha verification failed."); + } + return response.bypassToken; + }, + [], + ); + + const handleCaptchaSuccess = useCallback((bypassToken: string) => { + bypassTokenRef.current = bypassToken; + }, []); + + const handleCaptchaRetry = useCallback(() => { + const payload = lastSubmissionRef.current; + if (!payload) { + return; + } + setIsViewCleared(false); + if (bypassTokenRef.current) { + void submit(payload, { bypassToken: bypassTokenRef.current }); + return; + } + void submit(payload); + }, [submit]); + + const handleCaptchaClose = useCallback(() => { + clearCaptchaChallenge(); + }, [clearCaptchaChallenge]); return (
@@ -112,7 +159,10 @@ export default function Home() {
- +
+ + +
@@ -198,6 +248,14 @@ export default function Home() { ) : null}
+
); } diff --git a/frontend/src/components/CaptchaChallenge.tsx b/frontend/src/components/CaptchaChallenge.tsx index 396dfc7..ae56264 100644 --- a/frontend/src/components/CaptchaChallenge.tsx +++ b/frontend/src/components/CaptchaChallenge.tsx @@ -4,15 +4,7 @@ import { useCallback, useId, useLayoutEffect, useRef, useState, type KeyboardEve import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faLock, faUnlock } from "@fortawesome/free-solid-svg-icons"; -export type CaptchaChallengeData = { - challengeToken: string; - imageBase64: string; -}; - -export type CaptchaVerifyPayload = { - challengeToken: string; - answer: string; -}; +import type { CaptchaChallengeData, CaptchaVerifyPayload } from "../types/captcha"; type CaptchaChallengeProps = { isOpen: boolean; diff --git a/frontend/src/hooks/useAnalysis.ts b/frontend/src/hooks/useAnalysis.ts index bbb94f5..6391331 100644 --- a/frontend/src/hooks/useAnalysis.ts +++ b/frontend/src/hooks/useAnalysis.ts @@ -1,7 +1,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { flushSync } from "react-dom"; -import { apiClient, type SseEvent } from "../lib/api-client"; +import { ApiError, apiClient, type SseEvent } from "../lib/api-client"; +import type { CaptchaChallengeData } from "../types/captcha"; import type { AnalysisConfig, AnalysisProgress, AnalysisReport } from "../types/analysis"; export type AnalysisStatus = "idle" | "submitting" | "analysing" | "complete" | "error" | "timeout"; @@ -16,8 +17,14 @@ export interface UseAnalysisState { progress: AnalysisProgress | null; result: AnalysisReport | null; error: string | null; - submit: (request: AnalysisRequest) => Promise; + captchaChallenge: CaptchaChallengeData | null; + submit: (request: AnalysisRequest, options?: AnalysisSubmitOptions) => Promise; cancel: () => void; + clearCaptchaChallenge: () => void; +} + +export interface AnalysisSubmitOptions { + bypassToken?: string; } const scheduleTask = (handler: () => void): void => { @@ -29,6 +36,7 @@ const useAnalysis = (): UseAnalysisState => { const [progress, setProgress] = useState(null); const [result, setResult] = useState(null); const [error, setError] = useState(null); + const [captchaChallenge, setCaptchaChallenge] = useState(null); const abortRef = useRef(null); const requestIdRef = useRef(0); @@ -47,6 +55,11 @@ const useAnalysis = (): UseAnalysisState => { setProgress(null); setResult(null); setError(null); + setCaptchaChallenge(null); + }, []); + + const clearCaptchaChallenge = useCallback(() => { + setCaptchaChallenge(null); }, []); const handleEvent = useCallback( @@ -92,7 +105,7 @@ const useAnalysis = (): UseAnalysisState => { ); const submit = useCallback( - async (request: AnalysisRequest): Promise => { + async (request: AnalysisRequest, options: AnalysisSubmitOptions = {}): Promise => { requestIdRef.current += 1; const requestId = requestIdRef.current; @@ -106,10 +119,15 @@ const useAnalysis = (): UseAnalysisState => { hasProgressRef.current = false; try { + const headers = options.bypassToken + ? { "x-captcha-bypass-token": options.bypassToken } + : undefined; + await apiClient.stream("/api/analyse", { body: request, signal: controller.signal, onEvent: (event) => handleEvent(event, requestId, controller.signal), + headers, }); } catch (err) { if (!mountedRef.current || controller.signal.aborted) { @@ -120,7 +138,13 @@ const useAnalysis = (): UseAnalysisState => { } inFlightRef.current = false; - const message = err instanceof Error ? err.message : "Unknown error"; + let message = err instanceof Error ? err.message : "Unknown error"; + if (err instanceof ApiError) { + message = err.message; + if (err.status === 429 && err.payload?.captchaChallenge) { + setCaptchaChallenge(err.payload.captchaChallenge); + } + } setError(message); setStatus("error"); } @@ -143,8 +167,10 @@ const useAnalysis = (): UseAnalysisState => { progress, result, error, + captchaChallenge, submit, cancel, + clearCaptchaChallenge, }; }; diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts index 7d98d76..2247ac0 100644 --- a/frontend/src/lib/api-client.ts +++ b/frontend/src/lib/api-client.ts @@ -1,13 +1,12 @@ +import type { CaptchaChallengeData } from "../types/captcha"; + const DEFAULT_BASE_URL = "http://localhost:8000"; export interface ApiErrorPayload { error?: string; detail?: string; retryAfter?: number; - captchaChallenge?: { - challengeToken: string; - imageBase64: string; - }; + captchaChallenge?: CaptchaChallengeData; } export class ApiError extends Error { diff --git a/frontend/src/types/captcha.ts b/frontend/src/types/captcha.ts new file mode 100644 index 0000000..ef50336 --- /dev/null +++ b/frontend/src/types/captcha.ts @@ -0,0 +1,14 @@ +export interface CaptchaChallengeData { + challengeToken: string; + imageBase64: string; +} + +export interface CaptchaVerifyPayload { + challengeToken: string; + answer: string; +} + +export interface CaptchaVerifyResponse { + success: boolean; + bypassToken?: string | null; +}