MAESTRO: wire header analyzer controls and captcha

This commit is contained in:
Mariusz Banach
2026-02-18 04:40:57 +01:00
parent cdda2b987f
commit ffce9053a8
9 changed files with 199 additions and 22 deletions

View File

@@ -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 3202560px, 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 P1P8)
## Dependencies
- **Requires ALL previous phases (18)** 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 320px2560px (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

View File

@@ -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(<Home />);
const modal = container.querySelector('[data-testid="captcha-challenge"]');
expect(modal).not.toBeNull();
});
});

View File

@@ -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(() => {

View File

@@ -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) => {
<span data-testid="percentage">{progress?.percentage ?? ""}</span>
<span data-testid="result-total">{result?.metadata.totalTests ?? ""}</span>
<span data-testid="error">{error ?? ""}</span>
<span data-testid="captcha-token">{captchaChallenge?.challengeToken ?? ""}</span>
<button data-testid="submit" onClick={() => submit(request)}>
Submit
</button>
<button data-testid="cancel" onClick={() => cancel()}>
Cancel
</button>
<button data-testid="clear-captcha" onClick={() => clearCaptchaChallenge()}>
Clear Captcha
</button>
</div>
);
};
@@ -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(<AnalysisHarness request={baseRequest} />);
act(() => {
getByTestId(container, "submit").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => {
await flushPromises();
});
expect(getByTestId(container, "captcha-token").textContent).toBe("abc123");
});
});

View File

@@ -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<string | null>(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<string> => {
const response = await apiClient.post<CaptchaVerifyResponse, CaptchaVerifyPayload>(
"/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 (
<main className="min-h-screen bg-background text-text">
@@ -112,7 +159,10 @@ export default function Home() {
</header>
<section className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<HeaderInput value={headerInput} onChange={setHeaderInput} />
<div className="flex flex-col gap-6">
<HeaderInput value={headerInput} onChange={setHeaderInput} />
<AnalysisControls config={analysisConfig} onChange={setAnalysisConfig} />
</div>
<div className="flex flex-col gap-6">
<FileDropZone onFileContent={setHeaderInput} />
@@ -198,6 +248,14 @@ export default function Home() {
) : null}
</div>
</div>
<CaptchaChallenge
isOpen={Boolean(captchaChallenge)}
challenge={captchaChallenge}
onVerify={handleCaptchaVerify}
onSuccess={handleCaptchaSuccess}
onRetry={handleCaptchaRetry}
onClose={handleCaptchaClose}
/>
</main>
);
}

View File

@@ -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;

View File

@@ -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<void>;
captchaChallenge: CaptchaChallengeData | null;
submit: (request: AnalysisRequest, options?: AnalysisSubmitOptions) => Promise<void>;
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<AnalysisProgress | null>(null);
const [result, setResult] = useState<AnalysisReport | null>(null);
const [error, setError] = useState<string | null>(null);
const [captchaChallenge, setCaptchaChallenge] = useState<CaptchaChallengeData | null>(null);
const abortRef = useRef<AbortController | null>(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<void> => {
async (request: AnalysisRequest, options: AnalysisSubmitOptions = {}): Promise<void> => {
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<AnalysisRequest, AnalysisProgress | AnalysisReport>("/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,
};
};

View File

@@ -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 {

View File

@@ -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;
}