Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@
"ms-azuretools.vscode-docker",
"tamasfe.even-better-toml",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
"esbenp.prettier-vscode",
"ms-playwright.playwright",
"orta.vscode-jest"
]
}
},
Expand Down
5 changes: 5 additions & 0 deletions .devcontainer/devcontainer_setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ fi
cd /workspace/frontend
if [ -f "package.json" ]; then
npm install

# Install Playwright browsers and system dependencies for E2E testing
echo "📦 Installing Playwright browsers..."
npx playwright install --with-deps chromium

echo "✅ Frontend dependencies installed."
fi
cd /workspace
Expand Down
157 changes: 157 additions & 0 deletions .github/workflows/frontend_tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Frontend testing workflow for PyRIT Frontend
# Runs unit tests, coverage checks, and E2E tests

name: Frontend Tests

on:
push:
branches:
- "main"
paths:
- "frontend/**"
- ".github/workflows/frontend_tests.yml"
pull_request:
branches:
- "main"
- "release/**"
paths:
- "frontend/**"
- ".github/workflows/frontend_tests.yml"
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true

env:
NODE_VERSION: "20"
PYTHON_VERSION: "3.11"

jobs:
unit-tests:
name: Frontend Unit Tests & Coverage
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: frontend

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
cache-dependency-path: frontend/package-lock.json

- name: Install dependencies
run: npm ci

- name: Run unit tests with coverage
run: npm run test:coverage

- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: frontend/coverage/
retention-days: 7

e2e-tests:
name: Frontend E2E Tests
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: frontend

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
cache-dependency-path: frontend/package-lock.json

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}

- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "0.9.17"
enable-cache: true
cache-dependency-glob: |
**/pyproject.toml

- name: Install Python dependencies
run: |
cd ..
uv sync --extra dev

- name: Install frontend dependencies
run: npm ci

- name: Install Playwright browsers
run: npx playwright install --with-deps chromium

- name: Run E2E tests
run: npm run test:e2e
env:
CI: true

- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: frontend/playwright-report/
retention-days: 7

- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results
path: frontend/test-results/
retention-days: 7

lint:
name: Frontend Lint & Type Check
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: frontend

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
cache-dependency-path: frontend/package-lock.json

- name: Install dependencies
run: npm ci

- name: Run ESLint
run: npm run lint

- name: Run TypeScript type check
run: npx tsc --noEmit
5 changes: 5 additions & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,8 @@ dist-ssr/
.env
.env.local
.env.*.local

# Testing
coverage/
playwright-report/
test-results/
18 changes: 18 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,24 @@ npm run preview
- **Vite** - Fast build tool
- **Axios** - HTTP client

## Testing

```bash
# Unit & Integration Tests (Jest + React Testing Library)
npm test # Run all tests
npm run test:watch # Watch mode for development
npm run test:coverage # Run with coverage report (85%+ threshold)

# End-to-End Tests (Playwright)
npm run test:e2e # Run headless (auto-starts frontend + backend via dev.py)
npm run test:e2e:headed # Run with visible browser windows (requires display)
npm run test:e2e:ui # Interactive UI mode (requires display)
```

E2E tests use `dev.py` to automatically start both frontend and backend servers. If servers are already running, they will be reused.

> **Note**: `test:e2e:ui` and `test:e2e:headed` require a graphical display and won't work in headless environments like devcontainers. Use `npm run test:e2e` for CI/headless testing.

## Configuration

The frontend proxies API requests to `http://localhost:8000` in development.
Expand Down
80 changes: 80 additions & 0 deletions frontend/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { test, expect } from "@playwright/test";

test.describe("Accessibility", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});

test("should have accessible form controls", async ({ page }) => {
// Input should be accessible
const input = page.getByRole("textbox");
await expect(input).toBeVisible();

// Send button should have accessible name
const sendButton = page.getByRole("button", { name: /send/i });
await expect(sendButton).toBeVisible();

// New Chat button should have accessible name
const newChatButton = page.getByRole("button", { name: /new chat/i });
await expect(newChatButton).toBeVisible();
});

test("should be navigable with keyboard", async ({ page }) => {
// Tab to the first interactive element
await page.keyboard.press("Tab");
const focused = page.locator(":focus");
await expect(focused).toBeVisible();

// Continue tabbing through elements
await page.keyboard.press("Tab");
await expect(page.locator(":focus")).toBeVisible();
});

test("should support Enter key to send message", async ({ page }) => {
const input = page.getByRole("textbox");
await input.fill("Test message via Enter");

// Press Enter to send (if supported)
await input.press("Enter");

// Either the message is sent, or we're still in the input
// This depends on the implementation
await expect(page.locator("body")).toBeVisible();
});

test("should have proper focus management", async ({ page }) => {
const input = page.getByRole("textbox");

// Focus input
await input.focus();
await expect(input).toBeFocused();

// Type and verify focus is maintained
await input.fill("Test");
await expect(input).toBeFocused();
});
});

test.describe("Visual Consistency", () => {
test("should render without layout shifts", async ({ page }) => {
await page.goto("/");

// Wait for initial render
await expect(page.getByText("PyRIT Frontend")).toBeVisible();

// Take measurements
const header = page.getByText("PyRIT Frontend");
const initialBox = await header.boundingBox();

// Wait a moment for any delayed renders
await page.waitForTimeout(500);

// Verify position hasn't changed
const finalBox = await header.boundingBox();

if (initialBox && finalBox) {
expect(finalBox.x).toBe(initialBox.x);
expect(finalBox.y).toBe(initialBox.y);
}
});
});
34 changes: 34 additions & 0 deletions frontend/e2e/api.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { test, expect } from "@playwright/test";

test.describe("API Health Check", () => {
test("should have healthy backend API", async ({ request }) => {
const response = await request.get("http://localhost:8000/api/health");

expect(response.ok()).toBe(true);
const data = await response.json();
expect(data).toBeDefined();
});

test("should get version from API", async ({ request }) => {
const response = await request.get("http://localhost:8000/api/version");

expect(response.ok()).toBe(true);
const data = await response.json();
expect(data).toBeDefined();
});
});

test.describe("Error Handling", () => {
test("should display UI when backend is slow", async ({ page }) => {
// Intercept and delay API calls
await page.route("**/api/**", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
route.continue();
});

await page.goto("/");

// UI should be responsive
await expect(page.getByRole("textbox")).toBeVisible({ timeout: 10000 });
});
});
Loading