Write and maintain Playwright end-to-end tests for the Onyx application. Use when creating new E2E tests, debugging test failures, adding test coverage, or when the user mentions Playwright, E2E tests, or browser testing.
web/tests/e2e/ — organized by feature (auth/, admin/, chat/, assistants/, connectors/, mcp/)web/playwright.config.tsweb/tests/e2e/utils/web/tests/e2e/constants.tsweb/tests/e2e/global-setup.tsweb/output/playwright/Always use absolute imports with the @tests/e2e/ prefix — never relative paths (../, ../../). The alias is defined in web/tsconfig.json and resolves to web/tests/.
import { loginAs } from "@tests/e2e/utils/auth";
import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient";
import { TEST_ADMIN_CREDENTIALS } from "@tests/e2e/constants";
All new files should be .ts, not .js.
# Run a specific test file
npx playwright test web/tests/e2e/chat/default_assistant.spec.ts
# Run a specific project
npx playwright test --project admin
npx playwright test --project exclusive
| Project | Description | Parallelism |
|---|---|---|
admin | Standard tests (excludes @exclusive) | Parallel |
exclusive | Serial, slower tests (tagged @exclusive) | 1 worker |
All tests use admin_auth.json storage state by default (pre-authenticated admin session).
Global setup (global-setup.ts) runs automatically before all tests and handles:
[email protected] through [email protected]) (idempotent)admin_auth.json, admin2_auth.json, and worker{N}_auth.json for each worker user"worker" for each worker userBoth test projects set storageState: "admin_auth.json", so every test starts pre-authenticated as admin with no login code needed.
When a test needs a different user, use API-based login — never drive the login UI:
import { loginAs } from "@tests/e2e/utils/auth";
await page.context().clearCookies();
await loginAs(page, "admin2");
// Log in as the worker-specific user (preferred for test isolation):
import { loginAsWorkerUser } from "@tests/e2e/utils/auth";
await page.context().clearCookies();
await loginAsWorkerUser(page, testInfo.workerIndex);
Tests start pre-authenticated as admin — navigate and test directly:
import { test, expect } from "@playwright/test";
test.describe("Feature Name", () => {
test("should describe expected behavior clearly", async ({ page }) => {
await page.goto("/app");
await page.waitForLoadState("networkidle");
// Already authenticated as admin — go straight to testing
});
});
User isolation — tests that modify visible app state (creating assistants, sending chat messages, pinning items) should run as a worker-specific user and clean up resources in afterAll. Global setup provisions a pool of worker users ([email protected] through [email protected]). loginAsWorkerUser maps testInfo.workerIndex to a pool slot via modulo, so retry workers (which get incrementing indices beyond the pool size) safely reuse existing users. This ensures parallel workers never share user state, keeps usernames deterministic for screenshots, and avoids cross-contamination:
import { test } from "@playwright/test";
import { loginAsWorkerUser } from "@tests/e2e/utils/auth";
test.beforeEach(async ({ page }, testInfo) => {
await page.context().clearCookies();
await loginAsWorkerUser(page, testInfo.workerIndex);
});
If the test requires admin privileges and modifies visible state, use "admin2" instead — it's a pre-provisioned admin account that keeps the primary "admin" clean for other parallel tests. Switch to "admin" only for privileged setup (creating providers, configuring tools), then back to the worker user for the actual test. See chat/default_assistant.spec.ts for a full example.
loginAsRandomUser exists for the rare case where the test requires a brand-new user (e.g. onboarding flows). Avoid it elsewhere — it produces non-deterministic usernames that complicate screenshots.
API resource setup — only when tests need to create backend resources (image gen configs, web search providers, MCP servers). Use beforeAll/afterAll with OnyxApiClient to create and clean up. See chat/default_assistant.spec.ts or mcp/mcp_oauth_flow.spec.ts for examples. This is uncommon (~4 of 37 test files).
OnyxApiClient (@tests/e2e/utils/onyxApiClient)Backend API client for test setup/teardown. Key methods:
createFileConnector(), deleteCCPair(), pauseConnector()ensurePublicProvider(), createRestrictedProvider(), setProviderAsDefault()createAssistant(), deleteAssistant(), findAssistantByName()createUserGroup(), deleteUserGroup(), setUserRole()createWebSearchProvider(), createImageGenerationConfig()createChatSession(), deleteChatSession()chatActions (@tests/e2e/utils/chatActions)sendMessage(page, message) — sends a message and waits for AI responsestartNewChat(page) — clicks new-chat button and waits for introverifyDefaultAssistantIsChosen(page) — checks Onyx logo is visibleverifyAssistantIsChosen(page, name) — checks assistant name displayswitchModel(page, modelName) — switches LLM model via popovervisualRegression (@tests/e2e/utils/visualRegression)expectScreenshot(page, { name, mask?, hide?, fullPage? })expectElementScreenshot(locator, { name, mask?, hide? })VISUAL_REGRESSION=true env vartheme (@tests/e2e/utils/theme)THEMES — ["light", "dark"] as const array for iterating over both themessetThemeBeforeNavigation(page, theme) — sets next-themes theme via localStorage before navigationWhen tests need light/dark screenshots, loop over THEMES at the test.describe level and call setThemeBeforeNavigation in beforeEach before any page.goto(). Include the theme in screenshot names. See admin/admin_pages.spec.ts or chat/chat_message_rendering.spec.ts for examples:
import { THEMES, setThemeBeforeNavigation } from "@tests/e2e/utils/theme";
for (const theme of THEMES) {
test.describe(`Feature (${theme} mode)`, () => {
test.beforeEach(async ({ page }) => {
await setThemeBeforeNavigation(page, theme);
});
test("renders correctly", async ({ page }) => {
await page.goto("/app");
await expectScreenshot(page, { name: `feature-${theme}` });
});
});
}
tools (@tests/e2e/utils/tools)TOOL_IDS — centralized data-testid selectors for tool optionsopenActionManagement(page) — opens the tool management popoverUse locators in this priority order:
data-testid / aria-label — preferred for Onyx components
page.getByTestId("AppSidebar/new-session")
page.getByLabel("admin-page-title")
Role-based — for standard HTML elements
page.getByRole("button", { name: "Create" })
page.getByRole("dialog")
Text/Label — for visible text content
page.getByText("Custom Assistant")
page.getByLabel("Email")
CSS selectors — last resort, only when above won't work
page.locator('input[name="name"]')
page.locator("#onyx-chat-input-textarea")
Never use page.locator with complex CSS/XPath when a built-in locator works.
Use web-first assertions — they auto-retry until the condition is met:
// Visibility
await expect(page.getByTestId("onyx-logo")).toBeVisible({ timeout: 5000 });
// Text content
await expect(page.getByTestId("assistant-name-display")).toHaveText("My Assistant");
// Count
await expect(page.locator('[data-testid="onyx-ai-message"]')).toHaveCount(2, { timeout: 30000 });
// URL
await expect(page).toHaveURL(/chatId=/);
// Element state
await expect(toggle).toBeChecked();
await expect(button).toBeEnabled();
Never use assert statements or hardcoded page.waitForTimeout().
// Wait for load state after navigation
await page.goto("/app");
await page.waitForLoadState("networkidle");
// Wait for specific element
await page.getByTestId("chat-intro").waitFor({ state: "visible", timeout: 10000 });
// Wait for URL change
await page.waitForFunction(() => window.location.href.includes("chatId="), null, { timeout: 10000 });
// Wait for network response
await page.waitForResponse(resp => resp.url().includes("/api/chat") && resp.status() === 200);
"should display greeting message when opening new chat"OnyxApiClient for backend state; reserve UI interactions for the behavior under testloginAsWorkerUser(page, testInfo.workerIndex) (not admin) and clean up resources in afterAll. Each parallel worker gets its own user, preventing cross-contamination. Reserve loginAsRandomUser for flows that require a brand-new user (e.g. onboarding)utils/ with JSDoc commentswaitFor, waitForLoadState, or web-first assertions"E2E-CMD Chat 1") and clean up resources by ID in afterAll. This keeps screenshots deterministic and avoids needing to mask/hide dynamic text. Only fall back to timestamps (\test-${Date.now()}``) when resources cannot be reliably cleaned up or when name collisions across parallel workers would cause functional failures@exclusive in the test titleexpectScreenshot() for UI consistency checks