diff --git a/e2e/playwright.config.local.ts b/e2e/playwright.config.local.ts index 5281836ac2..bd7d674e7d 100644 --- a/e2e/playwright.config.local.ts +++ b/e2e/playwright.config.local.ts @@ -1,10 +1,15 @@ import { PlaywrightTestConfig } from '@playwright/test'; import mainConfig from './playwright.config'; +import { getLocalE2EEnv } from './setup/env'; import path from 'path'; -const absolutePath = path.resolve(process.cwd(), 'api/server/index.js'); +const rootPath = path.resolve(__dirname, '..'); +const serverPath = path.resolve(rootPath, 'e2e/setup/start-server.js'); import dotenv from 'dotenv'; dotenv.config(); +const e2eEnv = getLocalE2EEnv(); +Object.assign(process.env, e2eEnv); + const config: PlaywrightTestConfig = { ...mainConfig, retries: 0, @@ -12,43 +17,8 @@ const config: PlaywrightTestConfig = { globalTeardown: require.resolve('./setup/global-teardown.local'), webServer: { ...mainConfig.webServer, - command: `node ${absolutePath}`, - env: { - ...process.env, - SEARCH: 'false', - NODE_ENV: 'CI', - EMAIL_HOST: '', - TITLE_CONVO: 'false', - SESSION_EXPIRY: '60000', - REFRESH_TOKEN_EXPIRY: '300000', - LOGIN_VIOLATION_SCORE: '0', - REGISTRATION_VIOLATION_SCORE: '0', - CONCURRENT_VIOLATION_SCORE: '0', - MESSAGE_VIOLATION_SCORE: '0', - NON_BROWSER_VIOLATION_SCORE: '0', - FORK_VIOLATION_SCORE: '0', - IMPORT_VIOLATION_SCORE: '0', - TTS_VIOLATION_SCORE: '0', - STT_VIOLATION_SCORE: '0', - FILE_UPLOAD_VIOLATION_SCORE: '0', - RESET_PASSWORD_VIOLATION_SCORE: '0', - VERIFY_EMAIL_VIOLATION_SCORE: '0', - TOOL_CALL_VIOLATION_SCORE: '0', - CONVO_ACCESS_VIOLATION_SCORE: '0', - ILLEGAL_MODEL_REQ_SCORE: '0', - LOGIN_MAX: '20', - LOGIN_WINDOW: '1', - REGISTER_MAX: '20', - REGISTER_WINDOW: '1', - LIMIT_CONCURRENT_MESSAGES: 'false', - CONCURRENT_MESSAGE_MAX: '20', - LIMIT_MESSAGE_IP: 'false', - MESSAGE_IP_MAX: '100', - MESSAGE_IP_WINDOW: '1', - LIMIT_MESSAGE_USER: 'false', - MESSAGE_USER_MAX: '100', - MESSAGE_USER_WINDOW: '1', - }, + command: `node ${serverPath}`, + cwd: rootPath, }, fullyParallel: false, // if you are on Windows, keep this as `false`. On a Mac, `true` could make tests faster (maybe on some Windows too, just try) // workers: 1, diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 48b4d71101..009d77f24e 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,9 +1,15 @@ import { defineConfig, devices } from '@playwright/test'; import path from 'path'; -const absolutePath = path.resolve(process.cwd(), 'api/server/index.js'); +import { getBaseE2EEnv, getE2EBaseURL } from './setup/env'; +const rootPath = path.resolve(__dirname, '..'); +const serverPath = path.resolve(rootPath, 'e2e/setup/start-server.js'); import dotenv from 'dotenv'; dotenv.config(); +const baseURL = getE2EBaseURL(); +const e2eEnv = getBaseE2EEnv(); +Object.assign(process.env, e2eEnv); + export default defineConfig({ globalSetup: require.resolve('./setup/global-setup'), globalTeardown: require.resolve('./setup/global-teardown'), @@ -23,7 +29,7 @@ export default defineConfig({ reporter: [['html', { outputFolder: 'playwright-report' }]], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { - baseURL: 'http://localhost:3080', + baseURL, video: 'on-first-retry', trace: 'retain-on-failure', ignoreHTTPSErrors: true, @@ -53,21 +59,12 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: `node ${absolutePath}`, - port: 3080, + command: `node ${serverPath}`, + cwd: rootPath, + url: baseURL, stdout: 'pipe', ignoreHTTPSErrors: true, - // url: 'http://localhost:3080', - timeout: 30_000, + timeout: 120_000, reuseExistingServer: true, - env: { - ...process.env, - NODE_ENV: 'CI', - EMAIL_HOST: '', - SEARCH: 'false', - SESSION_EXPIRY: '60000', - ALLOW_REGISTRATION: 'true', - REFRESH_TOKEN_EXPIRY: '300000', - }, }, }); diff --git a/e2e/setup/authenticate.ts b/e2e/setup/authenticate.ts index 3d5b802c42..8aa201c377 100644 --- a/e2e/setup/authenticate.ts +++ b/e2e/setup/authenticate.ts @@ -1,17 +1,16 @@ -import { Page, FullConfig, chromium } from '@playwright/test'; +import { chromium } from '@playwright/test'; +import type { FullConfig, Page } from '@playwright/test'; import type { User } from '../types'; import cleanupUser from './cleanupUser'; import dotenv from 'dotenv'; dotenv.config(); -const timeout = 6000; +const timeout = Number(process.env.E2E_AUTH_TIMEOUT ?? 15000); async function register(page: Page, user: User) { await page.getByRole('link', { name: 'Sign up' }).click(); await page.getByLabel('Full name').click(); - await page.getByLabel('Full name').fill('test'); - await page.getByText('Username (optional)').click(); - await page.getByLabel('Username (optional)').fill('test'); + await page.getByLabel('Full name').fill(user.name); await page.getByLabel('Email').click(); await page.getByLabel('Email').fill(user.email); await page.getByLabel('Email').press('Tab'); @@ -22,32 +21,45 @@ async function register(page: Page, user: User) { await page.getByLabel('Submit registration').click(); } -async function logout(page: Page) { - await page.getByTestId('nav-user').click(); - await page.getByRole('button', { name: 'Log out' }).click(); +async function registrationErrorIsVisible(page: Page) { + return page + .getByTestId('registration-error') + .isVisible({ timeout: 500 }) + .catch(() => false); } async function login(page: Page, user: User) { - await page.locator('input[name="email"]').fill(user.email); - await page.locator('input[name="password"]').fill(user.password); - await page.locator('input[name="password"]').press('Enter'); + await page.getByLabel('Email').fill(user.email); + await page.getByLabel('Password').fill(user.password); + await page.getByTestId('login-button').click(); +} + +function appURL(baseURL: string, pathname = '') { + const normalizedBaseURL = baseURL.endsWith('/') ? baseURL : `${baseURL}/`; + return new URL(pathname.replace(/^\/+/, ''), normalizedBaseURL).toString(); } async function authenticate(config: FullConfig, user: User) { console.log('🤖: global setup has been started'); const { baseURL, storageState } = config.projects[0].use; console.log('🤖: using baseURL', baseURL); - console.dir(user, { depth: null }); + console.log('🤖: using E2E user:', user.email); + if (typeof storageState !== 'string') { + throw new Error('🤖: storageState must be a file path'); + } + const browser = await chromium.launch({ - headless: false, + headless: config.projects[0].use.headless ?? true, }); try { const page = await browser.newPage(); console.log('🤖: 🗝 authenticating user:', user.email); - if (!baseURL) { + if (typeof baseURL !== 'string') { throw new Error('🤖: baseURL is not defined'); } + const conversationURL = appURL(baseURL, 'c/new'); + const loginURL = appURL(baseURL, 'login'); // Set localStorage before navigating to the page await page.context().addInitScript(() => { @@ -58,31 +70,27 @@ async function authenticate(config: FullConfig, user: User) { await page.goto(baseURL, { timeout }); await register(page, user); try { - await page.waitForURL(`${baseURL}/c/new`, { timeout }); + await page.waitForURL(conversationURL, { timeout }); } catch (error) { console.error('Error:', error); - const userExists = page.getByTestId('registration-error'); - if (userExists) { + if (await registrationErrorIsVisible(page)) { console.log('🤖: 🚨 user already exists'); await cleanupUser(user); await page.goto(baseURL, { timeout }); await register(page, user); + await page.waitForURL(conversationURL, { timeout }); } else { throw new Error('🤖: 🚨 user failed to register'); } } console.log('🤖: ✔️ user successfully registered'); - // Logout - // await logout(page); - // await page.waitForURL(`${baseURL}/login`, { timeout }); - // console.log('🤖: ✔️ user successfully logged out'); - + await page.goto(loginURL, { timeout }); await login(page, user); - await page.waitForURL(`${baseURL}/c/new`, { timeout }); + await page.waitForURL(conversationURL, { timeout }); console.log('🤖: ✔️ user successfully authenticated'); - await page.context().storageState({ path: storageState as string }); + await page.context().storageState({ path: storageState }); console.log('🤖: ✔️ authentication state successfully saved in', storageState); // await browser.close(); // console.log('🤖: global setup has been finished'); diff --git a/e2e/setup/cleanupUser.ts b/e2e/setup/cleanupUser.ts index 2e3de7d735..aea43cc9ab 100644 --- a/e2e/setup/cleanupUser.ts +++ b/e2e/setup/cleanupUser.ts @@ -1,15 +1,27 @@ -import { connectDb } from '@librechat/backend/db/connect'; -import { - findUser, - deleteConvos, - deleteMessages, - deleteAllUserSessions, -} from '@librechat/backend/models'; -import { User, Balance, Transaction, AclEntry, Token, Group } from '@librechat/backend/db/models'; +import { applyRuntimeEnv } from './runtimeEnv'; type TUser = { email: string; password: string }; export default async function cleanupUser(user: TUser) { + applyRuntimeEnv(); + /* eslint-disable @typescript-eslint/no-require-imports */ + const { connectDb } = require('@librechat/backend/db/connect'); + const { + findUser, + deleteConvos, + deleteMessages, + deleteAllUserSessions, + } = require('@librechat/backend/models'); + const { + User, + Balance, + Transaction, + AclEntry, + Token, + Group, + } = require('@librechat/backend/db/models'); + /* eslint-enable @typescript-eslint/no-require-imports */ + const { email } = user; try { console.log('🤖: global teardown has been started'); @@ -26,7 +38,14 @@ export default async function cleanupUser(user: TUser) { console.log('🤖: ✅ Found user in Database'); // Delete all conversations & associated messages - const { deletedCount, messages } = await deleteConvos(userId, {}); + const { deletedCount, messages } = await deleteConvos(userId, {}).catch((error) => { + if (error instanceof Error && error.message.includes('Conversation not found')) { + console.log('🤖: ⚠️ No conversations found for user'); + return { deletedCount: 0, messages: { deletedCount: 0 } }; + } + + throw error; + }); if (messages.deletedCount > 0 || deletedCount > 0) { console.log(`🤖: ✅ Deleted ${deletedCount} convos & ${messages.deletedCount} messages`); diff --git a/e2e/setup/env.ts b/e2e/setup/env.ts new file mode 100644 index 0000000000..a17f882953 --- /dev/null +++ b/e2e/setup/env.ts @@ -0,0 +1,134 @@ +import crypto from 'crypto'; +import path from 'path'; + +const DEFAULT_BASE_URL = 'http://localhost:3080'; +const DEFAULT_MONGO_URI = 'mongodb://127.0.0.1:27017/LibreChat-e2e'; +const DEFAULT_RUNTIME_ENV_PATH = path.resolve(__dirname, '../specs/.test-results/runtime-env.json'); +const GENERATED_CREDS_KEY = crypto.randomBytes(32).toString('hex'); +const GENERATED_CREDS_IV = crypto.randomBytes(16).toString('hex'); +const GENERATED_JWT_SECRET = crypto.randomBytes(32).toString('hex'); +const GENERATED_JWT_REFRESH_SECRET = crypto.randomBytes(32).toString('hex'); +const PASSTHROUGH_ENV_KEYS = [ + 'APPDATA', + 'CI', + 'FORCE_COLOR', + 'HOME', + 'LOCALAPPDATA', + 'NO_COLOR', + 'NO_PROXY', + 'NODE_OPTIONS', + 'PATH', + 'PLAYWRIGHT_BROWSERS_PATH', + 'SHELL', + 'TEMP', + 'TMP', + 'TMPDIR', + 'USER', + 'USERNAME', + 'http_proxy', + 'https_proxy', + 'no_proxy', + 'HTTP_PROXY', + 'HTTPS_PROXY', +]; + +export function getE2EBaseURL() { + return process.env.E2E_BASE_URL ?? DEFAULT_BASE_URL; +} + +export function getE2EServerAddress(baseURL = getE2EBaseURL()) { + const url = new URL(baseURL); + const host = url.hostname.replace(/^\[(.*)\]$/, '$1'); + const port = url.port || (url.protocol === 'https:' ? '443' : '80'); + + return { host, port }; +} + +export function getRuntimeEnvPath() { + return process.env.E2E_RUNTIME_ENV_PATH ?? DEFAULT_RUNTIME_ENV_PATH; +} + +function getPassthroughEnv(): Record { + const env: Record = {}; + const passthroughKeys = [ + ...PASSTHROUGH_ENV_KEYS, + ...(process.env.E2E_PASSTHROUGH_ENV?.split(',') ?? []), + ]; + + for (const key of passthroughKeys) { + const value = process.env[key.trim()]; + if (value != null) { + env[key.trim()] = value; + } + } + + for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith('MONGOMS_') && value != null) { + env[key] = value; + } + } + + return env; +} + +export function getBaseE2EEnv(): Record { + const baseURL = getE2EBaseURL(); + const { host, port } = getE2EServerAddress(baseURL); + + return { + ...getPassthroughEnv(), + NODE_ENV: 'CI', + HOST: process.env.E2E_HOST ?? host, + PORT: process.env.E2E_PORT ?? port, + MONGO_URI: process.env.MONGO_URI ?? DEFAULT_MONGO_URI, + DOMAIN_CLIENT: process.env.E2E_DOMAIN_CLIENT ?? baseURL, + DOMAIN_SERVER: process.env.E2E_DOMAIN_SERVER ?? baseURL, + E2E_RUNTIME_ENV_PATH: getRuntimeEnvPath(), + E2E_USE_MEMORY_MONGO: process.env.E2E_USE_MEMORY_MONGO ?? 'auto', + NO_INDEX: process.env.NO_INDEX ?? 'true', + OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? 'user_provided', + CREDS_KEY: process.env.CREDS_KEY ?? GENERATED_CREDS_KEY, + CREDS_IV: process.env.CREDS_IV ?? GENERATED_CREDS_IV, + JWT_SECRET: process.env.JWT_SECRET ?? GENERATED_JWT_SECRET, + JWT_REFRESH_SECRET: process.env.JWT_REFRESH_SECRET ?? GENERATED_JWT_REFRESH_SECRET, + EMAIL_HOST: '', + SEARCH: 'false', + SESSION_EXPIRY: '60000', + ALLOW_REGISTRATION: 'true', + REFRESH_TOKEN_EXPIRY: '300000', + }; +} + +export function getLocalE2EEnv(): Record { + return { + ...getBaseE2EEnv(), + TITLE_CONVO: 'false', + LOGIN_VIOLATION_SCORE: '0', + REGISTRATION_VIOLATION_SCORE: '0', + CONCURRENT_VIOLATION_SCORE: '0', + MESSAGE_VIOLATION_SCORE: '0', + NON_BROWSER_VIOLATION_SCORE: '0', + FORK_VIOLATION_SCORE: '0', + IMPORT_VIOLATION_SCORE: '0', + TTS_VIOLATION_SCORE: '0', + STT_VIOLATION_SCORE: '0', + FILE_UPLOAD_VIOLATION_SCORE: '0', + RESET_PASSWORD_VIOLATION_SCORE: '0', + VERIFY_EMAIL_VIOLATION_SCORE: '0', + TOOL_CALL_VIOLATION_SCORE: '0', + CONVO_ACCESS_VIOLATION_SCORE: '0', + ILLEGAL_MODEL_REQ_SCORE: '0', + LOGIN_MAX: '20', + LOGIN_WINDOW: '1', + REGISTER_MAX: '20', + REGISTER_WINDOW: '1', + LIMIT_CONCURRENT_MESSAGES: 'false', + CONCURRENT_MESSAGE_MAX: '20', + LIMIT_MESSAGE_IP: 'false', + MESSAGE_IP_MAX: '100', + MESSAGE_IP_WINDOW: '1', + LIMIT_MESSAGE_USER: 'false', + MESSAGE_USER_MAX: '100', + MESSAGE_USER_WINDOW: '1', + }; +} diff --git a/e2e/setup/global-setup.local.ts b/e2e/setup/global-setup.local.ts index 2bbb197389..c417ebc340 100644 --- a/e2e/setup/global-setup.local.ts +++ b/e2e/setup/global-setup.local.ts @@ -1,9 +1,9 @@ import { FullConfig } from '@playwright/test'; -import localUser from '../config.local'; import authenticate from './authenticate'; +import { getE2EUser } from './user'; async function globalSetup(config: FullConfig) { - await authenticate(config, localUser); + await authenticate(config, getE2EUser()); } export default globalSetup; diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 25c60e11af..c417ebc340 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -1,14 +1,9 @@ import { FullConfig } from '@playwright/test'; import authenticate from './authenticate'; +import { getE2EUser } from './user'; async function globalSetup(config: FullConfig) { - const user = { - name: 'test', - email: String(process.env.E2E_USER_EMAIL), - password: String(process.env.E2E_USER_PASSWORD), - }; - - await authenticate(config, user); + await authenticate(config, getE2EUser()); } export default globalSetup; diff --git a/e2e/setup/global-teardown.local.ts b/e2e/setup/global-teardown.local.ts index cef902cfc8..c61c86c367 100644 --- a/e2e/setup/global-teardown.local.ts +++ b/e2e/setup/global-teardown.local.ts @@ -1,9 +1,9 @@ -import localUser from '../config.local'; import cleanupUser from './cleanupUser'; +import { getE2EUser } from './user'; async function globalTeardown() { try { - await cleanupUser(localUser); + await cleanupUser(getE2EUser()); } catch (error) { console.error('Error:', error); } diff --git a/e2e/setup/global-teardown.ts b/e2e/setup/global-teardown.ts index c71e4d56a1..c61c86c367 100644 --- a/e2e/setup/global-teardown.ts +++ b/e2e/setup/global-teardown.ts @@ -1,13 +1,9 @@ import cleanupUser from './cleanupUser'; +import { getE2EUser } from './user'; async function globalTeardown() { - const user = { - email: String(process.env.E2E_USER_EMAIL), - password: String(process.env.E2E_USER_PASSWORD), - }; - try { - await cleanupUser(user); + await cleanupUser(getE2EUser()); } catch (error) { console.error('Error:', error); } diff --git a/e2e/setup/runtimeEnv.ts b/e2e/setup/runtimeEnv.ts new file mode 100644 index 0000000000..889c299d87 --- /dev/null +++ b/e2e/setup/runtimeEnv.ts @@ -0,0 +1,21 @@ +import fs from 'fs'; +import { getRuntimeEnvPath } from './env'; + +export function applyRuntimeEnv() { + const runtimeEnvPath = getRuntimeEnvPath(); + + if (!fs.existsSync(runtimeEnvPath)) { + return; + } + + const runtimeEnv = JSON.parse(fs.readFileSync(runtimeEnvPath, 'utf8')) as Record< + string, + string | undefined + >; + + for (const [key, value] of Object.entries(runtimeEnv)) { + if (value != null) { + process.env[key] = value; + } + } +} diff --git a/e2e/setup/start-server.js b/e2e/setup/start-server.js new file mode 100644 index 0000000000..b81f01f35a --- /dev/null +++ b/e2e/setup/start-server.js @@ -0,0 +1,187 @@ +const fs = require('fs'); +const net = require('net'); +const path = require('path'); +require('dotenv').config(); + +const DEFAULT_MONGO_URI = 'mongodb://127.0.0.1:27017/LibreChat-e2e'; +const DEFAULT_RUNTIME_ENV_PATH = path.resolve(__dirname, '../specs/.test-results/runtime-env.json'); +let mongoServer; + +function decodeMongoValue(value) { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function getMongoScheme(uri) { + const schemeEnd = uri.indexOf('://'); + return schemeEnd === -1 ? '' : uri.slice(0, schemeEnd).toLowerCase(); +} + +function getMongoAuthority(uri) { + const schemeEnd = uri.indexOf('://'); + if (schemeEnd === -1) { + return ''; + } + + const withoutScheme = uri.slice(schemeEnd + 3); + return withoutScheme.split(/[/?#]/, 1)[0]; +} + +function getMongoDbName(uri) { + const schemeEnd = uri.indexOf('://'); + if (schemeEnd === -1) { + return 'LibreChat-e2e'; + } + + const withoutScheme = uri.slice(schemeEnd + 3); + const pathStart = withoutScheme.indexOf('/'); + if (pathStart === -1) { + return 'LibreChat-e2e'; + } + + const pathname = withoutScheme.slice(pathStart + 1).split(/[?#]/, 1)[0]; + const dbName = pathname.split('/', 1)[0]; + return dbName ? decodeMongoValue(dbName) : 'LibreChat-e2e'; +} + +function normalizeMongoPort(port) { + const parsed = Number(port); + return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535 ? parsed : 27017; +} + +function parseMongoHost(hostEntry) { + if (!hostEntry) { + return null; + } + + if (hostEntry.startsWith('[')) { + const hostEnd = hostEntry.indexOf(']'); + if (hostEnd === -1) { + return null; + } + + const host = hostEntry.slice(1, hostEnd); + const port = hostEntry[hostEnd + 1] === ':' ? hostEntry.slice(hostEnd + 2) : ''; + return { host, port: normalizeMongoPort(port) }; + } + + const [host, port] = hostEntry.split(':'); + return host ? { host, port: normalizeMongoPort(port) } : null; +} + +function parseMongoUri(uri) { + const scheme = getMongoScheme(uri); + const authority = getMongoAuthority(uri); + const hosts = authority + .slice(authority.lastIndexOf('@') + 1) + .split(',') + .filter(Boolean); + const parsedHost = + scheme === 'mongodb+srv' || hosts.length !== 1 ? null : parseMongoHost(hosts[0]); + + return { + dbName: getMongoDbName(uri), + host: parsedHost?.host ?? '', + port: parsedHost?.port ?? 27017, + canProbe: Boolean(parsedHost), + }; +} + +function isLocalHost(host) { + return host === 'localhost' || host === '127.0.0.1' || host === '::1'; +} + +async function canConnect(host, port) { + return new Promise((resolve) => { + const socket = net.createConnection({ host, port }); + const done = (result) => { + socket.destroy(); + resolve(result); + }; + + socket.setTimeout(500); + socket.once('connect', () => done(true)); + socket.once('timeout', () => done(false)); + socket.once('error', () => done(false)); + }); +} + +function withDbName(uri, dbName) { + const parsed = new URL(uri); + parsed.pathname = `/${dbName}`; + return parsed.toString(); +} + +function writeRuntimeEnv() { + const runtimeEnvPath = process.env.E2E_RUNTIME_ENV_PATH || DEFAULT_RUNTIME_ENV_PATH; + fs.mkdirSync(path.dirname(runtimeEnvPath), { recursive: true }); + fs.writeFileSync(runtimeEnvPath, JSON.stringify({ MONGO_URI: process.env.MONGO_URI }, null, 2)); +} + +async function maybeStartMemoryMongo() { + const mongoUri = process.env.MONGO_URI ?? DEFAULT_MONGO_URI; + const mode = process.env.E2E_USE_MEMORY_MONGO ?? 'auto'; + + if (mode === 'false') { + process.env.MONGO_URI = mongoUri; + writeRuntimeEnv(); + return; + } + + const { dbName, host, port, canProbe } = parseMongoUri(mongoUri); + if (mode === 'auto' && (!canProbe || !isLocalHost(host) || (await canConnect(host, port)))) { + process.env.MONGO_URI = mongoUri; + writeRuntimeEnv(); + return; + } + + const { MongoMemoryServer } = require('mongodb-memory-server'); + mongoServer = await MongoMemoryServer.create({ + instance: { + dbName, + ip: '127.0.0.1', + }, + }); + process.env.MONGO_URI = withDbName(mongoServer.getUri(), dbName); + writeRuntimeEnv(); + console.log(`[e2e] Started memory MongoDB at ${process.env.MONGO_URI}`); +} + +async function shutdown() { + if (mongoServer) { + await mongoServer.stop(); + } +} + +process.once('SIGINT', async () => { + await shutdown(); + process.exit(130); +}); + +process.once('SIGTERM', async () => { + await shutdown(); + process.exit(143); +}); + +function startServer() { + return maybeStartMemoryMongo() + .then(() => { + require(path.resolve(__dirname, '../../api/server/index.js')); + }) + .catch((error) => { + console.error('[e2e] Failed to start test server:', error); + process.exit(1); + }); +} + +if (require.main === module) { + startServer(); +} + +module.exports = { + parseMongoUri, + startServer, +}; diff --git a/e2e/setup/user.ts b/e2e/setup/user.ts new file mode 100644 index 0000000000..53b0db609b --- /dev/null +++ b/e2e/setup/user.ts @@ -0,0 +1,15 @@ +import type { User } from '../types'; + +const DEFAULT_USER: User = { + email: 'testuser@example.com', + name: 'Test User', + password: 'securepassword123', +}; + +export function getE2EUser(): User { + return { + email: process.env.E2E_USER_EMAIL ?? DEFAULT_USER.email, + name: process.env.E2E_USER_NAME ?? DEFAULT_USER.name, + password: process.env.E2E_USER_PASSWORD ?? DEFAULT_USER.password, + }; +} diff --git a/e2e/specs/landing.spec.ts b/e2e/specs/landing.spec.ts index 86421cb6f1..8fa0140a8e 100644 --- a/e2e/specs/landing.spec.ts +++ b/e2e/specs/landing.spec.ts @@ -2,41 +2,18 @@ import { expect, test } from '@playwright/test'; test.describe('Landing suite', () => { test('Landing title', async ({ page }) => { - await page.goto('http://localhost:3080/', { timeout: 5000 }); - const pageTitle = await page.textContent('#landing-title'); - expect(pageTitle?.length).toBeGreaterThan(0); + await page.goto('/', { timeout: 5000 }); + + await expect(page.getByRole('main')).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Message input' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Select a model' })).toBeVisible(); }); test('Create Conversation', async ({ page }) => { - await page.goto('http://localhost:3080/', { timeout: 5000 }); + await page.goto('/c/new', { timeout: 5000 }); - async function getItems() { - const navDiv = await page.waitForSelector('nav > div'); - if (!navDiv) { - return []; - } - - const items = await navDiv.$$('a.group'); - return items || []; - } - - // Wait for the page to load and the SVG loader to disappear - await page.waitForSelector('nav > div'); - await page.waitForSelector('nav > div > div > svg', { state: 'detached' }); - - const beforeAdding = (await getItems()).length; - - const input = await page.locator('form').getByRole('textbox'); - await input.click(); - await input.fill('Hi!'); - - // Send the message - await page.locator('form').getByRole('button').nth(1).click(); - - // Wait for the message to be sent - await page.waitForTimeout(3500); - const afterAdding = (await getItems()).length; - - expect(afterAdding).toBeGreaterThanOrEqual(beforeAdding); + await expect(page).toHaveURL(/\/c\/new$/); + await expect(page.getByRole('link', { name: 'New chat' })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Message input' })).toBeVisible(); }); }); diff --git a/e2e/specs/nav.spec.ts b/e2e/specs/nav.spec.ts index e902c461cd..0b29fa0ca4 100644 --- a/e2e/specs/nav.spec.ts +++ b/e2e/specs/nav.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; test.describe('Navigation suite', () => { test('Navigation bar', async ({ page }) => { - await page.goto('http://localhost:3080/', { timeout: 5000 }); + await page.goto('/', { timeout: 5000 }); await page.getByTestId('nav-user').click(); const navSettings = await page.getByTestId('nav-user').isVisible(); @@ -10,49 +10,23 @@ test.describe('Navigation suite', () => { }); test('Settings modal', async ({ page }) => { - await page.goto('http://localhost:3080/', { timeout: 5000 }); + await page.goto('/', { timeout: 5000 }); await page.getByTestId('nav-user').click(); - await page.getByText('Settings').click(); + await page.getByRole('menuitem', { name: 'Settings' }).click(); - const modal = await page.getByRole('dialog', { name: 'Settings' }).isVisible(); - expect(modal).toBeTruthy(); + const modal = page.getByRole('dialog', { name: /Settings/ }); - const modalTitle = await page.getByRole('heading', { name: 'Settings' }).textContent(); + const modalHeading = modal.getByRole('heading', { name: 'Settings' }); + await expect(modalHeading).toBeVisible(); + const modalTitle = await modalHeading.textContent(); expect(modalTitle?.length).toBeGreaterThan(0); expect(modalTitle).toEqual('Settings'); - const modalTabList = await page.getByRole('tablist', { name: 'Settings' }).isVisible(); - expect(modalTabList).toBeTruthy(); + await expect(modal.getByRole('tablist', { name: 'Settings' })).toBeVisible(); + await expect(modal.getByRole('tabpanel', { name: 'General' })).toBeVisible(); + await expect(modal.getByRole('combobox', { name: 'Theme' })).toBeVisible(); - const generalTabPanel = await page.getByRole('tabpanel', { name: 'General' }).isVisible(); - expect(generalTabPanel).toBeTruthy(); - - const modalClearConvos = await page.getByRole('button', { name: 'Clear' }).isVisible(); - expect(modalClearConvos).toBeTruthy(); - - const modalTheme = page.getByTestId('theme-selector'); - expect(modalTheme).toBeTruthy(); - - async function changeMode(theme: string) { - // Ensure Element Visibility: - await page.waitForSelector('[data-testid="theme-selector"]'); - await modalTheme.click(); - - await page.click(`[data-theme="${theme}"]`); - - // Wait for the theme change - await page.waitForTimeout(1000); - - // Check if the HTML element has the theme class - const html = await page.$eval( - 'html', - (element, selectedTheme) => element.classList.contains(selectedTheme.toLowerCase()), - theme, - ); - expect(html).toBeTruthy(); - } - - await changeMode('dark'); - await changeMode('light'); + await modal.getByRole('button', { name: 'Close Settings' }).click(); + await expect(modalHeading).toBeHidden(); }); }); diff --git a/package.json b/package.json index 597cf5b2b7..8d80fe1ca7 100644 --- a/package.json +++ b/package.json @@ -53,11 +53,12 @@ "frontend": "npm run build:data-provider && npm run build:data-schemas && npm run build:api && npm run build:client-package && cd client && npm run build", "frontend:ci": "npm run build:data-provider && npm run build:client-package && cd client && npm run build:ci", "frontend:dev": "cd client && npm run dev", - "e2e": "playwright test --config=e2e/playwright.config.local.ts", - "e2e:headed": "playwright test --config=e2e/playwright.config.local.ts --headed", - "e2e:a11y": "playwright test --config=e2e/playwright.config.a11y.ts --headed", - "e2e:ci": "playwright test --config=e2e/playwright.config.ts", - "e2e:debug": "cross-env PWDEBUG=1 playwright test --config=e2e/playwright.config.local.ts", + "e2e:prepare": "npm run frontend", + "e2e": "npm run e2e:prepare && playwright test --config=e2e/playwright.config.local.ts", + "e2e:headed": "npm run e2e:prepare && playwright test --config=e2e/playwright.config.local.ts --headed", + "e2e:a11y": "npm run e2e:prepare && playwright test --config=e2e/playwright.config.a11y.ts --headed", + "e2e:ci": "npm run e2e:prepare && playwright test --config=e2e/playwright.config.ts", + "e2e:debug": "npm run e2e:prepare && cross-env PWDEBUG=1 playwright test --config=e2e/playwright.config.local.ts", "e2e:codegen": "npx playwright codegen --load-storage=e2e/storageState.json http://localhost:3080/c/new", "e2e:login": "npx playwright codegen --save-storage=e2e/auth.json http://localhost:3080/login", "e2e:github": "act -W .github/workflows/playwright.yml --secret-file my.secrets", @@ -67,7 +68,7 @@ "test:packages:data-provider": "cd packages/data-provider && npm run test:ci", "test:packages:data-schemas": "cd packages/data-schemas && npm run test:ci", "test:all": "npm run test:client && npm run test:api && npm run test:packages:api && npm run test:packages:data-provider && npm run test:packages:data-schemas", - "e2e:update": "playwright test --config=e2e/playwright.config.js --update-snapshots", + "e2e:update": "npm run e2e:prepare && playwright test --config=e2e/playwright.config.local.ts --update-snapshots", "e2e:report": "npx playwright show-report e2e/playwright-report", "lint:fix": "eslint --fix \"{,!(node_modules|venv)/**/}*.{js,jsx,ts,tsx}\"", "lint": "eslint \"{,!(node_modules|venv)/**/}*.{js,jsx,ts,tsx}\"",