How to write and run Playwright browser tests against the Powernode platform.
- What this guide covers
- Prerequisites
- Directory layout
- Quick start
- Running tests
- Authentication strategy
- Writing tests
- Page objects
- Selector strategy
- Test data and fixtures
- Debugging
- CI integration
- Best practices
- Troubleshooting
- Related guides
- Materials previously at
End-to-end browser tests exercise the full stack — Rails API, Sidekiq worker, React frontend, ActionCable WebSockets, and real AI providers — in a headless or headed browser. Playwright is the supported framework; older Cypress specs remain in frontend/cypress/ for reference only during migration.
This guide is for engineers writing or running cross-browser E2E coverage of platform features. It expects you already understand the platform's backend, frontend, and testing conventions.
- Working local environment:
sudo systemctl start powernode.target - Playwright browsers installed:
npx playwright install - Test credentials in
test-credentials.json(created bycd server && rails db:seed) - Optionally:
TEST_USER_EMAILandTEST_USER_PASSWORDenvironment variables for custom credentials
e2e/
├── fixtures/
│ ├── auth.ts # Authentication helpers
│ └── test-data.ts # Test data constants and uniqueness helpers
├── pages/ # Page Object Models
│ ├── login.page.ts
│ └── ai/
│ ├── providers.page.ts
│ ├── agents.page.ts
│ ├── workflows.page.ts
│ ├── conversations.page.ts
│ ├── agent-teams.page.ts
│ └── monitoring.page.ts
├── ai/ # AI test specs
│ ├── providers.spec.ts
│ ├── agents.spec.ts
│ ├── conversations.spec.ts
│ ├── workflows.spec.ts
│ ├── agent-teams.spec.ts
│ └── monitoring.spec.ts
├── global-setup.ts # Auth state setup
└── .auth/ # Cached auth state (gitignored)
flowchart LR
Setup[global-setup.ts] -->|writes| Auth[.auth/user.json]
Auth -->|loaded by| Chromium[chromium project]
Auth -->|loaded by| Firefox[firefox project]
Auth -->|loaded by| WebKit[webkit project]
Spec[spec/*.spec.ts] --> POM[Page Object]
POM -->|locates| UI[Frontend at :3001]
UI --> API[Rails API at :3000]
UI --> WS[ActionCable]
# 1. Install browsers (one-time)
npx playwright install
# 2. Start services
sudo systemctl start powernode.target
# 3. Run all tests
npm run test:e2e
# 4. Run with UI (interactive mode)
npm run test:e2e:ui
# 5. View HTML report
npm run test:e2e:report# All specs
npm run test:e2e
# Single file
npm run test:e2e -- e2e/ai/providers.spec.ts
# Filter by name pattern
npm run test:e2e -- --grep "providers"
# Per-browser
npm run test:e2e:chromium
npm run test:e2e:firefox
npm run test:e2e:webkit
# Headed (visible browser)
npm run test:e2e:headed
# Step-debugger
npm run test:e2e:debugTo avoid logging in for every test:
global-setup.tsruns once at the start of the suite, logs in as the test user, and writes the storage state toe2e/.auth/user.json.- Every browser project loads this storage state before running specs, so each spec starts authenticated.
To force a re-login (after a password change or schema reset):
rm -rf e2e/.auth/
npm run test:e2eThe setup project must always be listed as a dependency of the browser projects in playwright.config.ts.
import { test, expect } from '@playwright/test';
import { ProvidersPage } from '../pages/ai/providers.page';
test.describe('AI Providers', () => {
let providersPage: ProvidersPage;
test.beforeEach(async ({ page }) => {
// Suppress unhandled page errors — they don't fail the test but they're noisy
page.on('pageerror', () => {});
providersPage = new ProvidersPage(page);
await providersPage.goto();
await providersPage.waitForReady();
});
test('tests provider connection', async () => {
await providersPage.testConnection('Ollama');
await providersPage.verifyConnectionSuccess();
});
});import { TEST_AGENT, uniqueTestData } from '../fixtures/test-data';
test('creates an agent', async () => {
const testAgent = uniqueTestData(TEST_AGENT); // appends unique suffix to name
await agentsPage.createAgent(testAgent);
await agentsPage.verifyAgentExists(testAgent.name);
});Available fixtures in e2e/fixtures/test-data.ts:
| Constant | Purpose |
|---|---|
TEST_PROVIDER |
Provider creation payload |
TEST_AGENT |
Agent creation payload |
TEST_CONVERSATION |
Conversation seed data |
TEST_WORKFLOW |
Workflow definition |
TEST_AGENT_TEAM |
Team composition |
TEST_CONTEXT |
Context/memory entries |
ROUTES |
Frontend route map |
API_ENDPOINTS |
API endpoint map |
uniqueTestData(template) |
Returns template with unique-suffix name to avoid test collisions |
Page objects encapsulate page interactions so specs stay declarative and brittle selectors live in one place.
// e2e/pages/ai/agents.page.ts
import { Page, expect } from '@playwright/test';
export class AgentsPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/app/ai/agents');
}
async waitForReady() {
await this.page.waitForLoadState('networkidle');
}
async createAgent(data: AgentTestData) {
await this.page.getByRole('button', { name: /new agent/i }).click();
await this.page.getByLabel('Name').fill(data.name);
await this.page.getByLabel('Description').fill(data.description);
await this.page.getByLabel('Provider').selectOption(data.providerId);
await this.page.getByRole('button', { name: /create/i }).click();
}
async verifyAgentExists(name: string) {
await expect(this.page.getByText(name)).toBeVisible();
}
}Rules:
- One page object per route.
- No assertions inside page methods (except verifiers like
verifyAgentExists). - All hard-coded selectors live in the page object — specs never reach raw locators.
- Page methods return primitives or void, never
Locatorobjects.
Prefer the most stable selector available, in this order:
- Role + accessible name —
getByRole('button', { name: /save/i }). Most stable, exercises a11y. - Label —
getByLabel('Email'). Good for form fields. - Test ID —
getByTestId('widget-card'). Adddata-testidto new components when the above don't work. - Class fragment —
page.locator('[class*="card"]'). Avoid; tied to CSS implementation. - Raw text —
getByText('Save'). Only when nothing else fits.
Add data-testid attributes to new components proactively. They cost nothing in production bundles (Vite strips them in production by default) and they make E2E tests resilient to copy or styling changes.
const editButton = page.getByRole('button', { name: /edit/i });
if (await editButton.count() > 0) {
await editButton.click();
}Don't fail a test because an optional UI element wasn't present — guard with count() first.
Every spec that creates resources MUST use uniqueTestData() so tests don't collide when running in parallel:
const agent = uniqueTestData(TEST_AGENT); // { name: 'Test Agent 1736887234123', ... }By default, fixtures don't clean up after themselves — the test database is recreated per suite. If a spec needs to verify deletion semantics, it should explicitly delete the resource and assert the result.
AI specs hit a real provider (Ollama or remote). Set the provider URL via environment variables or test-credentials.json. Allow longer timeouts (60s+) for model inference:
await page.waitForSelector('[data-testid="agent-response"]', { timeout: 60_000 });npm run test:e2e:debugOpens Playwright Inspector with breakpoint support and step-through DOM inspection.
For failed tests, traces are saved to test-results/:
npx playwright show-trace test-results/path/to/trace.zipThe trace viewer shows DOM, network, console, screenshots, and per-step timing.
Configured via playwright.config.ts:
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
}Install "Playwright Test for VS Code" for:
- Run/debug tests inline in the editor
- Set breakpoints with full source maps
- View traces without leaving the IDE
Sample workflow step (works with Gitea Actions and GitHub Actions):
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run E2E Tests
run: npm run test:e2e
env:
CI: 'true'
- name: Upload Report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 14CI mode automatically:
- Enables 2 retries per failing test
- Drops to 1 worker (parallel can mask race conditions)
- Captures trace on first retry
- Outputs HTML report to
playwright-report/
// GOOD
await page.waitForSelector('[data-testid="response"]');
// BAD
await page.waitForTimeout(2000);Timeouts that pass occasionally fail intermittently. Always wait for a concrete condition (selector, network idle, response).
AI operations are slow. Don't set a 5-second timeout on an LLM response — set 60 seconds:
await page.waitForSelector('[data-testid="agent-response"]', { timeout: 60_000 });test.beforeEach(async ({ page }) => {
page.on('pageerror', () => {}); // don't fail the test on unrelated errors
});const empty = await page.locator(':text("No agents")').count() > 0;
const list = await page.locator('[data-testid="agent-card"]').count() > 0;
expect(empty || list).toBe(true);If you find yourself writing the same page.locator(...) twice, lift it into a page object method.
Run the full suite across Chromium, Firefox, and WebKit at least weekly. WebKit catches Safari-specific issues that other browsers miss.
- Increase per-test timeout (
test.setTimeout(60_000)) - Set
workers: 1to eliminate parallel race conditions - Verify the API and frontend services finish booting before tests start (
global-setup.tsshould poll until/api/v1/healthreturns 200)
- Delete
e2e/.auth/and re-run - Verify
test-credentials.jsonhas the expected user (re-runrails db:seedif it doesn't) - Check the login page object's selectors haven't drifted
- Replace
waitForTimeoutwithwaitForSelectororwaitForLoadState('networkidle') - Add explicit waits before assertions that depend on async UI updates
- Check for unhandled promise rejections in spec code
# Reinstall all
npx playwright install --force
# Only one browser
npx playwright install chromium- Testing — unit/integration testing (RSpec, Jest)
- Frontend — patterns the E2E tests exercise
- Backend — API contracts the E2E tests assume
This guide consolidates content from:
docs/testing/PLAYWRIGHT_E2E_TESTING.md
Last verified: 2026-05-17