mirror of
https://github.com/mgeeky/decode-spam-headers.git
synced 2026-02-22 05:23:31 +01:00
MAESTRO: add analyzer page object and fixtures
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
# Phase 10: Playwright E2E Tests
|
||||
|
||||
This phase implements the full Playwright end-to-end test suite that runs against the live application (both FastAPI backend and NextJS frontend). Tests exercise complete user flows via browser automation, capture screenshots for visual regression, perform automated WCAG 2.1 AA accessibility audits via axe-core, and verify responsive layout at multiple viewport breakpoints. By the end of this phase, the application has comprehensive runtime validation beyond unit/integration tests.
|
||||
|
||||
## Spec Kit Context
|
||||
|
||||
- **Feature:** 1-web-header-analyzer
|
||||
- **Specification:** .specify/specs/1-web-header-analyzer/spec.md (all scenarios, all NFRs)
|
||||
- **Plan:** .specify/specs/1-web-header-analyzer/plan.md (Playwright E2E Testing Strategy section)
|
||||
- **Tasks:** .specify/specs/1-web-header-analyzer/tasks.md
|
||||
- **Research:** .specify/specs/1-web-header-analyzer/research.md (Playwright config, file drop testing, visual regression baselines)
|
||||
- **Constitution:** .specify/memory/constitution.md (TDD: P6, UX: P7)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Requires ALL previous phases (1–9)** completed
|
||||
- Both backend and frontend must be fully functional and startable
|
||||
- `playwright.config.ts` already configured in Phase 01 with `webServer` array (uvicorn on 8000, NextJS on 3000)
|
||||
|
||||
## Playwright Configuration Reference
|
||||
|
||||
```typescript
|
||||
// frontend/playwright.config.ts (configured in Phase 01)
|
||||
{
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
webServer: [
|
||||
{ command: 'uvicorn backend.app.main:app --port 8000', port: 8000 },
|
||||
{ command: 'npm run dev', port: 3000 }
|
||||
],
|
||||
projects: [
|
||||
{ name: 'desktop', use: { viewport: { width: 1280, height: 720 } } },
|
||||
{ name: 'mobile', use: { viewport: { width: 320, height: 568 } } },
|
||||
{ name: 'tablet', use: { viewport: { width: 768, height: 1024 } } },
|
||||
{ name: 'ultrawide', use: { viewport: { width: 2560, height: 1080 } } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Test Suite Architecture
|
||||
|
||||
```
|
||||
frontend/e2e/
|
||||
├── fixtures/
|
||||
│ ├── sample-headers.txt # Reference headers for E2E flows
|
||||
│ └── sample.eml # Reference EML file for drop tests
|
||||
├── pages/
|
||||
│ └── analyzer-page.ts # Page Object Model
|
||||
├── paste-and-analyse.spec.ts # US1+US3+US4: primary flow
|
||||
├── file-drop.spec.ts # US1: drag-and-drop file flow
|
||||
├── test-selection.spec.ts # US2: test selection and toggles
|
||||
├── report-interaction.spec.ts # US4: expand/collapse, search, export
|
||||
├── browser-cache.spec.ts # US5: cache persistence and clear
|
||||
├── rate-limiting.spec.ts # US6: rate limit and CAPTCHA flow
|
||||
├── visual-regression.spec.ts # Screenshot comparison per viewport
|
||||
├── accessibility.spec.ts # axe-core WCAG 2.1 AA audit
|
||||
└── responsive.spec.ts # Viewport matrix (320–2560px)
|
||||
```
|
||||
|
||||
## Key Testing Patterns
|
||||
|
||||
- **SSE testing:** Assert on UI effects (progress bar updates, result rendering) — Playwright has no first-class SSE API
|
||||
- **File drop testing:** Use `page.evaluate()` to construct `DataTransfer` object, dispatch `drop` event on drop zone
|
||||
- **Visual regression:** `expect(page).toHaveScreenshot()` with `animations: 'disabled'`, `mask` dynamic content
|
||||
- **Accessibility:** `@axe-core/playwright` with tags `['wcag2a', 'wcag2aa', 'wcag21aa']`
|
||||
|
||||
## Tasks
|
||||
|
||||
All tasks in this phase are parallelizable [P] since they are independent E2E spec files.
|
||||
|
||||
- [x] T054 Create Page Object Model in `frontend/e2e/pages/analyzer-page.ts` — encapsulates all page interactions: `pasteHeaders(text)`, `dropFile(path)`, `clickAnalyse()`, `pressCtrlEnter()`, `selectTests(ids)`, `deselectAll()`, `selectAll()`, `toggleDns()`, `toggleDecodeAll()`, `waitForResults()`, `getResultCards()`, `expandCard(index)`, `collapseCard(index)`, `searchReport(query)`, `exportJson()`, `exportHtml()`, `clearCache()`, `getCaptchaModal()`. Copy test fixture files to `frontend/e2e/fixtures/sample-headers.txt` and `frontend/e2e/fixtures/sample.eml`
|
||||
- [ ] T055 [P] Create `frontend/e2e/paste-and-analyse.spec.ts` — test US1+US3+US4 primary flow: paste sample headers → click Analyse → verify progress indicator appears with test names → verify report renders with severity-coloured cards → expand/collapse a card → verify hop chain visualisation rendered. Also test Ctrl+Enter keyboard shortcut triggers analysis. Assert on UI effects of SSE progress (progress bar increments, test name updates)
|
||||
- [ ] T056 [P] Create `frontend/e2e/file-drop.spec.ts` — test US1 file drop flow: dispatch synthetic DataTransfer+drop events on drop zone with `.eml` fixture → verify textarea auto-populates with header content → click Analyse → verify report renders. Test rejection of unsupported file types (e.g., `.pdf`)
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
|
||||
## Completion
|
||||
|
||||
- [ ] All Playwright E2E specs pass: `npx playwright test`
|
||||
- [ ] Both backend (uvicorn) and frontend (NextJS) start automatically via Playwright `webServer` config
|
||||
- [ ] Visual regression baselines committed to `frontend/e2e/__snapshots__/`
|
||||
- [ ] Zero axe-core WCAG 2.1 AA violations across all tested views
|
||||
- [ ] No horizontal scrollbar or layout issues at any tested viewport (320–2560px)
|
||||
- [ ] All user flows (paste, drop, select, analyse, report, cache, rate-limit) pass E2E
|
||||
- [ ] Playwright test report generated (HTML report available for review)
|
||||
- [ ] Run `/speckit.analyze` to verify consistency
|
||||
34
frontend/e2e/fixtures/sample-headers.txt
Normal file
34
frontend/e2e/fixtures/sample-headers.txt
Normal file
@@ -0,0 +1,34 @@
|
||||
Received: from mail.example.org (mail.example.org [203.0.113.10])
|
||||
by mx.example.com with ESMTPS id 12345
|
||||
for <user@example.com>; Tue, 17 Feb 2026 10:00:00 +0000
|
||||
Received: from localhost (localhost [127.0.0.1])
|
||||
by mail.example.org with SMTP id 67890
|
||||
for <user@example.com>; Tue, 17 Feb 2026 09:59:00 +0000
|
||||
Authentication-Results: mx.example.com;
|
||||
spf=pass smtp.mailfrom=example.org;
|
||||
dkim=pass header.d=example.org;
|
||||
dmarc=pass
|
||||
Subject: This is a test subject
|
||||
with a folded line
|
||||
From: "Sender Name" <sender@example.org>
|
||||
To: user@example.com
|
||||
Date: Tue, 17 Feb 2026 10:00:00 +0000
|
||||
Message-ID: <1234@example.org>
|
||||
Content-Type: multipart/alternative; boundary="boundary-123"
|
||||
X-Forefront-Antispam-Report: CIP:203.0.113.10;CTRY:US;LANG:en;SCL:1;SRV:;
|
||||
IPV:NLI;SFV:SKI;H:mail.example.org;CAT:NONE;SFTY:0.0;SFS:(0);DIR:INB;
|
||||
X-Spam-Status: No, score=-0.1 required=5.0 tests=NONE
|
||||
X-Spam-Level: **
|
||||
X-Spam-Flag: NO
|
||||
X-Spam-Report: Example report line one
|
||||
Example report line two
|
||||
X-Mimecast-Spam-Score: 1
|
||||
X-Proofpoint-Spam-Details: rule=default, score=0
|
||||
X-MS-Exchange-Organization-SCL: 1
|
||||
|
||||
--boundary-123
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
This is the body.
|
||||
X-Should-Not-Be-Parsed: nope
|
||||
--boundary-123--
|
||||
34
frontend/e2e/fixtures/sample.eml
Normal file
34
frontend/e2e/fixtures/sample.eml
Normal file
@@ -0,0 +1,34 @@
|
||||
Received: from mail.example.org (mail.example.org [203.0.113.10])
|
||||
by mx.example.com with ESMTPS id 12345
|
||||
for <user@example.com>; Tue, 17 Feb 2026 10:00:00 +0000
|
||||
Received: from localhost (localhost [127.0.0.1])
|
||||
by mail.example.org with SMTP id 67890
|
||||
for <user@example.com>; Tue, 17 Feb 2026 09:59:00 +0000
|
||||
Authentication-Results: mx.example.com;
|
||||
spf=pass smtp.mailfrom=example.org;
|
||||
dkim=pass header.d=example.org;
|
||||
dmarc=pass
|
||||
Subject: This is a test subject
|
||||
with a folded line
|
||||
From: "Sender Name" <sender@example.org>
|
||||
To: user@example.com
|
||||
Date: Tue, 17 Feb 2026 10:00:00 +0000
|
||||
Message-ID: <1234@example.org>
|
||||
Content-Type: multipart/alternative; boundary="boundary-123"
|
||||
X-Forefront-Antispam-Report: CIP:203.0.113.10;CTRY:US;LANG:en;SCL:1;SRV:;
|
||||
IPV:NLI;SFV:SKI;H:mail.example.org;CAT:NONE;SFTY:0.0;SFS:(0);DIR:INB;
|
||||
X-Spam-Status: No, score=-0.1 required=5.0 tests=NONE
|
||||
X-Spam-Level: **
|
||||
X-Spam-Flag: NO
|
||||
X-Spam-Report: Example report line one
|
||||
Example report line two
|
||||
X-Mimecast-Spam-Score: 1
|
||||
X-Proofpoint-Spam-Details: rule=default, score=0
|
||||
X-MS-Exchange-Organization-SCL: 1
|
||||
|
||||
--boundary-123
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
This is the body.
|
||||
X-Should-Not-Be-Parsed: nope
|
||||
--boundary-123--
|
||||
172
frontend/e2e/pages/analyzer-page.ts
Normal file
172
frontend/e2e/pages/analyzer-page.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { Download, Locator, Page } from "@playwright/test";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const resolveMimeType = (fileName: string): string => {
|
||||
const extension = path.extname(fileName).toLowerCase();
|
||||
if (extension === ".eml") {
|
||||
return "message/rfc822";
|
||||
}
|
||||
return "text/plain";
|
||||
};
|
||||
|
||||
export class AnalyzerPage {
|
||||
readonly page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto("http://localhost:3100");
|
||||
}
|
||||
|
||||
async pasteHeaders(text: string): Promise<void> {
|
||||
await this.headerInput().fill(text);
|
||||
}
|
||||
|
||||
async dropFile(filePath: string): Promise<void> {
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
const fileName = path.basename(resolvedPath);
|
||||
const mimeType = resolveMimeType(fileName);
|
||||
const fileBuffer = await fs.readFile(resolvedPath);
|
||||
const base64 = fileBuffer.toString("base64");
|
||||
|
||||
await this.page.evaluate(
|
||||
({ base64Data, name, type }) => {
|
||||
const binary = atob(base64Data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
const file = new File([bytes], name, { type });
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
|
||||
const dropZone = document.querySelector('[data-testid="file-drop-zone"]');
|
||||
if (!dropZone) {
|
||||
throw new Error("File drop zone not found");
|
||||
}
|
||||
|
||||
const dragOver = new DragEvent("dragover", {
|
||||
dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
dropZone.dispatchEvent(dragOver);
|
||||
|
||||
const drop = new DragEvent("drop", {
|
||||
dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
dropZone.dispatchEvent(drop);
|
||||
},
|
||||
{ base64Data: base64, name: fileName, type: mimeType },
|
||||
);
|
||||
}
|
||||
|
||||
async clickAnalyse(): Promise<void> {
|
||||
await this.analyseButton().click();
|
||||
}
|
||||
|
||||
async pressCtrlEnter(): Promise<void> {
|
||||
await this.page.keyboard.press("Control+Enter");
|
||||
}
|
||||
|
||||
async selectTests(ids: number[]): Promise<void> {
|
||||
await this.page.getByTestId("test-selector").waitFor({ state: "visible" });
|
||||
for (const id of ids) {
|
||||
const checkbox = this.page.getByTestId(`test-checkbox-${id}`);
|
||||
await checkbox.scrollIntoViewIfNeeded();
|
||||
if (!(await checkbox.isChecked())) {
|
||||
await checkbox.check();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deselectAll(): Promise<void> {
|
||||
await this.page.getByTestId("deselect-all-tests").click();
|
||||
}
|
||||
|
||||
async selectAll(): Promise<void> {
|
||||
await this.page.getByTestId("select-all-tests").click();
|
||||
}
|
||||
|
||||
async toggleDns(): Promise<void> {
|
||||
await this.page.getByTestId("toggle-resolve").click();
|
||||
}
|
||||
|
||||
async toggleDecodeAll(): Promise<void> {
|
||||
await this.page.getByTestId("toggle-decode-all").click();
|
||||
}
|
||||
|
||||
async waitForResults(): Promise<void> {
|
||||
await this.page.getByTestId("report-container").waitFor({
|
||||
state: "visible",
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
getResultCards(): Locator {
|
||||
return this.page.locator('[data-testid^="test-result-card-"]');
|
||||
}
|
||||
|
||||
async expandCard(index: number): Promise<void> {
|
||||
const toggle = this.getResultCards()
|
||||
.nth(index)
|
||||
.locator('[data-testid^="test-result-toggle-"]');
|
||||
await toggle.waitFor({ state: "visible" });
|
||||
const expanded = await toggle.getAttribute("aria-expanded");
|
||||
if (expanded !== "true") {
|
||||
await toggle.click();
|
||||
}
|
||||
}
|
||||
|
||||
async collapseCard(index: number): Promise<void> {
|
||||
const toggle = this.getResultCards()
|
||||
.nth(index)
|
||||
.locator('[data-testid^="test-result-toggle-"]');
|
||||
await toggle.waitFor({ state: "visible" });
|
||||
const expanded = await toggle.getAttribute("aria-expanded");
|
||||
if (expanded === "true") {
|
||||
await toggle.click();
|
||||
}
|
||||
}
|
||||
|
||||
async searchReport(query: string): Promise<void> {
|
||||
await this.page.getByTestId("report-search-input").fill(query);
|
||||
}
|
||||
|
||||
async exportJson(): Promise<Download> {
|
||||
const [download] = await Promise.all([
|
||||
this.page.waitForEvent("download"),
|
||||
this.page.getByTestId("report-export-json").click(),
|
||||
]);
|
||||
return download;
|
||||
}
|
||||
|
||||
async exportHtml(): Promise<Download> {
|
||||
const [download] = await Promise.all([
|
||||
this.page.waitForEvent("download"),
|
||||
this.page.getByTestId("report-export-html").click(),
|
||||
]);
|
||||
return download;
|
||||
}
|
||||
|
||||
async clearCache(): Promise<void> {
|
||||
await this.page.getByRole("button", { name: "Clear Cache" }).click();
|
||||
}
|
||||
|
||||
getCaptchaModal(): Locator {
|
||||
return this.page.getByTestId("captcha-challenge");
|
||||
}
|
||||
|
||||
private headerInput(): Locator {
|
||||
return this.page.getByRole("textbox", { name: "Header Input" });
|
||||
}
|
||||
|
||||
private analyseButton(): Locator {
|
||||
return this.page.getByRole("button", { name: "Analyse Headers" });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user