.cursor/skills/playwright/SKILL.md
Spec-authoring rules live in web/tests/e2e/README.md — the Page Object Model, locator
priority, and auto-retrying matchers. Read it before adding or changing a spec. This skill covers
the surrounding workflow: layout, environment, running tests, auth, and Onyx-specific utilities.
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 Playwright against your local dev servers so the tests exercise your working-tree changes:
start ods web dev (frontend, :3000) and ods backend api (backend, :8080), then use the
config default BASE_URL=http://localhost:3000 — no override needed. In dev, next dev proxies
/api/* to the backend itself (the route handler web/src/app/api/[...path]/route.ts), so the UI
and /api both live on :3000; global setup needs the backend up too, since it registers/logs in
users over /api/auth/*.
Readiness check: curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/api/auth/type → 200.
Other bootstrap notes:
bunx playwright install chromium.web/ directory; global-setup.ts writes auth-state files (admin_auth.json, …) to the cwd. It's idempotent, so setup is fast.web/.vscode/.env may be absent — dotenv skips it silently (fine for most tests). One exception: tests/e2e/mcp/mcp_oauth_flow.spec.ts throws at import without MCP_OAUTH_* vars, which also breaks a whole-suite --list. Scope runs to specific files/dirs to avoid it.cd web # from the repo root
# Run a specific test file
bunx playwright test tests/e2e/chat/default_assistant.spec.ts
# Narrow to a single test by title (fast smoke check)
bunx playwright test tests/e2e/chat/welcome_page.spec.ts \
-g "chat input is visible and focusable" --project admin
| 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 popover// 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 commentsweb/tests/e2e/README.md), waitFor, or waitForLoadState; never waitForTimeout"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