diff --git a/web/tests/e2e/admin/default-assistant.spec.ts b/web/tests/e2e/admin/default-assistant.spec.ts index 2fb1284e7f..f5eedf9f8c 100644 --- a/web/tests/e2e/admin/default-assistant.spec.ts +++ b/web/tests/e2e/admin/default-assistant.spec.ts @@ -17,7 +17,7 @@ test.describe("Default Assistant Admin Page", () => { await page.context().clearCookies(); await loginAs(page, "admin"); - const apiClient = new OnyxApiClient(page); + const apiClient = new OnyxApiClient(page.request); // Create a connector so Internal Search tool becomes available testCcPairId = await apiClient.createFileConnector( @@ -83,7 +83,7 @@ test.describe("Default Assistant Admin Page", () => { }); test.afterEach(async ({ page }) => { - const apiClient = new OnyxApiClient(page); + const apiClient = new OnyxApiClient(page.request); // Clean up the test connector if (testCcPairId !== null) { diff --git a/web/tests/e2e/admin/discord-bot/fixtures.ts b/web/tests/e2e/admin/discord-bot/fixtures.ts index 6f556de0d9..03941f7e25 100644 --- a/web/tests/e2e/admin/discord-bot/fixtures.ts +++ b/web/tests/e2e/admin/discord-bot/fixtures.ts @@ -153,7 +153,7 @@ export const test = base.extend<{ // API client fixture - provides access to OnyxApiClient for backend operations apiClient: async ({ page }, use) => { await authenticateAdmin(page); - const client = new OnyxApiClient(page); + const client = new OnyxApiClient(page.request); await use(client); }, @@ -161,7 +161,7 @@ export const test = base.extend<{ seededGuild: async ({ page }, use) => { await authenticateAdmin(page); - const apiClient = new OnyxApiClient(page); + const apiClient = new OnyxApiClient(page.request); const guild = await apiClient.createDiscordGuild(); await use({ diff --git a/web/tests/e2e/admin/image-generation/image-generation-content.spec.ts b/web/tests/e2e/admin/image-generation/image-generation-content.spec.ts index b22b30b081..b0089a7e93 100644 --- a/web/tests/e2e/admin/image-generation/image-generation-content.spec.ts +++ b/web/tests/e2e/admin/image-generation/image-generation-content.spec.ts @@ -89,7 +89,7 @@ test.describe("Image Generation Provider Configuration", () => { test.afterEach(async ({ page }) => { // Clean up the image generation config created during the test - const apiClient = new OnyxApiClient(page); + const apiClient = new OnyxApiClient(page.request); try { await apiClient.deleteImageGenerationConfig("openai_dalle_3"); console.log("[image-gen-test] Cleaned up DALL-E 3 config"); diff --git a/web/tests/e2e/admin/theme/appearance_theme_settings.spec.ts b/web/tests/e2e/admin/theme/appearance_theme_settings.spec.ts index 0d278a67f5..760f68ad4c 100644 --- a/web/tests/e2e/admin/theme/appearance_theme_settings.spec.ts +++ b/web/tests/e2e/admin/theme/appearance_theme_settings.spec.ts @@ -16,6 +16,21 @@ test.describe("Appearance Theme Settings", () => { await page.context().clearCookies(); await loginAs(page, "admin"); + // Navigate first so localStorage is accessible (API-based login + // doesn't navigate, leaving the page on about:blank). + await page.goto("/admin/theme"); + await page.waitForLoadState("networkidle"); + + // Skip the entire test when Enterprise features are not licensed. + // The /admin/theme page is gated behind ee_features_enabled and + // renders a license-required message instead of the settings form. + const eeLocked = page.getByText( + "This functionality requires an active Enterprise license." + ); + if (await eeLocked.isVisible({ timeout: 1000 }).catch(() => false)) { + test.skip(true, "Enterprise license not active — skipping theme tests"); + } + // Clear localStorage to ensure consent modal shows await page.evaluate(() => { localStorage.removeItem("allUsersInitialPopupFlowCompleted"); @@ -24,11 +39,17 @@ test.describe("Appearance Theme Settings", () => { test.afterEach(async ({ page }) => { // Reset settings to defaults - await page.goto("http://localhost:3000/admin/theme"); + await page.goto("/admin/theme"); await page.waitForLoadState("networkidle"); - // Clear form fields + // If the form isn't visible (e.g. EE license not active, or test failed + // before navigating here), skip cleanup — there's nothing to reset. const appNameInput = page.locator('[data-label="application-name-input"]'); + if (!(await appNameInput.isVisible({ timeout: 3000 }).catch(() => false))) { + return; + } + + // Clear form fields await appNameInput.clear(); const greetingInput = page.locator('[data-label="greeting-message-input"]'); @@ -70,11 +91,7 @@ test.describe("Appearance Theme Settings", () => { test("admin configures branding and verifies across pages", async ({ page, }) => { - // 1. Navigate to theme page - await page.goto("http://localhost:3000/admin/theme"); - await page.waitForLoadState("networkidle"); - - // 2. Fill in Application Name + // 1. Fill in Application Name (page already navigated in beforeEach) const appNameInput = page.locator('[data-label="application-name-input"]'); await appNameInput.fill(TEST_VALUES.applicationName); @@ -154,7 +171,7 @@ test.describe("Appearance Theme Settings", () => { await page.evaluate(() => { localStorage.removeItem("allUsersInitialPopupFlowCompleted"); }); - await page.goto("http://localhost:3000/app"); + await page.goto("/app"); await page.waitForLoadState("networkidle"); // 16. Handle consent modal diff --git a/web/tests/e2e/assistants/create_and_edit_assistant.spec.ts b/web/tests/e2e/assistants/create_and_edit_assistant.spec.ts index 064b532f1a..a3340a029a 100644 --- a/web/tests/e2e/assistants/create_and_edit_assistant.spec.ts +++ b/web/tests/e2e/assistants/create_and_edit_assistant.spec.ts @@ -180,7 +180,7 @@ test.describe("Assistant Creation and Edit Verification", () => { storageState: "admin_auth.json", }); const page = await context.newPage(); - const cleanupClient = new OnyxApiClient(page); + const cleanupClient = new OnyxApiClient(page.request); await cleanupClient.deleteDocumentSet(documentSetId); await cleanupClient.deleteCCPair(ccPairId); await context.close(); @@ -200,7 +200,7 @@ test.describe("Assistant Creation and Edit Verification", () => { await loginAs(page, "admin"); // Create a connector and document set to enable the Knowledge toggle - const onyxApiClient = new OnyxApiClient(page); + const onyxApiClient = new OnyxApiClient(page.request); ccPairId = await onyxApiClient.createFileConnector("Test Connector"); documentSetId = await onyxApiClient.createDocumentSet( "Test Document Set", diff --git a/web/tests/e2e/assistants/llm_provider_rbac.spec.ts b/web/tests/e2e/assistants/llm_provider_rbac.spec.ts index 4e8ea0395e..789f6a2bad 100644 --- a/web/tests/e2e/assistants/llm_provider_rbac.spec.ts +++ b/web/tests/e2e/assistants/llm_provider_rbac.spec.ts @@ -50,7 +50,7 @@ test("Restricted LLM Provider should not appear for unauthorized users", async ( let groupId: number | null = null; let providerId: number | null = null; - const client = new OnyxApiClient(page); + const client = new OnyxApiClient(page.request); try { groupId = await client.createUserGroup(restrictedGroupName); diff --git a/web/tests/e2e/auth/login.spec.ts b/web/tests/e2e/auth/login.spec.ts new file mode 100644 index 0000000000..8a71aee6e0 --- /dev/null +++ b/web/tests/e2e/auth/login.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from "@playwright/test"; +import { TEST_ADMIN_CREDENTIALS, TEST_USER_CREDENTIALS } from "../constants"; + +// These tests exercise the browser login UI. +// They clear cookies to start unauthenticated, then drive the login form. + +test.describe("Login flow", () => { + test.beforeEach(async ({ page }) => { + await page.context().clearCookies(); + }); + + test("Login page renders email and password fields", async ({ page }) => { + await page.goto("/auth/login"); + await page.waitForLoadState("networkidle"); + + await expect(page.getByTestId("email")).toBeVisible(); + await expect(page.getByTestId("password")).toBeVisible(); + await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible(); + }); + + test("User can log in with valid credentials", async ({ page }) => { + const { email, password } = TEST_ADMIN_CREDENTIALS; + + await page.goto("/auth/login"); + await page.waitForLoadState("networkidle"); + + await page.getByTestId("email").fill(email); + await page.getByTestId("password").fill(password); + await page.getByRole("button", { name: "Sign In" }).click(); + + await expect(page).toHaveURL(/\/app/); + + // Verify the session is valid + const me = await page.request.get("/api/me"); + expect(me.ok()).toBe(true); + const body = await me.json(); + expect(body.email).toBe(email); + }); + + test("Login fails with invalid password", async ({ page }) => { + await page.goto("/auth/login"); + await page.waitForLoadState("networkidle"); + + await page.getByTestId("email").fill(TEST_USER_CREDENTIALS.email); + await page.getByTestId("password").fill("WrongPassword123!"); + await page.getByRole("button", { name: "Sign In" }).click(); + + // Should stay on the login page + await expect(page).toHaveURL(/\/auth\/login/); + + // Should not be authenticated + const me = await page.request.get("/api/me"); + expect(me.ok()).toBe(false); + }); + + test("Login fails with non-existent user", async ({ page }) => { + await page.goto("/auth/login"); + await page.waitForLoadState("networkidle"); + + await page.getByTestId("email").fill("nonexistent@example.com"); + await page.getByTestId("password").fill("SomePassword123!"); + await page.getByRole("button", { name: "Sign In" }).click(); + + // Should stay on the login page + await expect(page).toHaveURL(/\/auth\/login/); + }); +}); diff --git a/web/tests/e2e/chat-search-command-menu.spec.ts b/web/tests/e2e/chat-search-command-menu.spec.ts index 9d94f2e005..7c4e50886e 100644 --- a/web/tests/e2e/chat-search-command-menu.spec.ts +++ b/web/tests/e2e/chat-search-command-menu.spec.ts @@ -42,7 +42,7 @@ test.describe("Chat Search Command Menu", () => { storageState: "admin_auth.json", }); const page = await context.newPage(); - const client = new OnyxApiClient(page); + const client = new OnyxApiClient(page.request); // Navigate to app to establish session await page.goto("/app"); @@ -69,7 +69,7 @@ test.describe("Chat Search Command Menu", () => { storageState: "admin_auth.json", }); const page = await context.newPage(); - const client = new OnyxApiClient(page); + const client = new OnyxApiClient(page.request); // Navigate to app to establish session await page.goto("/app"); diff --git a/web/tests/e2e/chat/default_assistant.spec.ts b/web/tests/e2e/chat/default_assistant.spec.ts index 40be95c330..7e65e7091a 100644 --- a/web/tests/e2e/chat/default_assistant.spec.ts +++ b/web/tests/e2e/chat/default_assistant.spec.ts @@ -29,7 +29,7 @@ test.describe("Default Assistant Tests", () => { await adminPage.goto("http://localhost:3000/app"); await adminPage.waitForLoadState("networkidle"); - const apiClient = new OnyxApiClient(adminPage); + const apiClient = new OnyxApiClient(adminPage.request); try { imageGenConfigId = await apiClient.createImageGenerationConfig( `test-default-assistant-${Date.now()}` @@ -51,7 +51,7 @@ test.describe("Default Assistant Tests", () => { await adminPage.goto("http://localhost:3000/app"); await adminPage.waitForLoadState("networkidle"); - const apiClient = new OnyxApiClient(adminPage); + const apiClient = new OnyxApiClient(adminPage.request); await apiClient.deleteImageGenerationConfig(imageGenConfigId); await adminContext.close(); @@ -317,7 +317,7 @@ test.describe("Default Assistant Tests", () => { await adminPage.goto("http://localhost:3000/app"); await adminPage.waitForLoadState("networkidle"); - const apiClient = new OnyxApiClient(adminPage); + const apiClient = new OnyxApiClient(adminPage.request); try { imageGenConfigId = await apiClient.createImageGenerationConfig( `test-action-toggle-${Date.now()}` @@ -339,7 +339,7 @@ test.describe("Default Assistant Tests", () => { await adminPage.goto("http://localhost:3000/app"); await adminPage.waitForLoadState("networkidle"); - const apiClient = new OnyxApiClient(adminPage); + const apiClient = new OnyxApiClient(adminPage.request); await apiClient.deleteImageGenerationConfig(imageGenConfigId); await adminContext.close(); @@ -364,7 +364,7 @@ test.describe("Default Assistant Tests", () => { await page.goto("/app"); await page.waitForLoadState("domcontentloaded"); - const apiClient = new OnyxApiClient(page); + const apiClient = new OnyxApiClient(page.request); let webSearchProviderId: number | null = null; try { @@ -555,7 +555,7 @@ test.describe("End-to-End Default Assistant Flow", () => { await adminPage.goto("http://localhost:3000/app"); await adminPage.waitForLoadState("networkidle"); - const apiClient = new OnyxApiClient(adminPage); + const apiClient = new OnyxApiClient(adminPage.request); try { imageGenConfigId = await apiClient.createImageGenerationConfig( `test-e2e-journey-${Date.now()}` @@ -577,7 +577,7 @@ test.describe("End-to-End Default Assistant Flow", () => { await adminPage.goto("http://localhost:3000/app"); await adminPage.waitForLoadState("networkidle"); - const apiClient = new OnyxApiClient(adminPage); + const apiClient = new OnyxApiClient(adminPage.request); await apiClient.deleteImageGenerationConfig(imageGenConfigId); await adminContext.close(); diff --git a/web/tests/e2e/chat/llm_ordering.spec.ts b/web/tests/e2e/chat/llm_ordering.spec.ts index 699d7993dd..aa3054e7d5 100644 --- a/web/tests/e2e/chat/llm_ordering.spec.ts +++ b/web/tests/e2e/chat/llm_ordering.spec.ts @@ -11,7 +11,7 @@ test.describe("LLM Ordering", () => { await page.context().clearCookies(); await loginAs(page, "admin"); - const apiClient = new OnyxApiClient(page); + const apiClient = new OnyxApiClient(page.request); // Create image generation config so the checkbox appears try { @@ -24,7 +24,7 @@ test.describe("LLM Ordering", () => { }); test.afterEach(async ({ page }) => { - const apiClient = new OnyxApiClient(page); + const apiClient = new OnyxApiClient(page.request); if (imageGenConfigId !== null) { try { diff --git a/web/tests/e2e/connectors/inlineFileManagement.spec.ts b/web/tests/e2e/connectors/inlineFileManagement.spec.ts index 5d763d4f6f..d236d54df2 100644 --- a/web/tests/e2e/connectors/inlineFileManagement.spec.ts +++ b/web/tests/e2e/connectors/inlineFileManagement.spec.ts @@ -47,14 +47,14 @@ test.describe("InlineFileManagement", () => { await page.context().clearCookies(); await loginAs(page, "admin"); - const apiClient = new OnyxApiClient(page); + const apiClient = new OnyxApiClient(page.request); testCcPairId = await apiClient.createFileConnector( `Test File Connector ${Date.now()}` ); }); test.afterEach(async ({ page }) => { - const apiClient = new OnyxApiClient(page); + const apiClient = new OnyxApiClient(page.request); if (testCcPairId !== null) { try { diff --git a/web/tests/e2e/fixtures/llmProvider.ts b/web/tests/e2e/fixtures/llmProvider.ts new file mode 100644 index 0000000000..48d7242a10 --- /dev/null +++ b/web/tests/e2e/fixtures/llmProvider.ts @@ -0,0 +1,52 @@ +/** + * Playwright fixture that ensures a public LLM provider is available. + * + * Usage: + * ```ts + * // Import from this file instead of @playwright/test + * import { test, expect } from "@tests/e2e/fixtures/llmProvider"; + * + * test("my test that needs an LLM provider", async ({ page, llmProviderId }) => { + * // llmProviderId is the ID of the provider that was created (or null if + * // one already existed). The fixture handles cleanup automatically. + * }); + * ``` + * + * The fixture: + * - Authenticates as admin + * - Creates a public LLM provider if none exists + * - Provides the created provider ID to the test + * - Cleans up the provider after all tests in the file complete + */ + +import { test as base, expect } from "@playwright/test"; +import { loginAs } from "@tests/e2e/utils/auth"; +import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient"; + +export const test = base.extend<{ + /** + * The ID of the public LLM provider created by this fixture, or `null` + * if a public provider already existed. + */ + llmProviderId: number | null; +}>({ + llmProviderId: async ({ page }, use) => { + // Authenticate as admin to be able to create/list providers + await page.context().clearCookies(); + await loginAs(page, "admin"); + + const client = new OnyxApiClient(page.request); + const createdId = await client.ensurePublicProvider(); + await use(createdId); + + // Cleanup: only delete if we created one + if (createdId !== null) { + // Re-authenticate in case the test changed the session + await page.context().clearCookies(); + await loginAs(page, "admin"); + await client.deleteProvider(createdId); + } + }, +}); + +export { expect }; diff --git a/web/tests/e2e/global-setup.ts b/web/tests/e2e/global-setup.ts index 833c190962..c9ee7734d7 100644 --- a/web/tests/e2e/global-setup.ts +++ b/web/tests/e2e/global-setup.ts @@ -1,10 +1,10 @@ -import { chromium, FullConfig, request } from "@playwright/test"; -import { loginAs } from "./utils/auth"; +import { FullConfig, request } from "@playwright/test"; import { TEST_ADMIN_CREDENTIALS, TEST_ADMIN2_CREDENTIALS, TEST_USER_CREDENTIALS, } from "./constants"; +import { OnyxApiClient } from "./utils/onyxApiClient"; const PREFLIGHT_TIMEOUT_MS = 60_000; const PREFLIGHT_POLL_INTERVAL_MS = 2_000; @@ -78,6 +78,34 @@ async function ensureUserExists( } } +/** + * Log in via the API and save the resulting cookies as a Playwright storage + * state file. No browser is needed — this uses Playwright's lightweight + * request context, which is much faster and produces no console noise. + */ +async function apiLoginAndSaveState( + baseURL: string, + email: string, + password: string, + storageStatePath: string +): Promise { + const ctx = await request.newContext({ baseURL }); + try { + const res = await ctx.post("/api/auth/login", { + form: { username: email, password }, + }); + if (!res.ok()) { + const body = await res.text(); + throw new Error( + `[global-setup] Login failed for ${email}: ${res.status()} ${body}` + ); + } + await ctx.storageState({ path: storageStatePath }); + } finally { + await ctx.dispose(); + } +} + /** * Promote a user to admin via the manage API. * Requires an authenticated context (admin storage state). @@ -147,14 +175,13 @@ async function globalSetup(config: FullConfig) { TEST_ADMIN2_CREDENTIALS.password ); - // ── Login and save storage state ───────────────────────────────────── - const browser = await chromium.launch(); - - const adminContext = await browser.newContext({ baseURL }); - const adminPage = await adminContext.newPage(); - await loginAs(adminPage, "admin"); - await adminContext.storageState({ path: "admin_auth.json" }); - await adminContext.close(); + // ── Login via API and save storage state ─────────────────────────── + await apiLoginAndSaveState( + baseURL, + TEST_ADMIN_CREDENTIALS.email, + TEST_ADMIN_CREDENTIALS.password, + "admin_auth.json" + ); // Promote admin2 now that we have an admin session await promoteToAdmin( @@ -163,19 +190,33 @@ async function globalSetup(config: FullConfig) { TEST_ADMIN2_CREDENTIALS.email ); - const userContext = await browser.newContext({ baseURL }); - const userPage = await userContext.newPage(); - await loginAs(userPage, "user"); - await userContext.storageState({ path: "user_auth.json" }); - await userContext.close(); + await apiLoginAndSaveState( + baseURL, + TEST_USER_CREDENTIALS.email, + TEST_USER_CREDENTIALS.password, + "user_auth.json" + ); - const admin2Context = await browser.newContext({ baseURL }); - const admin2Page = await admin2Context.newPage(); - await loginAs(admin2Page, "admin2"); - await admin2Context.storageState({ path: "admin2_auth.json" }); - await admin2Context.close(); + await apiLoginAndSaveState( + baseURL, + TEST_ADMIN2_CREDENTIALS.email, + TEST_ADMIN2_CREDENTIALS.password, + "admin2_auth.json" + ); - await browser.close(); + // ── Ensure a public LLM provider exists ─────────────────────────── + // Many tests depend on a default LLM being configured (file uploads, + // assistant creation, etc.). Re-use the admin session we just saved. + const adminCtx = await request.newContext({ + baseURL, + storageState: "admin_auth.json", + }); + try { + const client = new OnyxApiClient(adminCtx, baseURL); + await client.ensurePublicProvider(); + } finally { + await adminCtx.dispose(); + } } export default globalSetup; diff --git a/web/tests/e2e/mcp/default-assistant-mcp.spec.ts b/web/tests/e2e/mcp/default-assistant-mcp.spec.ts index bfdb3cc2b0..3d670b6de4 100644 --- a/web/tests/e2e/mcp/default-assistant-mcp.spec.ts +++ b/web/tests/e2e/mcp/default-assistant-mcp.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "@playwright/test"; import type { Page } from "@playwright/test"; -import { loginAs, loginWithCredentials } from "../utils/auth"; +import { loginAs, apiLogin } from "../utils/auth"; import { OnyxApiClient } from "../utils/onyxApiClient"; import { startMcpApiKeyServer, McpServerProcess } from "../utils/mcpServer"; @@ -90,15 +90,10 @@ test.describe("Default Assistant MCP Integration", () => { storageState: "admin_auth.json", }); const adminPage = await adminContext.newPage(); - const adminClient = new OnyxApiClient(adminPage); + const adminClient = new OnyxApiClient(adminPage.request); - const providers = await adminClient.listLlmProviders(); - const hasPublicProvider = providers.some((provider) => provider.is_public); - if (!hasPublicProvider) { - createdProviderId = await adminClient.createPublicProvider( - `PW Public Provider ${Date.now()}` - ); - } + // Ensure a public LLM provider exists + createdProviderId = await adminClient.ensurePublicProvider(); // Clean up any existing servers with the same URL try { @@ -125,9 +120,9 @@ test.describe("Default Assistant MCP Integration", () => { storageState: "admin_auth.json", }); const adminPage = await adminContext.newPage(); - const adminClient = new OnyxApiClient(adminPage); + const adminClient = new OnyxApiClient(adminPage.request); - if (createdProviderId) { + if (createdProviderId !== null) { await adminClient.deleteProvider(createdProviderId); } @@ -361,7 +356,7 @@ test.describe("Default Assistant MCP Integration", () => { test.skip(!basicUserEmail, "Basic user must be created first"); await page.context().clearCookies(); - await loginWithCredentials(page, basicUserEmail, basicUserPassword); + await apiLogin(page, basicUserEmail, basicUserPassword); console.log(`[test] Logged in as basic user: ${basicUserEmail}`); // Navigate to chat (which uses default assistant for new users) @@ -470,7 +465,7 @@ test.describe("Default Assistant MCP Integration", () => { test.skip(!basicUserEmail, "Basic user must be created first"); await page.context().clearCookies(); - await loginWithCredentials(page, basicUserEmail, basicUserPassword); + await apiLogin(page, basicUserEmail, basicUserPassword); await page.goto("/app"); await ensureOnboardingComplete(page); @@ -517,7 +512,7 @@ test.describe("Default Assistant MCP Integration", () => { const assistantId = assistantIdMatch ? assistantIdMatch[1] : null; expect(assistantId).not.toBeNull(); - const client = new OnyxApiClient(page); + const client = new OnyxApiClient(page.request); const assistant = await client.getAssistant(Number(assistantId)); const hasMcpTool = assistant.tools.some( (tool) => tool.mcp_server_id === serverId @@ -665,7 +660,7 @@ test.describe("Default Assistant MCP Integration", () => { test.skip(!basicUserEmail, "Basic user must be created first"); await page.context().clearCookies(); - await loginWithCredentials(page, basicUserEmail, basicUserPassword); + await apiLogin(page, basicUserEmail, basicUserPassword); console.log(`[test] Logged in as basic user to verify tool visibility`); // Navigate to chat diff --git a/web/tests/e2e/mcp/mcp_oauth_flow.spec.ts b/web/tests/e2e/mcp/mcp_oauth_flow.spec.ts index 9c6a3a0dee..356a40c207 100644 --- a/web/tests/e2e/mcp/mcp_oauth_flow.spec.ts +++ b/web/tests/e2e/mcp/mcp_oauth_flow.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "@playwright/test"; import type { Page, Browser, Locator } from "@playwright/test"; -import { loginAs, loginWithCredentials } from "../utils/auth"; +import { loginAs, apiLogin } from "../utils/auth"; import { OnyxApiClient } from "../utils/onyxApiClient"; import { startMcpOauthServer, McpServerProcess } from "../utils/mcpServer"; import { TEST_ADMIN_CREDENTIALS } from "../constants"; @@ -1056,7 +1056,7 @@ test.describe("MCP OAuth flows", () => { storageState: "admin_auth.json", }); const adminPage = await adminContext.newPage(); - const adminClient = new OnyxApiClient(adminPage); + const adminClient = new OnyxApiClient(adminPage.request); try { const existingServers = await adminClient.listMcpServers(); for (const server of existingServers) { @@ -1128,7 +1128,7 @@ test.describe("MCP OAuth flows", () => { storageState: "admin_auth.json", }); const adminPage = await adminContext.newPage(); - const adminClient = new OnyxApiClient(adminPage); + const adminClient = new OnyxApiClient(adminPage.request); if (adminArtifacts?.assistantId) { await adminClient.deleteAssistant(adminArtifacts.assistantId); @@ -1180,7 +1180,7 @@ test.describe("MCP OAuth flows", () => { { email: TEST_ADMIN_CREDENTIALS.email, role: "admin" }, "AdminFlow primary login" ); - const adminApiClient = new OnyxApiClient(page); + const adminApiClient = new OnyxApiClient(page.request); logStep("Logged in as admin"); const serverName = `PW MCP Admin ${Date.now()}`; @@ -1432,7 +1432,7 @@ test.describe("MCP OAuth flows", () => { await page.context().clearCookies(); logStep("Cleared cookies"); - await loginWithCredentials( + await apiLogin( page, curatorCredentials!.email, curatorCredentials!.password @@ -1443,7 +1443,7 @@ test.describe("MCP OAuth flows", () => { "CuratorFlow primary login" ); logStep("Logged in as curator"); - const curatorApiClient = new OnyxApiClient(page); + const curatorApiClient = new OnyxApiClient(page.request); const serverName = `PW MCP Curator ${Date.now()}`; const assistantName = `PW Curator Assistant ${Date.now()}`; @@ -1654,7 +1654,7 @@ test.describe("MCP OAuth flows", () => { curatorTwoPage, "CuratorFlow secondary pre-login logout" ); - await loginWithCredentials( + await apiLogin( curatorTwoPage, curatorTwoCredentials!.email, curatorTwoCredentials!.password diff --git a/web/tests/e2e/utils/auth.ts b/web/tests/e2e/utils/auth.ts index ebb0bd9914..5479ed6eb3 100644 --- a/web/tests/e2e/utils/auth.ts +++ b/web/tests/e2e/utils/auth.ts @@ -1,17 +1,34 @@ -import type { Locator, Page } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { TEST_ADMIN2_CREDENTIALS, TEST_ADMIN_CREDENTIALS, TEST_USER_CREDENTIALS, } from "../constants"; -import { logPageState } from "./pageStateLogger"; -// Logs in a user (admin, user, or admin2) to the application. +/** + * Log in via the API and set cookies on the page's browser context. + * Much faster than navigating through the login UI. + */ +export async function apiLogin( + page: Page, + email: string, + password: string +): Promise { + const res = await page.request.post("/api/auth/login", { + form: { username: email, password }, + }); + if (!res.ok()) { + const body = await res.text(); + throw new Error(`API login failed for ${email}: ${res.status()} ${body}`); + } +} + +// Logs in a known test user (admin, user, or admin2) via the API. // Users must already be provisioned (see global-setup.ts). export async function loginAs( page: Page, userType: "admin" | "user" | "admin2" -) { +): Promise { const { email, password } = userType === "admin" ? TEST_ADMIN_CREDENTIALS @@ -19,92 +36,10 @@ export async function loginAs( ? TEST_ADMIN2_CREDENTIALS : TEST_USER_CREDENTIALS; - const waitForVisible = async ( - locator: Locator, - debugContext: string, - timeoutMs = 30000 - ) => { - try { - await locator.waitFor({ state: "visible", timeout: timeoutMs }); - } catch (error) { - await logPageState(page, debugContext, "[login-debug]"); - throw error; - } - }; - - const fillCredentials = async (contextLabel: string) => { - const emailInput = page.getByTestId("email"); - const passwordInput = page.getByTestId("password"); - await waitForVisible(emailInput, `${contextLabel}: email input`); - await waitForVisible(passwordInput, `${contextLabel}: password input`); - await emailInput.fill(email); - await passwordInput.fill(password); - }; - - console.log(`[loginAs] Navigating to /auth/login as ${userType}`); - await page.goto("/auth/login"); - - // Wait for navigation to settle (login page may redirect to signup if no users exist) - await page.waitForLoadState("networkidle"); - - const currentUrl = page.url(); - const isOnSignup = currentUrl.includes("/auth/signup"); - console.log( - `[loginAs] After navigation, landed on: ${currentUrl} (isOnSignup: ${isOnSignup})` - ); - - await fillCredentials( - isOnSignup ? "loginAs signup form" : "loginAs login form" - ); - - // Click the submit button - await page.click('button[type="submit"]'); - // Log any console errors during login - page.on("console", (msg) => { - if (msg.type() === "error") { - console.log(`[page:console:error] ${msg.text()}`); - } - }); - - try { - await page.waitForURL(/\/app.*/, { timeout: 10000 }); - console.log( - `[loginAs] Redirected to /app for ${userType}. URL: ${page.url()}` - ); - } catch { - await logPageState( - page, - `[loginAs] Timeout waiting for /app redirect (${userType}). URL: ${page.url()}`, - "[login-debug]" - ); - throw new Error( - `[loginAs] Failed to login as ${userType}. Current URL: ${page.url()}` - ); - } - - try { - // Try to fetch current user info from the page context - const me = await page.evaluate(async () => { - try { - const res = await fetch("/api/me", { credentials: "include" }); - return { - ok: res.ok, - status: res.status, - url: res.url, - body: await res.text(), - }; - } catch (e) { - return { ok: false, status: 0, url: "", body: `error: ${String(e)}` }; - } - }); - console.log( - `[loginAs] /api/me => ok=${me.ok} status=${me.status} url=${me.url}` - ); - } catch (e) { - console.log(`[loginAs] Failed to query /api/me: ${String(e)}`); - } + await apiLogin(page, email, password); } -// Function to generate a random email and password + +// Generate a random email and password for throwaway test users. const generateRandomCredentials = () => { const randomString = Math.random().toString(36).substring(2, 10); const specialChars = "!@#$%^&*()_+{}[]|:;<>,.?~"; @@ -121,57 +56,28 @@ const generateRandomCredentials = () => { }; }; -// Function to sign up a new random user -export async function loginAsRandomUser(page: Page) { +// Register and log in as a new random user via the API. +export async function loginAsRandomUser(page: Page): Promise<{ + email: string; + password: string; +}> { const { email, password } = generateRandomCredentials(); - await page.goto("/auth/signup"); - - const emailInput = page.getByTestId("email"); - const passwordInput = page.getByTestId("password"); - await emailInput.waitFor({ state: "visible", timeout: 30000 }); - await emailInput.fill(email); - await passwordInput.fill(password); - - // Click the signup button - await page.click('button[type="submit"]'); - try { - await page.waitForURL(/\/(app|chat)(\?.*)?$/, { timeout: 30000 }); - await page.waitForLoadState("domcontentloaded"); - } catch { - console.log(`Timeout occurred. Current URL: ${page.url()}`); - throw new Error("Failed to sign up and redirect to app page"); + const registerRes = await page.request.post("/api/auth/register", { + data: { email, username: email, password }, + }); + if (!registerRes.ok()) { + const body = await registerRes.text(); + throw new Error( + `Failed to register random user ${email}: ${registerRes.status()} ${body}` + ); } + await apiLogin(page, email, password); + + // Navigate to the app so the page is ready for test interactions + await page.goto("/app?new_team=true"); + await page.waitForLoadState("networkidle"); + return { email, password }; } - -export async function loginWithCredentials( - page: Page, - email: string, - password: string -) { - if (process.env.SKIP_AUTH === "true") { - console.log("[loginWithCredentials] Skipping authentication"); - return; - } - - await page.goto("/auth/login"); - - // Wait for navigation to settle (login page may redirect to signup if no users exist) - await page.waitForLoadState("networkidle"); - - const currentUrl = page.url(); - const isOnSignup = currentUrl.includes("/auth/signup"); - console.log( - `[loginWithCredentials] After navigation, landed on: ${currentUrl} (isOnSignup: ${isOnSignup})` - ); - - const emailInput = page.getByTestId("email"); - const passwordInput = page.getByTestId("password"); - await emailInput.waitFor({ state: "visible", timeout: 30000 }); - await emailInput.fill(email); - await passwordInput.fill(password); - await page.click('button[type="submit"]'); - await page.waitForURL(/\/app.*/, { timeout: 15000 }); -} diff --git a/web/tests/e2e/utils/onyxApiClient.ts b/web/tests/e2e/utils/onyxApiClient.ts index 5c0cfe5e74..bfd8f02892 100644 --- a/web/tests/e2e/utils/onyxApiClient.ts +++ b/web/tests/e2e/utils/onyxApiClient.ts @@ -1,4 +1,4 @@ -import { Page, expect, APIResponse } from "@playwright/test"; +import { APIRequestContext, expect, APIResponse } from "@playwright/test"; /** * API Client for Onyx backend operations in E2E tests. @@ -18,7 +18,10 @@ import { Page, expect, APIResponse } from "@playwright/test"; * - `deleteDocumentSet(id)` - Deletes a document set (with polling until complete) * * **LLM Providers:** + * - `listLlmProviders()` - Lists LLM providers (admin endpoint, includes is_public) + * - `ensurePublicProvider(name?)` - Idempotently creates a public default LLM provider * - `createRestrictedProvider(name, groupId)` - Creates a restricted LLM provider assigned to a group + * - `setProviderAsDefault(id)` - Sets an LLM provider as the default for chat * - `deleteProvider(id)` - Deletes an LLM provider * * **User Groups:** @@ -41,19 +44,33 @@ import { Page, expect, APIResponse } from "@playwright/test"; * * **Usage Example:** * ```typescript - * const client = new OnyxApiClient(page); - * const { ccPairId } = await client.createFileConnector("Test Connector"); - * const docSetId = await client.createDocumentSet("Test Set", [ccPairId]); - * await client.deleteDocumentSet(docSetId); - * await client.deleteCCPair(ccPairId); + * // From a test with a Page: + * const client = new OnyxApiClient(page.request); + * + * // From global-setup with a standalone context (pass baseURL explicitly): + * const ctx = await request.newContext({ baseURL, storageState: "admin_auth.json" }); + * const client = new OnyxApiClient(ctx, baseURL); * ``` * - * @param page - Playwright Page instance with authenticated session + * @param request - Playwright APIRequestContext with authenticated session + * (e.g. `page.request`, `context.request`, or `request.newContext()`) + * @param baseUrl - Optional base URL override (e.g. `http://localhost:3000`). + * Defaults to `process.env.BASE_URL` or `http://localhost:3000`. + * Pass this when the Playwright-configured baseURL differs from + * the env var (e.g. in `global-setup.ts` where the config value + * is authoritative). */ export class OnyxApiClient { - private baseUrl = `${process.env.BASE_URL || "http://localhost:3000"}/api`; + private baseUrl: string; - constructor(private page: Page) {} + constructor( + private request: APIRequestContext, + baseUrl?: string + ) { + this.baseUrl = `${ + baseUrl ?? process.env.BASE_URL ?? "http://localhost:3000" + }/api`; + } /** * Generic GET request to the API. @@ -62,7 +79,7 @@ export class OnyxApiClient { * @returns The API response */ private async get(endpoint: string): Promise { - return await this.page.request.get(`${this.baseUrl}${endpoint}`); + return await this.request.get(`${this.baseUrl}${endpoint}`); } /** @@ -73,7 +90,7 @@ export class OnyxApiClient { * @returns The API response */ private async post(endpoint: string, data?: any): Promise { - return await this.page.request.post(`${this.baseUrl}${endpoint}`, { + return await this.request.post(`${this.baseUrl}${endpoint}`, { data, }); } @@ -85,7 +102,7 @@ export class OnyxApiClient { * @returns The API response */ private async delete(endpoint: string): Promise { - return await this.page.request.delete(`${this.baseUrl}${endpoint}`); + return await this.request.delete(`${this.baseUrl}${endpoint}`); } /** @@ -96,7 +113,7 @@ export class OnyxApiClient { * @returns The API response */ private async put(endpoint: string, data?: any): Promise { - return await this.page.request.put(`${this.baseUrl}${endpoint}`, { + return await this.request.put(`${this.baseUrl}${endpoint}`, { data, }); } @@ -371,7 +388,7 @@ export class OnyxApiClient { providerName: string, groupId: number ): Promise { - const response = await this.page.request.put( + const response = await this.request.put( `${this.baseUrl}/admin/llm/provider?is_creation=true`, { data: { @@ -397,18 +414,41 @@ export class OnyxApiClient { return responseData.id; } + /** + * Lists LLM providers visible to the admin (includes `is_public`). + * + * @returns Array of LLM providers with id and is_public fields + */ async listLlmProviders(): Promise< Array<{ id: number; is_public?: boolean; }> > { - const response = await this.get("/llm/provider"); + const response = await this.get("/admin/llm/provider"); return await this.handleResponse(response, "Failed to list LLM providers"); } - async createPublicProvider(providerName: string): Promise { - const response = await this.page.request.put( + /** + * Ensure at least one public LLM provider exists and is set as default. + * + * Idempotent — returns `null` if a public provider already exists, + * or the new provider ID if one was created. + * + * @param providerName - Name for the provider (default: "PW Default Provider") + * @returns The provider ID if one was created, or `null` if already present + */ + async ensurePublicProvider( + providerName: string = "PW Default Provider" + ): Promise { + const providers = await this.listLlmProviders(); + const hasPublic = providers.some((p) => p.is_public); + + if (hasPublic) { + return null; + } + + const response = await this.request.put( `${this.baseUrl}/admin/llm/provider?is_creation=true`, { data: { @@ -428,12 +468,33 @@ export class OnyxApiClient { "Failed to create public provider" ); + // Set as default so get_default_llm() works (needed for tokenization, etc.) + await this.setProviderAsDefault(responseData.id); + this.log( `Created public LLM provider: ${providerName} (ID: ${responseData.id})` ); return responseData.id; } + /** + * Sets an LLM provider as the default for chat. + * + * @param providerId - The provider ID to set as default + */ + async setProviderAsDefault(providerId: number): Promise { + const response = await this.post( + `/admin/llm/provider/${providerId}/default` + ); + + await this.handleResponseSoft( + response, + `Failed to set provider ${providerId} as default` + ); + + this.log(`Set LLM provider ${providerId} as default`); + } + /** * Deletes an LLM provider. * @@ -497,7 +558,7 @@ export class OnyxApiClient { role: "admin" | "curator" | "global_curator" | "basic", explicitOverride = false ): Promise { - const response = await this.page.request.patch( + const response = await this.request.patch( `${this.baseUrl}/manage/set-user-role`, { data: { @@ -512,7 +573,7 @@ export class OnyxApiClient { } async deleteMcpServer(serverId: number): Promise { - const response = await this.page.request.delete( + const response = await this.request.delete( `${this.baseUrl}/admin/mcp/server/${serverId}` ); const success = await this.handleResponseSoft( @@ -526,7 +587,7 @@ export class OnyxApiClient { } async deleteAssistant(assistantId: number): Promise { - const response = await this.page.request.delete( + const response = await this.request.delete( `${this.baseUrl}/persona/${assistantId}` ); const success = await this.handleResponseSoft( @@ -589,16 +650,13 @@ export class OnyxApiClient { } async registerUser(email: string, password: string): Promise<{ id: string }> { - const response = await this.page.request.post( - `${this.baseUrl}/auth/register`, - { - data: { - email, - username: email, - password, - }, - } - ); + const response = await this.request.post(`${this.baseUrl}/auth/register`, { + data: { + email, + username: email, + password, + }, + }); const data = await this.handleResponse<{ id: string }>( response, `Failed to register user ${email}` @@ -611,7 +669,7 @@ export class OnyxApiClient { email: string; role: string; } | null> { - const response = await this.page.request.get( + const response = await this.request.get( `${this.baseUrl}/manage/users/accepted`, { params: { @@ -639,7 +697,7 @@ export class OnyxApiClient { userId: string, isCurator: boolean = true ): Promise { - const response = await this.page.request.post( + const response = await this.request.post( `${this.baseUrl}/manage/admin/user-group/${userGroupId}/set-curator`, { data: { @@ -851,7 +909,7 @@ export class OnyxApiClient { guild_name: string | null; enabled: boolean; }> { - const response = await this.page.request.patch( + const response = await this.request.patch( `${this.baseUrl}/manage/admin/discord-bot/guilds/${guildId}`, { data: updates } ); @@ -926,7 +984,7 @@ export class OnyxApiClient { channel_name: string; enabled: boolean; }> { - const response = await this.page.request.patch( + const response = await this.request.patch( `${this.baseUrl}/manage/admin/discord-bot/guilds/${guildConfigId}/channels/${channelConfigId}`, { data: updates } );