mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-09 17:31:19 +00:00
* 🎭 test: Run Mock E2E Suite Through createRun With In-Process Fake Model Replace the standalone HTTP mock LLM server with an in-process fake model injected into the real createRun -> Run.create pipeline via run.Graph.overrideTestModel, so the mock suite exercises the agents integration end-to-end without a live provider or a separate server. - Bump @librechat/agents to 3.2.2 for the FakeChatModel/createFakeStreamingLLM exports - Add an env-gated applyTestRunHook seam in packages/api createRun (no /api changes) - Add e2e/setup/fake-model.js to drive default replies + the skill-authoring tool-call flow - Drop the mock-llm webServer from playwright.config.mock.ts and set LIBRECHAT_TEST_RUN_HOOK * 🧹 test: Retire Standalone Mock LLM Server From E2E Recorder Migrate the `--profile=mock` recorder onto the same in-process fake model as the Playwright mock suite, then delete the now-unused HTTP mock server so the fake-LLM logic lives in a single place. - Point record.js mock profile at the fake model via LIBRECHAT_TEST_RUN_HOOK - Remove the mock-llm-server spawn/wait and MOCK_LLM_PORT plumbing from record.js - Delete e2e/setup/mock-llm-server.js (e2e/setup/fake-model.js is now the only source) - Update e2e/README.md to describe the in-process fake LLM * 🏷️ ci: Rename Playwright Mock E2E Check to Playwright E2E Tests
425 lines
12 KiB
JavaScript
425 lines
12 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { spawn } = require('child_process');
|
|
|
|
const rootPath = path.resolve(__dirname, '../..');
|
|
const baseURL = process.env.E2E_BASE_URL || 'http://localhost:3080';
|
|
const storageStatePath = path.resolve(rootPath, 'e2e/storageState.json');
|
|
const configTemplatePath = path.resolve(rootPath, 'e2e/config/librechat.e2e.yaml');
|
|
const configPath = path.resolve(rootPath, 'e2e/.generated/librechat.e2e.yaml');
|
|
const fakeModelHookPath = path.resolve(rootPath, 'e2e/setup/fake-model.js');
|
|
const defaultUser = {
|
|
email: 'testuser@example.com',
|
|
name: 'Test User',
|
|
password: 'securepassword123',
|
|
};
|
|
|
|
const rateLimitOverrides = {
|
|
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',
|
|
};
|
|
|
|
const mockOverrides = {
|
|
CONFIG_PATH: configPath,
|
|
/** Loaded in-process by `@librechat/api`'s `createRun` to swap in a fake model. */
|
|
LIBRECHAT_TEST_RUN_HOOK: fakeModelHookPath,
|
|
OPENAI_API_KEY: 'user_provided',
|
|
TENANT_ISOLATION_STRICT: 'false',
|
|
OPENID_CLIENT_ID: '',
|
|
OPENID_ISSUER: '',
|
|
OPENID_AUTO_REDIRECT: 'false',
|
|
ALLOW_SOCIAL_LOGIN: 'false',
|
|
ALLOW_SOCIAL_REGISTRATION: 'false',
|
|
STREAM_KEEP_COMPLETED_JOBS: 'true',
|
|
};
|
|
|
|
const secretKeyPattern = /(API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIALS|CLIENT_ID|_KEY)$/i;
|
|
const preservedCredentialEnvKeys = new Set([
|
|
...Object.keys(rateLimitOverrides),
|
|
...Object.keys(mockOverrides).filter((key) => key !== 'OPENAI_API_KEY'),
|
|
'CREDS_KEY',
|
|
'CREDS_IV',
|
|
'E2E_USER_PASSWORD',
|
|
'E2E_USER_B_PASSWORD',
|
|
'JWT_SECRET',
|
|
'JWT_REFRESH_SECRET',
|
|
'REFRESH_TOKEN_EXPIRY',
|
|
'SESSION_EXPIRY',
|
|
]);
|
|
const npxBin = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
|
|
function appURL(pathname = '') {
|
|
const normalizedBaseURL = baseURL.endsWith('/') ? baseURL : `${baseURL}/`;
|
|
return new URL(pathname.replace(/^\/+/, ''), normalizedBaseURL).toString();
|
|
}
|
|
|
|
function getServerAddress() {
|
|
const url = new URL(baseURL);
|
|
const host = url.hostname.replace(/^\[(.*)\]$/, '$1');
|
|
const port = url.port || (url.protocol === 'https:' ? '443' : '80');
|
|
return { host, port };
|
|
}
|
|
|
|
function randomHex(bytes) {
|
|
return crypto.randomBytes(bytes).toString('hex');
|
|
}
|
|
|
|
function getUser(env) {
|
|
return {
|
|
email: env.E2E_USER_EMAIL || defaultUser.email,
|
|
name: env.E2E_USER_NAME || defaultUser.name,
|
|
password: env.E2E_USER_PASSWORD || defaultUser.password,
|
|
};
|
|
}
|
|
|
|
function getBaseEnv() {
|
|
const { host, port } = getServerAddress();
|
|
return {
|
|
...process.env,
|
|
NODE_ENV: 'CI',
|
|
HOST: process.env.E2E_HOST || host,
|
|
PORT: process.env.E2E_PORT || port,
|
|
MONGO_URI: process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/LibreChat-e2e',
|
|
DOMAIN_CLIENT: process.env.E2E_DOMAIN_CLIENT || baseURL,
|
|
DOMAIN_SERVER: process.env.E2E_DOMAIN_SERVER || baseURL,
|
|
E2E_RUNTIME_ENV_PATH:
|
|
process.env.E2E_RUNTIME_ENV_PATH ||
|
|
path.resolve(rootPath, 'e2e/specs/.test-results/runtime-env.json'),
|
|
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 || randomHex(32),
|
|
CREDS_IV: process.env.CREDS_IV || randomHex(16),
|
|
JWT_SECRET: process.env.JWT_SECRET || randomHex(32),
|
|
JWT_REFRESH_SECRET: process.env.JWT_REFRESH_SECRET || randomHex(32),
|
|
EMAIL_HOST: '',
|
|
SEARCH: 'false',
|
|
SESSION_EXPIRY: process.env.SESSION_EXPIRY || '3600000',
|
|
ALLOW_REGISTRATION: 'true',
|
|
REFRESH_TOKEN_EXPIRY: process.env.REFRESH_TOKEN_EXPIRY || '3600000',
|
|
TITLE_CONVO: 'false',
|
|
...rateLimitOverrides,
|
|
};
|
|
}
|
|
|
|
function neutralizeCredentialEnv(env) {
|
|
for (const key of Object.keys(env)) {
|
|
if (!preservedCredentialEnvKeys.has(key) && secretKeyPattern.test(key)) {
|
|
env[key] = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
function neutralizeDotenvSecrets(envFile, env) {
|
|
if (!fs.existsSync(envFile)) {
|
|
return;
|
|
}
|
|
const lines = fs.readFileSync(envFile, 'utf8').split('\n');
|
|
for (const line of lines) {
|
|
const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
const key = match[1];
|
|
if (!preservedCredentialEnvKeys.has(key) && secretKeyPattern.test(key)) {
|
|
env[key] = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
function getEnv(profile) {
|
|
const env = getBaseEnv();
|
|
if (profile === 'mock') {
|
|
neutralizeCredentialEnv(env);
|
|
neutralizeDotenvSecrets(path.resolve(rootPath, '.env'), env);
|
|
Object.assign(env, mockOverrides);
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function formatDate(date) {
|
|
return date
|
|
.toISOString()
|
|
.replace(/\.\d{3}Z$/, '')
|
|
.replace(/[:T]/g, '-');
|
|
}
|
|
|
|
function writeRuntimeMockConfig() {
|
|
const template = fs.readFileSync(configTemplatePath, 'utf8');
|
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
fs.writeFileSync(configPath, template);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
profile: 'mock',
|
|
output: path.resolve(rootPath, `e2e/recordings/recording-${formatDate(new Date())}.spec.ts`),
|
|
storage: storageStatePath,
|
|
url: appURL('c/new'),
|
|
authOnly: false,
|
|
saveOutput: true,
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
const next = argv[index + 1];
|
|
const readValue = () => {
|
|
if (arg.includes('=')) {
|
|
return arg.slice(arg.indexOf('=') + 1);
|
|
}
|
|
index += 1;
|
|
return next;
|
|
};
|
|
|
|
if (arg === '--help' || arg === '-h') {
|
|
options.help = true;
|
|
} else if (arg.startsWith('--profile')) {
|
|
options.profile = readValue();
|
|
} else if (arg.startsWith('--url')) {
|
|
const value = readValue();
|
|
options.url = /^https?:\/\//i.test(value) ? value : appURL(value);
|
|
} else if (arg.startsWith('--output')) {
|
|
options.output = path.resolve(rootPath, readValue());
|
|
} else if (arg.startsWith('--storage')) {
|
|
options.storage = path.resolve(rootPath, readValue());
|
|
} else if (arg === '--no-output') {
|
|
options.saveOutput = false;
|
|
} else if (arg === '--auth-only') {
|
|
options.authOnly = true;
|
|
}
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log(`
|
|
Usage: node e2e/setup/record.js [options]
|
|
|
|
Options:
|
|
--profile mock|local Server profile to record against. Defaults to mock.
|
|
--url <url> URL opened by Playwright codegen. Defaults to /c/new.
|
|
--output <path> Raw recording output path under the repo.
|
|
--storage <path> Auth storage state path. Defaults to e2e/storageState.json.
|
|
--no-output Let codegen show generated code without writing a file.
|
|
--auth-only Start servers, write storage state, then exit.
|
|
|
|
Examples:
|
|
node e2e/setup/record.js
|
|
node e2e/setup/record.js --profile=local --url=http://localhost:3080/c/new
|
|
`);
|
|
}
|
|
|
|
async function waitForURL(url, timeoutMs) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 1000);
|
|
try {
|
|
const response = await fetch(url, { signal: controller.signal });
|
|
if (response.ok) {
|
|
return true;
|
|
}
|
|
} catch {
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function spawnProcess(name, command, args, env) {
|
|
const child = spawn(command, args, {
|
|
cwd: rootPath,
|
|
env,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
child.stdout.on('data', (chunk) => process.stdout.write(`[${name}] ${chunk}`));
|
|
child.stderr.on('data', (chunk) => process.stderr.write(`[${name}] ${chunk}`));
|
|
child.on('exit', (code) => {
|
|
if (code && code !== 0) {
|
|
console.error(`[${name}] exited with code ${code}`);
|
|
}
|
|
});
|
|
|
|
return child;
|
|
}
|
|
|
|
async function stopProcess(child) {
|
|
if (!child || child.exitCode != null) {
|
|
return;
|
|
}
|
|
|
|
child.kill('SIGTERM');
|
|
await new Promise((resolve) => {
|
|
const timeout = setTimeout(() => {
|
|
child.kill('SIGKILL');
|
|
resolve();
|
|
}, 5000);
|
|
child.once('exit', () => {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function register(page, user, timeout) {
|
|
await page.getByRole('link', { name: 'Sign up' }).click({ timeout });
|
|
await page.getByLabel('Full name').fill(user.name);
|
|
await page.getByLabel('Email').fill(user.email);
|
|
await page.getByTestId('password').fill(user.password);
|
|
await page.getByTestId('confirm_password').fill(user.password);
|
|
await page.getByLabel('Submit registration').click();
|
|
}
|
|
|
|
async function login(page, user) {
|
|
await page.getByLabel('Email').fill(user.email);
|
|
await page.getByLabel('Password').fill(user.password);
|
|
await page.getByTestId('login-button').click();
|
|
}
|
|
|
|
async function writeStorageState(env, storagePath) {
|
|
const { chromium } = require('@playwright/test');
|
|
const user = getUser(env);
|
|
const timeout = Number(env.E2E_AUTH_TIMEOUT || 15000);
|
|
const conversationURL = appURL('c/new');
|
|
const loginURL = appURL('login');
|
|
const browser = await chromium.launch({ headless: true });
|
|
|
|
try {
|
|
const page = await browser.newPage();
|
|
await page.context().addInitScript(() => {
|
|
localStorage.setItem('navVisible', 'true');
|
|
});
|
|
|
|
await page.goto(baseURL, { timeout });
|
|
try {
|
|
await register(page, user, timeout);
|
|
await page.waitForURL(conversationURL, { timeout });
|
|
} catch {
|
|
await page.goto(loginURL, { timeout });
|
|
await login(page, user);
|
|
await page.waitForURL(conversationURL, { timeout });
|
|
}
|
|
|
|
fs.mkdirSync(path.dirname(storagePath), { recursive: true });
|
|
await page.context().storageState({ path: storagePath });
|
|
console.log(`[record] Saved authenticated storage state to ${storagePath}`);
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
}
|
|
|
|
function runCodegen(options, env) {
|
|
const args = [
|
|
'playwright',
|
|
'codegen',
|
|
'--target=playwright-test',
|
|
'--test-id-attribute=data-testid',
|
|
'--load-storage',
|
|
options.storage,
|
|
];
|
|
|
|
if (options.saveOutput) {
|
|
fs.mkdirSync(path.dirname(options.output), { recursive: true });
|
|
args.push('--output', options.output);
|
|
}
|
|
|
|
args.push(options.url);
|
|
console.log(`[record] Opening Playwright codegen at ${options.url}`);
|
|
if (options.saveOutput) {
|
|
console.log(`[record] Raw recording will be written to ${options.output}`);
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(npxBin, args, {
|
|
cwd: rootPath,
|
|
env,
|
|
stdio: 'inherit',
|
|
});
|
|
child.on('exit', (code) => {
|
|
if (code === 0) {
|
|
resolve();
|
|
} else {
|
|
reject(new Error(`Playwright codegen exited with code ${code}`));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
if (options.help) {
|
|
printHelp();
|
|
return;
|
|
}
|
|
|
|
if (!['mock', 'local'].includes(options.profile)) {
|
|
throw new Error('--profile must be "mock" or "local"');
|
|
}
|
|
|
|
const env = getEnv(options.profile);
|
|
const children = [];
|
|
|
|
try {
|
|
if (options.profile === 'mock') {
|
|
writeRuntimeMockConfig();
|
|
}
|
|
|
|
if (await waitForURL(baseURL, 1000)) {
|
|
if (options.profile === 'mock') {
|
|
console.warn('[record] Reusing an existing app server; make sure it uses e2e mock config.');
|
|
}
|
|
} else {
|
|
children.push(
|
|
spawnProcess('app', 'node', [path.resolve(rootPath, 'e2e/setup/start-server.js')], env),
|
|
);
|
|
if (!(await waitForURL(baseURL, 120000))) {
|
|
throw new Error(`LibreChat server did not become ready at ${baseURL}`);
|
|
}
|
|
}
|
|
|
|
await writeStorageState(env, options.storage);
|
|
if (options.authOnly) {
|
|
return;
|
|
}
|
|
await runCodegen(options, env);
|
|
} finally {
|
|
for (const child of children.reverse()) {
|
|
await stopProcess(child);
|
|
}
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error('[record] Failed:', error);
|
|
process.exit(1);
|
|
});
|