mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-09 17:31:19 +00:00
🛟 test: Restore Playwright Smoke E2E (#13020)
* test: restore Playwright smoke e2e * test: harden e2e smoke setup * test: sync e2e server bindings * test: normalize e2e auth urls
This commit is contained in:
parent
738ed005b6
commit
b993d9fb28
15 changed files with 473 additions and 179 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
134
e2e/setup/env.ts
Normal file
134
e2e/setup/env.ts
Normal file
|
|
@ -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<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
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<string, string> {
|
||||
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<string, string> {
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
21
e2e/setup/runtimeEnv.ts
Normal file
21
e2e/setup/runtimeEnv.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
187
e2e/setup/start-server.js
Normal file
187
e2e/setup/start-server.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
15
e2e/setup/user.ts
Normal file
15
e2e/setup/user.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
13
package.json
13
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}\"",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue