Skip to main content

Playwright E2E Testing

Overview

Playwright is used for end-to-end (E2E) testing in humuus, simulating complete user flows in real browser environments. These tests validate the full stack — frontend, Nakama backend, and WebSocket communication — from a user's perspective.

Tests live in e2e-tests/ and share helper utilities in e2e-tests/helpers/test-utils.ts.

Running Tests

Requires the full dev stack to be running (pnpm run dev).

# Run all E2E tests
npx playwright test

# Run a single test file
npx playwright test e2e-tests/login.spec.ts

# Run only on Chromium (fastest)
npx playwright test --project=chromium

# Open the interactive test UI
npx playwright test --ui

# Record a new test (opens a browser you can interact with)
npx playwright codegen http://localhost:3000

# Show the HTML report from the last run
npx playwright show-report

Test Coverage

Test fileWhat it covers
login.spec.tsLogin success and failure
lobby.spec.tsHost creates workshop, player joins
quiz.spec.tsHost navigates quiz slides and scoreboard
imagequiz.spec.tsPlayer answers image quiz correctly/incorrectly
hostcontrol.spec.tsHost control popup, moving players into groups
playerLeave.spec.tsPlayer disconnects, host sees update
theme.spec.tsBloom/Aqua theme switching via CSS tokens
impressum.spec.tsLegal page content
storage.spec.tsStoragePush page loads
plurv.spec.tsConspiracy Detective workshop flow
potenzialanalyseHostEdit.spec.tsWhiteboard: create, edit, delete notes
potenzialanalyseUserHost.spec.tsWhiteboard: 8 concurrent users creating notes
multitopicbrainstorm.spec.ts50-user stress test
performance.spec.ts20-user lobby stress test
datadecorator.spec.ts⛔ Skipped — Data Decorator is deprecated

Tests that require a live LLM connection are skipped locally.

Shared Helper Utilities

e2e-tests/helpers/test-utils.ts provides reusable functions used across tests:

import {
waitForAppReady, // waits for window.__APP_READY__ === true
loginAsHost, // opens menu, navigates to /login, fills credentials
getLobbyCode, // extracts the 4-digit code from the header
joinLobby, // player navigates to / and enters a code
selectAvatar, // selects mushroom avatar and confirms
navigateToWorkshop, // waits for lobby text + URL change
} from './helpers/test-utils';

Lobby code extraction — the join code is displayed in the header as a formatted string:

const lobbyText = await page
.getByText(/Teilnehmen auf humuus\.de\s*\|\s*\d+/)
.innerText();
const lobbyCode = (lobbyText.match(/\d+/) ?? [''])[0];
expect(lobbyCode).not.toEqual('');

Basic Test Structure

import { test, expect } from '@playwright/test';
import { loginAsHost, joinLobby, selectAvatar } from './helpers/test-utils';

test('host and player interaction', async ({ page, browser }) => {
// Host logs in
await loginAsHost(page);

// Host starts a workshop
await page.getByRole('button', { name: 'Starten' }).first().click();
await page.getByRole('heading', { name: 'Lobby' }).waitFor({ timeout: 15000 });

// Extract lobby code
const lobbyText = await page
.getByText(/Teilnehmen auf humuus\.de\s*\|\s*\d+/)
.innerText();
const lobbyCode = (lobbyText.match(/\d+/) ?? [''])[0];

// Player joins in a separate context
const playerContext = await browser.newContext();
const playerPage = await playerContext.newPage();
await joinLobby(playerPage, lobbyCode);
const avatarName = await selectAvatar(playerPage);

// Host sees the player
await expect(page.getByText(avatarName)).toBeVisible({ timeout: 15000 });

await playerContext.close();
});

Selector Priority

Always prefer selectors that reflect what users actually see, in this order:

// 1. Role + accessible name (best — matches ARIA semantics)
page.getByRole('button', { name: 'Starten' })
page.getByRole('heading', { name: 'Lobby' })
page.getByRole('menuitem', { name: 'Bearbeiten' })

// 2. Label or placeholder
page.getByLabel('Email')
page.getByPlaceholder('Enter your name')

// 3. Visible text
page.getByText('Gleich geht es los!')

// 4. Test ID (when no semantic anchor exists — e.g. ReactFlow nodes)
page.getByTestId('rf__node-0')

// 5. CSS selectors — last resort, fragile
page.locator('.my-class')

Assertions

// Visible / hidden
await expect(page.getByText('Welcome')).toBeVisible();
await expect(page.getByRole('button')).toBeEnabled();

// URL
await expect(page).toHaveURL(/\/host/);

// Text content
await expect(page.getByRole('heading')).toHaveText('Lernstationen');

// Count
await expect(page.getByRole('listitem')).toHaveCount(5);

// In viewport (see ReactFlow section below)
await expect(locator).toBeInViewport({ timeout: 10000 });

Timing and Waiting

Playwright auto-waits for elements to be visible, stable, and actionable before interacting. Rely on this — avoid arbitrary waitForTimeout unless dealing with a known animation.

// ✅ Let Playwright auto-wait
await page.getByRole('button', { name: 'Start' }).click();

// ✅ Explicit wait for a condition
await page.waitForFunction(() => window.__APP_READY__ === true);

// ✅ Explicit wait for an element state
await locator.waitFor({ state: 'visible', timeout: 10000 });

// ⚠️ Only use a fixed delay when waiting for a known animation to complete
await page.waitForTimeout(1200); // e.g., after ReactFlow fitView (800ms animation)

// ❌ Avoid long arbitrary sleeps
await page.waitForTimeout(5000);

Multi-User Testing

Create independent browser contexts for each participant. Each context has its own cookies, localStorage, and session:

test('multi-user workshop', async ({ browser }) => {
const hostContext = await browser.newContext();
const playerContext = await browser.newContext();

const hostPage = await hostContext.newPage();
const playerPage = await playerContext.newPage();

try {
// Host and player interact independently
await hostPage.goto('http://localhost:3000/');
await playerPage.goto('http://localhost:3000/');
// ...
} finally {
await hostContext.close();
await playerContext.close();
}
});

Project-Specific Patterns

Skipping tests

Tests that need LLM/network connections not available locally, or deprecated features, should be skipped with a clear reason:

test('some llm feature', async ({ page }) => {
test.skip(true, 'Requires LLM connection — not available in local env');
// ...
});

Skipping on mobile viewports

Some tests only make sense on desktop (e.g., host control popup):

test('host controls', async ({ page }, testInfo) => {
if (
testInfo.project.name === 'Mobile Chrome' ||
testInfo.project.name === 'Mobile Safari'
) {
test.skip();
}
// ...
});

The UserMenu trigger

Guests and logged-in users both get an avatar — the UserMenu trigger is always a <div>, never a <button>. The Next.js Dev Tools button also carries aria-haspopup="menu", and after opening a nested submenu, the Design item also has aria-haspopup. Use this compound selector to target exclusively the UserMenu:

const menuTrigger = page.locator(
'[aria-haspopup="menu"]:not([data-nextjs-dev-tools-button]):not([role="menuitem"])'
);
await menuTrigger.click();

Radix UI dropdowns and submenus

The UserMenu uses a nested DropdownMenu (not DropdownMenuSub) for the theme switcher. After selecting a sub-item, the outer dropdown stays open — you do not need to re-open it:

// Open menu once
await menuTrigger.click();

// Select submenu item — outer menu stays open
await page.getByRole('menuitem', { name: /Design/ }).click();
await page.getByRole('menuitem', { name: 'Aqua' }).click();

// Outer menu still open — select another submenu item directly
await page.getByRole('menuitem', { name: /Design/ }).click();
await page.getByRole('menuitem', { name: 'Bloom' }).click();

ReactFlow canvas interactions

ReactFlow positions nodes using CSS transform: translate(x, y) on a canvas element. Playwright's viewport hit-test does not account for this transform, so nodes that are visually accessible can appear "outside the viewport" to Playwright.

Wait for fitView to complete before clicking a node button. WhiteboardNode calls reactFlow.fitView({ duration: 800 }) when nodes change. Use toBeInViewport() to wait for the animation to finish:

const noteNode = page.getByTestId('rf__node-0');
await noteNode.waitFor({ state: 'visible', timeout: 10000 });

const noteButton = noteNode.getByRole('button');
await expect(noteButton).toBeInViewport({ timeout: 10000 }); // waits for fitView
await noteButton.click();

Do not use scrollIntoViewIfNeeded() on ReactFlow nodes — it operates on the document scroll, not the ReactFlow canvas transform, and has no effect.

When a node never enters the viewport (e.g., a second note positioned outside the current view), dispatch the pointer events directly via evaluate. Radix DropdownMenuTrigger opens on pointerdown, and DropdownMenuItem selects on pointerup:

// Open a Radix dropdown trigger on an off-viewport ReactFlow node
await noteButton.evaluate((el) => {
el.dispatchEvent(
new PointerEvent('pointerdown', {
bubbles: true,
cancelable: true,
button: 0,
isPrimary: true,
}),
);
});

// Select a Radix menuitem when the menu renders outside the viewport
await page.getByRole('menuitem', { name: 'Bearbeiten' }).evaluate((el) => {
el.dispatchEvent(
new PointerEvent('pointerup', {
bubbles: true,
cancelable: true,
button: 0,
isPrimary: true,
}),
);
});

When a click is intercepted by an overlapping node, use { force: true }:

// The spawner node may be overlapped by a note card
await page.getByTestId('rf__node--1').click({ force: true });

Workshop tabs on the host page

Workshops are grouped into category tabs (Deepfake, Conspiracy, Data, GPT, Sonstige). Only the active tab's workshops are visible. Click the correct tab before selecting a workshop by index:

// Select a workshop in the Conspiracy tab
await page.getByRole('tab', { name: 'Conspiracy' }).click();
await page.getByRole('button', { name: 'Starten' }).first().click();

// Or rely on the default tab (Deepfake Detective) and known alphabetical index:
// nth(0) = deepfake_detective_guessTheFake
// nth(1) = deepfake_detective_multiplayer_image_quiz
// nth(3) = deepfake_detective_quiz
// nth(4) = deepfake_detective_whiteboard (Potenzialanalyse)

Avatar selection

The avatar picker uses a Carousel component. The selected mushroom's German name is shown as the placeholder of the #userName input (not its value). Read it with getAttribute:

await page.waitForFunction(() => {
const el = document.getElementById('userName') as HTMLInputElement | null;
return el !== null && el.placeholder !== '';
});
const avatarName = await page.getAttribute('#userName', 'placeholder');

Generating Tests with Codegen

codegen records browser interactions and generates Playwright test code:

npx playwright codegen http://localhost:3000

Interact with the app in the opened browser. When done, copy the generated code into a new spec file. Always review and refactor the output — codegen captures every interaction literally, including accidental clicks, and rarely produces robust selectors.

Debugging

// Pause the test and open the Playwright inspector
await page.pause();

// Listen to console output
page.on('console', (msg) => console.log(msg.text()));

// Listen to network requests
page.on('request', (request) => console.log(request.url()));
# Run in headed mode (see the browser)
npx playwright test --headed

# Run with the Playwright inspector
PWDEBUG=1 npx playwright test e2e-tests/login.spec.ts

Screenshots and Artifacts

Failed tests automatically capture a screenshot and video (configured in playwright.config.ts). For intentional screenshots during a test, save to test-results/:

await page.screenshot({
path: 'test-results/my-screenshot.png',
fullPage: true,
});

Artifacts from the last run are in test-results/. View the full HTML report:

npx playwright show-report

Known Issues

Firefox — temporarily disabled

Firefox is commented out in playwright.config.ts. React button click events in the Playwright test environment do not consistently trigger SPA navigation in Firefox, causing waitForURL() timeouts during workshop navigation. No reliable fix has been found. Chromium and WebKit tests remain stable.

ReactFlow viewport detection

Playwright's hit-testing does not account for CSS transform used by ReactFlow. Elements can be visually accessible but report as "outside viewport". See the ReactFlow section above for the correct interaction patterns.

Resources