Skip to content
Open
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: 2 additions & 2 deletions .github/workflows/frontend_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,8 @@ jobs:
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium

- name: Run E2E tests
run: npm run test:e2e
- name: Run E2E tests (seeded mode)
run: npm run test:e2e:seeded
env:
CI: true

Expand Down
21 changes: 21 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,27 @@ npm run test:e2e:headed # Run with visible browser windows (requires display)
npm run test:e2e:ui # Interactive UI mode (requires display)
```

### E2E Test Modes

E2E flow tests run in two modes controlled by Playwright projects and an environment variable:

- **Seeded** (`--project seeded`, default for CI): Messages are stored directly in the database with `send: false` using dummy credentials. No real API keys needed. Tests cover the full UI flow (display, branching, conversation switching, promoting) without calling any external service.

- **Live** (`--project live`, requires `E2E_LIVE_MODE=true`): Messages are sent to real OpenAI endpoints with `send: true`. Each target variant requires its own set of environment variables (e.g., `OPENAI_CHAT_ENDPOINT`, `OPENAI_CHAT_KEY`, `OPENAI_CHAT_MODEL`). Variants whose env vars are missing are automatically skipped. Tests verify that real target responses render correctly.

```bash
# CI (seeded only — no credentials needed)
npx playwright test --project seeded

# Live integration (requires real API keys)
E2E_LIVE_MODE=true npx playwright test --project live

# Run both
E2E_LIVE_MODE=true npx playwright test
```

The seeded project runs in the **GitHub Actions** workflow. The live project is intended for an **Azure DevOps pipeline** that has the required secret API keys.

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.
Expand Down
17 changes: 3 additions & 14 deletions frontend/dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,11 @@ def stop_servers():
print("✅ Servers stopped")


def start_backend(initializers: list[str] | None = None):
def start_backend():
"""Start the FastAPI backend using pyrit_backend CLI.

Args:
initializers: Optional list of initializer names to run at startup.
If not specified, no initializers are run.
Configuration (initializers, database, env files) is read automatically
from ~/.pyrit/.pyrit_conf by the pyrit_backend CLI via ConfigurationLoader.
"""
print("🚀 Starting backend on port 8000...")

Expand All @@ -98,11 +97,6 @@ def start_backend(initializers: list[str] | None = None):
env = os.environ.copy()
env["PYRIT_DEV_MODE"] = "true"

# Default to no initializers
if initializers is None:
initializers = []

# Build command using pyrit_backend CLI
cmd = [
sys.executable,
"-m",
Expand All @@ -115,11 +109,6 @@ def start_backend(initializers: list[str] | None = None):
"info",
]

# Add initializers if specified
if initializers:
cmd.extend(["--initializers"] + initializers)

# Start backend
backend = subprocess.Popen(cmd, env=env)

return backend
Expand Down
85 changes: 71 additions & 14 deletions frontend/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ test.describe("Accessibility", () => {
await expect(newChatButton).toBeVisible();
});

test("should have accessible sidebar navigation", async ({ page }) => {
// Chat button
const chatBtn = page.getByTitle("Chat");
await expect(chatBtn).toBeVisible();

// Configuration button
const configBtn = page.getByTitle("Configuration");
await expect(configBtn).toBeVisible();

// Theme toggle button
const themeBtn = page.getByTitle(/light mode|dark mode/i);
await expect(themeBtn).toBeVisible();
});

test("should be navigable with keyboard", async ({ page }) => {
// Tab to the first interactive element
await page.keyboard.press("Tab");
Expand All @@ -30,20 +44,35 @@ test.describe("Accessibility", () => {
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 }) => {
// Mock a target so the input is enabled
await page.route(/\/api\/targets/, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
target_registry_name: "a11y-focus-target",
target_type: "OpenAIChatTarget",
endpoint: "https://test.com",
model_name: "gpt-4o",
},
],
}),
});
});

// Navigate to config, set active, return to chat so input is enabled
await page.getByTitle("Configuration").click();
await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10000 });
const setActiveBtn = page.getByRole("button", { name: /set active/i });
await expect(setActiveBtn).toBeVisible({ timeout: 5000 });
await setActiveBtn.click();
await page.getByTitle("Chat").click();

const input = page.getByRole("textbox");
await expect(input).toBeEnabled({ timeout: 5000 });

// Focus input
await input.focus();
Expand All @@ -53,17 +82,45 @@ test.describe("Accessibility", () => {
await input.fill("Test");
await expect(input).toBeFocused();
});

test("should have accessible target table in config view", async ({ page }) => {
// Mock targets API for consistent test
await page.route(/\/api\/targets/, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
target_registry_name: "a11y-test-target",
target_type: "OpenAIChatTarget",
endpoint: "https://test.com",
model_name: "gpt-4o",
},
],
}),
});
});

// Navigate to config
await page.getByTitle("Configuration").click();
await expect(page.getByText("Target Configuration")).toBeVisible();

// Table should have an aria-label
const table = page.getByRole("table", { name: /target instances/i });
await expect(table).toBeVisible();
});
});

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();
await expect(page.getByText("PyRIT Attack")).toBeVisible();

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

// Wait a moment for any delayed renders
Expand Down
92 changes: 91 additions & 1 deletion frontend/e2e/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,102 @@ test.describe("API Health Check", () => {
});
});

test.describe("Targets API", () => {
test.beforeAll(async ({ request }) => {
// Wait for backend readiness
const maxWait = 30_000;
const interval = 1_000;
const start = Date.now();
while (Date.now() - start < maxWait) {
try {
const resp = await request.get("/api/health");
if (resp.ok()) return;
} catch {
// Backend not ready yet
}
await new Promise((r) => setTimeout(r, interval));
}
throw new Error("Backend did not become healthy within 30 seconds");
});

test("should list targets", async ({ request }) => {
const response = await request.get("/api/targets?count=50");

expect(response.ok()).toBe(true);
const data = await response.json();
expect(data).toHaveProperty("items");
expect(Array.isArray(data.items)).toBe(true);
});

test("should create and retrieve a target", async ({ request }) => {
const createPayload = {
target_type: "OpenAIChatTarget",
params: {
endpoint: "https://e2e-test.openai.azure.com",
model_name: "gpt-4o-e2e-test",
api_key: "e2e-test-key",
},
};

const createResp = await request.post("/api/targets", { data: createPayload });
// The endpoint may not be implemented, may require different schema, or may
// return a validation error. Skip when the backend cannot handle the request.
if (!createResp.ok()) {
test.skip(true, `POST /api/targets returned ${createResp.status()} — skipping`);
return;
}

const created = await createResp.json();
expect(created).toHaveProperty("target_registry_name");
expect(created.target_type).toBe("OpenAIChatTarget");

// Retrieve via list and check it's there
const listResp = await request.get("/api/targets?count=200");
expect(listResp.ok()).toBe(true);
const list = await listResp.json();
const found = list.items.find(
(t: { target_registry_name: string }) =>
t.target_registry_name === created.target_registry_name,
);
expect(found).toBeDefined();
});
});

test.describe("Attacks API", () => {
test.beforeAll(async ({ request }) => {
const maxWait = 30_000;
const interval = 1_000;
const start = Date.now();
while (Date.now() - start < maxWait) {
try {
const resp = await request.get("/api/health");
if (resp.ok()) return;
} catch {
// Backend not ready yet
}
await new Promise((r) => setTimeout(r, interval));
}
throw new Error("Backend did not become healthy within 30 seconds");
});

test("should list attacks", async ({ request }) => {
const response = await request.get("/api/attacks");
// Backend may return 500 due to stale DB schema or 404 if not implemented.
// Only assert when the endpoint is actually healthy.
if (!response.ok()) {
test.skip(true, `GET /api/attacks returned ${response.status()} — skipping`);
return;
}
expect(response.ok()).toBe(true);
});
});

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 route.continue();
});

await page.goto("/");
Expand Down
Loading
Loading