End-to-end tests use Playwright with a debug ticket system for verification.
cd e2e
npx playwright install # first time only
npx playwright test # headless
npx playwright test --headed # watch in browser
npx playwright test --ui # interactive UI mode
npx playwright test --debug # step-through debuggerTests auto-start client (port 5715) and server (port 8001). Requires PostgreSQL on port 5433.
A Playwright MCP server is available for interactive browser testing and debugging. Use it to navigate pages, take screenshots, inspect elements, and run browser code directly when developing or debugging tests.
e2e/
├── playwright.config.ts # Config (ports, projects, timeouts)
├── tests/ # Test spec files
├── fixtures/
│ ├── auth.ts # authenticatedPage / anonymousPage fixtures
│ └── test-data.ts # Pre-configured test scenarios
└── helpers/
├── form-fillers.ts # Form navigation and filling helpers
└── debug-api.ts # Debug ticket API client
Import from fixtures/auth.ts instead of Playwright's default:
import { test, expect } from "../fixtures/auth";Two page fixtures are available:
authenticatedPage— Pre-seeded OIDC session (testuser,test@tum.de,request-adminrole). Keycloak endpoints are mocked, GitHub API returns test profile.anonymousPage— OIDC endpoints mocked but no session. User goes through "Continue without sign in" flow.
The server runs with TICKET_SYSTEM=debug, writing tickets as JSON to /tmp/aet-debug-tickets/. The debug API helpers verify what the server produced:
import { resetTestState, getLatestTicket } from "../helpers/debug-api";
// In beforeEach — always reset
await resetTestState(request);
// After form submission — verify
const ticket = await getLatestTicket(request);
expect(ticket.summary).toContain("[VM Request]");
expect(ticket.description).toContain("**Hostname:** my-vm");Available functions:
| Function | Purpose |
|---|---|
resetTestState(request) |
Clear tickets and database (use in beforeEach) |
getLatestTicket(request) |
Get most recently created ticket |
getAllTickets(request) |
Get all tickets from this test run |
clearTickets(request) |
Clear tickets only |
clearDatabase(request) |
Clear database only |
Ticket structure:
{
ticket_key: string;
summary: string;
description: string;
reporter_username: string | null;
reporter_name: string | null;
reporter_email: string | null;
issue_type: string;
comments: string[];
custom_fields: Array<{ field: string; value: unknown }>;
}Reusable functions in helpers/form-fillers.ts handle multi-step form navigation:
| Helper | Description |
|---|---|
navigateFromHome(page, cardTitle) |
Navigate from start page to a form |
clickNext(page) |
Click "Next" button in multi-step forms |
clickSubmit(page) |
Click "Submit Request" |
waitForSuccess(page) |
Wait for "Request Submitted!" message |
selectRadioCard(page, id) |
Click a radio card by its for attribute |
selectShadcnOption(page, triggerLabel, optionText) |
Select from shadcn dropdown |
fillDatePicker(page, dateStr) |
Fill date picker (format: YYYY-MM-DD) |
seedSSHKey(page) |
Pre-create an SSH key via API for "existing key" tests |
Form-specific fillers:
fillVMRequestForm(page, config)— 6-step VM requestfillVMAccessForm(page, config)— VM access requestfillArtemisForm(page, config)— Artemis developer requestfillTUMGuestForm(page, config)— TUM guest account requestfillSupportForm(page, config)— Support request
Pre-configured scenarios in fixtures/test-data.ts cover each form type with multiple variations (auth status, optional fields, edge cases). Use these configs with the form fillers:
import { vmRequestConfigs } from "../fixtures/test-data";
await fillVMRequestForm(page, vmRequestConfigs.ipraktikum_default);Add a new config object to fixtures/test-data.ts:
export const myFormConfigs = {
basic_scenario: {
isAuthenticated: true,
fieldA: "value",
fieldB: "value",
},
};Add a helper function to helpers/form-fillers.ts:
export async function fillMyForm(page: Page, config: MyFormConfig) {
await navigateFromHome(page, "My Form Card Title");
// Fill fields
await page.getByPlaceholder("Field A").fill(config.fieldA);
await selectShadcnOption(page, "Field B", config.fieldB);
// Multi-step: advance through steps
await clickNext(page);
// ... fill more steps ...
await clickSubmit(page);
await waitForSuccess(page);
}Create tests/my-form.spec.ts:
import { test, expect } from "../fixtures/auth";
import { myFormConfigs } from "../fixtures/test-data";
import { resetTestState, getLatestTicket } from "../helpers/debug-api";
import { fillMyForm } from "../helpers/form-fillers";
test.describe("My Form", () => {
test.beforeEach(async ({ request }) => {
await resetTestState(request);
});
test("basic scenario", async ({ authenticatedPage: page, request }) => {
await fillMyForm(page, myFormConfigs.basic_scenario);
const ticket = await getLatestTicket(request);
expect(ticket.summary).toContain("expected text");
expect(ticket.description).toContain("Field A");
expect(ticket.reporter_username).toBe("testuser");
});
test("anonymous scenario", async ({ anonymousPage: page, request }) => {
await fillMyForm(page, myFormConfigs.anon_scenario);
const ticket = await getLatestTicket(request);
expect(ticket.reporter_username).toBeNull();
});
});- Always call
resetTestState(request)inbeforeEach— never rely on state from another test. - Tests run sequentially (
workers: 1), but must be independent.
Prefer accessible queries that match what users see:
// Good — accessible, resilient to DOM changes
page.getByRole("button", { name: "Next" });
page.getByPlaceholder("Enter hostname");
page.getByLabel("Enable Default Ports");
page.getByText("Request Submitted!");
// Acceptable — for specific targeting
page.locator('label[for="radio-card-id"]');
// Avoid — brittle, breaks on refactors
page.locator(".btn-primary");
page.locator("#submit-form > div:nth-child(3) > button");Always use the provided helpers instead of reimplementing interactions:
// Good
await selectShadcnOption(page, "Protocol", "TCP");
await fillDatePicker(page, "2026-06-15");
await selectRadioCard(page, "thesis-ba");
// Bad — reimplements what helpers already do
await page.locator('[role="combobox"]').click();
await page.getByRole("option", { name: "TCP" }).click();Assert on the ticket created by the server, not just UI state:
// Good — verifies end-to-end
const ticket = await getLatestTicket(request);
expect(ticket.summary).toContain("[VM Request]");
expect(ticket.description).toContain("**Hostname:** my-vm");
// Incomplete — only verifies frontend
expect(page.getByText("Request Submitted!")).toBeVisible();// Authenticated
expect(ticket.reporter_username).toBe("testuser");
expect(ticket.comments.length).toBeGreaterThan(0);
// Anonymous
expect(ticket.reporter_username).toBeNull();
expect(ticket.comments.length).toBe(0);For tests that depend on existing data (e.g., selecting an existing SSH key):
test("with existing SSH key", async ({ authenticatedPage: page, request }) => {
await seedSSHKey(page); // Create key via API before filling form
await fillVMRequestForm(page, vmRequestConfigs.with_existing_key);
// ...
});// Bad — tests depend on each other
let sharedTicketId: string;
test("create", async ({ authenticatedPage: page }) => {
// ...
sharedTicketId = ticket.ticket_key;
});
test("verify", async ({ request }) => {
// Fails if "create" didn't run or failed
});// Bad — arbitrary sleep
await page.waitForTimeout(5000);
await page.click("#submit");
// Good — wait for the condition
await page.getByRole("button", { name: "Submit" }).waitFor({ state: "visible" });
await page.getByRole("button", { name: "Submit" }).click();Note: Short waitForTimeout(200-500) calls after UI transitions (e.g., step navigation, dropdown animations) are acceptable when no better wait condition exists.
// Bad — tests internal state
expect(await page.evaluate(() => window.__formState.step)).toBe(3);
// Good — tests what the user sees
expect(page.getByText("Step 3 of 6")).toBeVisible();// Bad — copy-pasting form filling steps across tests
test("scenario A", async ({ authenticatedPage: page }) => {
await page.goto("/");
await page.getByText("Request a New VM").click();
await page.getByPlaceholder("my-vm-name").fill("test-vm");
// ... 50 more lines of form filling
});
// Good — use the form filler helper
test("scenario A", async ({ authenticatedPage: page }) => {
await fillVMRequestForm(page, vmRequestConfigs.scenario_a);
});// Bad — only checks the frontend
await waitForSuccess(page);
// Test ends here
// Good — verify the backend actually processed it
await waitForSuccess(page);
const ticket = await getLatestTicket(request);
expect(ticket.summary).toContain("expected");// Bad
await page.locator("div.form-group > input.shadcn-input").fill("value");
// Good
await page.getByPlaceholder("Enter value").fill("value");
await page.getByLabel("Field name").fill("value");- Radio cards: Click the
label[for="id"], not the hidden input — useselectRadioCard(). - Select/Combobox: Click the trigger by accessible name, then the option — use
selectShadcnOption(). - Checkboxes in admin views: Some shadcn checkboxes need
.click()instead of.check()/.uncheck(). - Date picker: Has year/month dropdowns, not a native input — use
fillDatePicker().
| Setting | Value |
|---|---|
| Test timeout | 60s |
| Expect timeout | 10s |
| Workers | 1 (sequential) |
| Server port | 8001 |
| Client port | 5715 |
| Test DB port | 5433 |
| Browser projects | Chromium (desktop), iPhone 14 Pro (mobile) |
| Screenshots | On failure only |
| Traces | On first retry |