diff --git a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-10-Playwright-E2E.md b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-10-Playwright-E2E.md index 9398175..33706e0 100644 --- a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-10-Playwright-E2E.md +++ b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-10-Playwright-E2E.md @@ -74,7 +74,7 @@ All tasks in this phase are parallelizable [P] since they are independent E2E sp - [x] T057 [P] Create `frontend/e2e/test-selection.spec.ts` — test US2: open test selector → verify 106+ tests listed → click Deselect All → select 3 specific tests → analyse → verify only 3 results in report. Test search/filter narrows visible tests. Test DNS and decode-all toggle states persist through analysis - [x] T058 [P] Create `frontend/e2e/report-interaction.spec.ts` — test US4 report features: expand all cards → collapse all → search for a term → verify filtered results → clear search → export JSON → verify downloaded file is valid JSON. Export HTML → verify downloaded file contains styled content - [x] T059 [P] Create `frontend/e2e/browser-cache.spec.ts` — test US5: complete analysis → reload page → verify headers and results restored from cache → click Clear Cache → verify input and report cleared → reload → verify empty state -- [ ] T060 [P] Create `frontend/e2e/rate-limiting.spec.ts` — test US6 rate limiting flow: submit requests until 429 response → verify CAPTCHA modal appears → solve CAPTCHA → verify bypass token stored → retry original request succeeds. Test that the CAPTCHA modal is keyboard accessible and visually correct +- [x] T060 [P] Create `frontend/e2e/rate-limiting.spec.ts` — test US6 rate limiting flow: submit requests until 429 response → verify CAPTCHA modal appears → solve CAPTCHA → verify bypass token stored → retry original request succeeds. Test that the CAPTCHA modal is keyboard accessible and visually correct - [ ] T061 [P] Create `frontend/e2e/visual-regression.spec.ts` — screenshot-based visual testing at 4 viewports (320×568, 768×1024, 1280×720, 2560×1080). Capture: landing page (empty state), landing page with headers pasted, progress indicator active, report view with results expanded, hop chain visualisation. Use `expect(page).toHaveScreenshot()` with `animations: 'disabled'` and `mask` for dynamic content (timestamps, elapsed time). Baselines stored in `frontend/e2e/__snapshots__/` - [ ] T062 [P] Create `frontend/e2e/accessibility.spec.ts` — WCAG 2.1 AA audit using `@axe-core/playwright`. Run `AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze()` on: landing page (empty), landing page with input, report view, CAPTCHA modal (if rate-limited). Assert zero violations. Document any necessary exceptions with justification - [ ] T063 [P] Create `frontend/e2e/responsive.spec.ts` — viewport matrix test at breakpoints 320px, 768px, 1024px, 1440px, 2560px. At each viewport: verify no horizontal scrollbar, all interactive elements visible and clickable, text readable (no overflow/clipping), report cards stack correctly on narrow viewports. Use `page.setViewportSize()` for per-test overrides diff --git a/frontend/e2e/rate-limiting.spec.ts b/frontend/e2e/rate-limiting.spec.ts index 88293ed..5b36342 100644 --- a/frontend/e2e/rate-limiting.spec.ts +++ b/frontend/e2e/rate-limiting.spec.ts @@ -2,7 +2,9 @@ import { test, expect } from "@playwright/test"; import fs from "fs/promises"; import path from "path"; -const headersPath = path.resolve(__dirname, "../../backend/tests/fixtures/sample_headers.txt"); +import { AnalyzerPage } from "./pages/analyzer-page"; + +const headersPath = path.resolve(__dirname, "fixtures/sample-headers.txt"); const rateLimit = 3; const bypassToken = "playwright-bypass-token"; const captchaChallenge = { @@ -10,20 +12,47 @@ const captchaChallenge = { imageBase64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFgwJ/l0pNqQAAAABJRU5ErkJggg==", }; +const successReport = { + results: [], + hopChain: [], + securityAppliances: [], + metadata: { + totalTests: 0, + passedTests: 0, + failedTests: 0, + skippedTests: 0, + elapsedMs: 1, + timedOut: false, + incompleteTests: [] as string[], + }, +}; + +const corsHeaders = { + "access-control-allow-origin": "http://localhost:3100", + "access-control-allow-credentials": "true", +}; + +const successSseBody = `event: result\ndata: ${JSON.stringify(successReport)}\n\n`; test("rate limiting shows captcha and retries successfully", async ({ page }) => { const headers = await fs.readFile(headersPath, "utf8"); + const analyzer = new AnalyzerPage(page); let analyseCount = 0; + const bypassTokens: string[] = []; await page.route("**/api/analyse", async (route) => { analyseCount += 1; + const bypassHeader = route.request().headers()["x-captcha-bypass-token"]; + if (bypassHeader) { + bypassTokens.push(bypassHeader); + } + if (analyseCount === rateLimit + 1) { await route.fulfill({ status: 429, contentType: "application/json", headers: { - "access-control-allow-origin": "http://localhost:3100", - "access-control-allow-credentials": "true", + ...corsHeaders, "retry-after": "60", }, body: JSON.stringify({ @@ -34,25 +63,26 @@ test("rate limiting shows captcha and retries successfully", async ({ page }) => }); return; } - await route.continue(); + + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + headers: corsHeaders, + body: successSseBody, + }); }); await page.route("**/api/captcha/verify", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", - headers: { - "access-control-allow-origin": "http://localhost:3100", - "access-control-allow-credentials": "true", - }, + headers: corsHeaders, body: JSON.stringify({ success: true, bypassToken }), }); }); - await page.goto("http://localhost:3100"); - - const headerInput = page.getByRole("textbox", { name: "Header Input" }); - await headerInput.fill(headers); + await analyzer.goto(); + await analyzer.pasteHeaders(headers); const analyseButton = page.getByRole("button", { name: "Analyse Headers" }); @@ -79,9 +109,34 @@ test("rate limiting shows captcha and retries successfully", async ({ page }) => expect(rateLimitResponse.status()).toBe(429); - const captchaModal = page.getByTestId("captcha-challenge"); + const captchaModal = analyzer.getCaptchaModal(); await expect(captchaModal).toBeVisible(); + const captchaTitle = page.getByRole("heading", { name: "Security Check Required" }); + const captchaImage = page.getByTestId("captcha-image"); + const captchaInput = page.getByTestId("captcha-input"); + const captchaClose = page.getByTestId("captcha-close"); + const captchaSubmit = page.getByTestId("captcha-submit"); + + await expect(captchaTitle).toBeVisible(); + await expect(captchaImage).toBeVisible(); + await expect(captchaImage).toHaveAttribute("src", /^data:image\/png;base64,/); + await expect(captchaClose).toBeVisible(); + await expect(captchaSubmit).toBeVisible(); + await expect(captchaInput).toBeFocused(); + + await page.keyboard.press("Shift+Tab"); + await expect(captchaClose).toBeFocused(); + + await page.keyboard.press("Shift+Tab"); + await expect(captchaSubmit).toBeFocused(); + + await page.keyboard.press("Tab"); + await expect(captchaClose).toBeFocused(); + + await captchaInput.focus(); + await captchaInput.fill("12345"); + const retryResponsePromise = page.waitForResponse((response) => { if (!response.url().includes("/api/analyse")) { return false; @@ -93,12 +148,29 @@ test("rate limiting shows captcha and retries successfully", async ({ page }) => return bypassHeader === bypassToken; }); - await page.getByTestId("captcha-input").fill("12345"); - await page.getByTestId("captcha-submit").click(); + await page.keyboard.press("Enter"); const retryResponse = await retryResponsePromise; expect(retryResponse.status()).toBe(200); await expect(captchaModal).toBeHidden({ timeout: 30000 }); await expect(analyseButton).toBeEnabled({ timeout: 30000 }); + + const followUpResponsePromise = page.waitForResponse((response) => { + if (!response.url().includes("/api/analyse")) { + return false; + } + if (response.status() !== 200) { + return false; + } + const bypassHeader = response.request().headers()["x-captcha-bypass-token"]; + return bypassHeader === bypassToken; + }); + + await analyseButton.click(); + await followUpResponsePromise; + + const bypassHits = bypassTokens.filter((token) => token === bypassToken); + expect(bypassHits.length).toBeGreaterThanOrEqual(2); + await expect(analyseButton).toBeEnabled({ timeout: 30000 }); });