mirror of
https://github.com/open-webui/open-webui.git
synced 2024-11-25 16:33:05 +08:00
feat: add basic cypress test as initial work towards e2e tests
This commit is contained in:
parent
81fb53e757
commit
730befce45
@ -4,6 +4,7 @@ module.exports = {
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:svelte/recommended',
|
||||
'plugin:cypress/recommended',
|
||||
'prettier'
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
|
55
.github/workflows/integration-test.yml
vendored
Normal file
55
.github/workflows/integration-test.yml
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
name: Integration Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
cypress-run:
|
||||
name: Run Cypress Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and run Compose Stack
|
||||
run: |
|
||||
docker compose up --detach --build
|
||||
|
||||
- name: Preload Ollama model
|
||||
run: |
|
||||
docker exec ollama ollama pull qwen:0.5b-chat-v1.5-q2_K
|
||||
|
||||
- name: Cypress run
|
||||
uses: cypress-io/github-action@v6
|
||||
with:
|
||||
browser: chrome
|
||||
wait-on: 'http://localhost:3000'
|
||||
config: baseUrl=http://localhost:3000
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
name: Upload Cypress videos
|
||||
with:
|
||||
name: cypress-videos
|
||||
path: cypress/videos
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Extract Compose logs
|
||||
if: always()
|
||||
run: |
|
||||
docker compose logs > compose-logs.txt
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
name: Upload Compose logs
|
||||
with:
|
||||
name: compose-logs
|
||||
path: compose-logs.txt
|
||||
if-no-files-found: ignore
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -297,4 +297,8 @@ dist
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
.pnp.*
|
||||
|
||||
# cypress artifacts
|
||||
cypress/videos
|
||||
cypress/screenshots
|
||||
|
8
cypress.config.ts
Normal file
8
cypress.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'cypress';
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
baseUrl: 'http://localhost:8080'
|
||||
},
|
||||
video: true
|
||||
});
|
46
cypress/e2e/chat.cy.ts
Normal file
46
cypress/e2e/chat.cy.ts
Normal file
@ -0,0 +1,46 @@
|
||||
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
|
||||
/// <reference path="../support/index.d.ts" />
|
||||
|
||||
// These tests run through the chat flow.
|
||||
describe('Settings', () => {
|
||||
// Wait for 2 seconds after all tests to fix an issue with Cypress's video recording missing the last few frames
|
||||
after(() => {
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.wait(2000);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Login as the admin user
|
||||
cy.loginAdmin();
|
||||
// Visit the home page
|
||||
cy.visit('/');
|
||||
});
|
||||
|
||||
context('Ollama', () => {
|
||||
it('user can select a model', () => {
|
||||
// Click on the model selector
|
||||
cy.get('button[aria-label="Select a model"]').click();
|
||||
// Select the first model
|
||||
cy.get('div[role="option"][data-value]').first().click();
|
||||
});
|
||||
|
||||
it('user can perform text chat', () => {
|
||||
// Click on the model selector
|
||||
cy.get('button[aria-label="Select a model"]').click();
|
||||
// Select the first model
|
||||
cy.get('div[role="option"][data-value]').first().click();
|
||||
// Type a message
|
||||
cy.get('#chat-textarea').type('Hi, what can you do? A single sentence only please.', {
|
||||
force: true
|
||||
});
|
||||
// Send the message
|
||||
cy.get('button[type="submit"]').click();
|
||||
// User's message should be visible
|
||||
cy.get('.chat-user').should('exist');
|
||||
// Wait for the response
|
||||
cy.get('.chat-assistant', { timeout: 120_000 }) // .chat-assistant is created after the first token is received
|
||||
.find('div[aria-label="Generation Info"]', { timeout: 120_000 }) // Generation Info is created after the stop token is received
|
||||
.should('exist');
|
||||
});
|
||||
});
|
||||
});
|
52
cypress/e2e/registration.cy.ts
Normal file
52
cypress/e2e/registration.cy.ts
Normal file
@ -0,0 +1,52 @@
|
||||
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
|
||||
/// <reference path="../support/index.d.ts" />
|
||||
import { adminUser } from '../support/e2e';
|
||||
|
||||
// These tests assume the following defaults:
|
||||
// 1. No users exist in the database or that the test admin user is an admin
|
||||
// 2. Language is set to English
|
||||
// 3. The default role for new users is 'pending'
|
||||
describe('Registration and Login', () => {
|
||||
// Wait for 2 seconds after all tests to fix an issue with Cypress's video recording missing the last few frames
|
||||
after(() => {
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.wait(2000);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
cy.visit('/');
|
||||
});
|
||||
|
||||
it('should register a new user as pending', () => {
|
||||
const userName = `Test User - ${Date.now()}`;
|
||||
const userEmail = `cypress-${Date.now()}@example.com`;
|
||||
// Toggle from sign in to sign up
|
||||
cy.contains('Sign up').click();
|
||||
// Fill out the form
|
||||
cy.get('input[autocomplete="name"]').type(userName);
|
||||
cy.get('input[autocomplete="email"]').type(userEmail);
|
||||
cy.get('input[type="password"]').type('password');
|
||||
// Submit the form
|
||||
cy.get('button[type="submit"]').click();
|
||||
// Wait until the user is redirected to the home page
|
||||
cy.contains(userName);
|
||||
// Expect the user to be pending
|
||||
cy.contains('Check Again');
|
||||
});
|
||||
|
||||
it('can login with the admin user', () => {
|
||||
// Fill out the form
|
||||
cy.get('input[autocomplete="email"]').type(adminUser.email);
|
||||
cy.get('input[type="password"]').type(adminUser.password);
|
||||
// Submit the form
|
||||
cy.get('button[type="submit"]').click();
|
||||
// Wait until the user is redirected to the home page
|
||||
cy.contains(adminUser.name);
|
||||
// Dismiss the changelog dialog if it is visible
|
||||
cy.getAllLocalStorage().then((ls) => {
|
||||
if (!ls['version']) {
|
||||
cy.get('button').contains("Okay, Let's Go!").click();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
88
cypress/e2e/settings.cy.ts
Normal file
88
cypress/e2e/settings.cy.ts
Normal file
@ -0,0 +1,88 @@
|
||||
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
|
||||
/// <reference path="../support/index.d.ts" />
|
||||
import { adminUser } from '../support/e2e';
|
||||
|
||||
// These tests run through the various settings pages, ensuring that the user can interact with them as expected
|
||||
describe('Settings', () => {
|
||||
// Wait for 2 seconds after all tests to fix an issue with Cypress's video recording missing the last few frames
|
||||
after(() => {
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.wait(2000);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Login as the admin user
|
||||
cy.loginAdmin();
|
||||
// Visit the home page
|
||||
cy.visit('/');
|
||||
// Open the sidebar if it is not already open
|
||||
cy.get('[aria-label="Open sidebar"]').then(() => {
|
||||
cy.get('button[id="sidebar-toggle-button"]').click();
|
||||
});
|
||||
// Click on the profile link
|
||||
cy.get('button').contains(adminUser.name).click();
|
||||
// Click on the settings link
|
||||
cy.get('button').contains('Settings').click();
|
||||
});
|
||||
|
||||
context('General', () => {
|
||||
it('user can open the General modal and hit save', () => {
|
||||
cy.get('button').contains('General').click();
|
||||
cy.get('button').contains('Save').click();
|
||||
});
|
||||
});
|
||||
|
||||
context('Connections', () => {
|
||||
it('user can open the Connections modal and hit save', () => {
|
||||
cy.get('button').contains('Connections').click();
|
||||
cy.get('button').contains('Save').click();
|
||||
});
|
||||
});
|
||||
|
||||
context('Models', () => {
|
||||
it('user can open the Models modal', () => {
|
||||
cy.get('button').contains('Models').click();
|
||||
});
|
||||
});
|
||||
|
||||
context('Interface', () => {
|
||||
it('user can open the Interface modal and hit save', () => {
|
||||
cy.get('button').contains('Interface').click();
|
||||
cy.get('button').contains('Save').click();
|
||||
});
|
||||
});
|
||||
|
||||
context('Audio', () => {
|
||||
it('user can open the Audio modal and hit save', () => {
|
||||
cy.get('button').contains('Audio').click();
|
||||
cy.get('button').contains('Save').click();
|
||||
});
|
||||
});
|
||||
|
||||
context('Images', () => {
|
||||
it('user can open the Images modal and hit save', () => {
|
||||
cy.get('button').contains('Images').click();
|
||||
// Currently fails because the backend requires a valid URL
|
||||
// cy.get('button').contains('Save').click();
|
||||
});
|
||||
});
|
||||
|
||||
context('Chats', () => {
|
||||
it('user can open the Chats modal', () => {
|
||||
cy.get('button').contains('Chats').click();
|
||||
});
|
||||
});
|
||||
|
||||
context('Account', () => {
|
||||
it('user can open the Account modal and hit save', () => {
|
||||
cy.get('button').contains('Account').click();
|
||||
cy.get('button').contains('Save').click();
|
||||
});
|
||||
});
|
||||
|
||||
context('About', () => {
|
||||
it('user can open the About modal', () => {
|
||||
cy.get('button').contains('About').click();
|
||||
});
|
||||
});
|
||||
});
|
73
cypress/support/e2e.ts
Normal file
73
cypress/support/e2e.ts
Normal file
@ -0,0 +1,73 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
export const adminUser = {
|
||||
name: 'Admin User',
|
||||
email: 'admin@example.com',
|
||||
password: 'password'
|
||||
};
|
||||
|
||||
const login = (email: string, password: string) => {
|
||||
return cy.session(
|
||||
email,
|
||||
() => {
|
||||
// Visit auth page
|
||||
cy.visit('/auth');
|
||||
// Fill out the form
|
||||
cy.get('input[autocomplete="email"]').type(email);
|
||||
cy.get('input[type="password"]').type(password);
|
||||
// Submit the form
|
||||
cy.get('button[type="submit"]').click();
|
||||
// Wait until the user is redirected to the home page
|
||||
cy.get('#chat-search').should('exist');
|
||||
// Get the current version to skip the changelog dialog
|
||||
if (localStorage.getItem('version') === null) {
|
||||
cy.get('button').contains("Okay, Let's Go!").click();
|
||||
}
|
||||
},
|
||||
{
|
||||
validate: () => {
|
||||
cy.request({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auths/',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + localStorage.getItem('token')
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const register = (name: string, email: string, password: string) => {
|
||||
return cy
|
||||
.request({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auths/signup',
|
||||
body: {
|
||||
name: name,
|
||||
email: email,
|
||||
password: password
|
||||
},
|
||||
failOnStatusCode: false
|
||||
})
|
||||
.then((response) => {
|
||||
expect(response.status).to.be.oneOf([200, 400]);
|
||||
});
|
||||
};
|
||||
|
||||
const registerAdmin = () => {
|
||||
return register(adminUser.name, adminUser.email, adminUser.password);
|
||||
};
|
||||
|
||||
const loginAdmin = () => {
|
||||
return login(adminUser.email, adminUser.password);
|
||||
};
|
||||
|
||||
Cypress.Commands.add('login', (email, password) => login(email, password));
|
||||
Cypress.Commands.add('register', (name, email, password) => register(name, email, password));
|
||||
Cypress.Commands.add('registerAdmin', () => registerAdmin());
|
||||
Cypress.Commands.add('loginAdmin', () => loginAdmin());
|
||||
|
||||
before(() => {
|
||||
cy.registerAdmin();
|
||||
});
|
11
cypress/support/index.d.ts
vendored
Normal file
11
cypress/support/index.d.ts
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
// load the global Cypress types
|
||||
/// <reference types="cypress" />
|
||||
|
||||
declare namespace Cypress {
|
||||
interface Chainable {
|
||||
login(email: string, password: string): Chainable<Element>;
|
||||
register(name: string, email: string, password: string): Chainable<Element>;
|
||||
registerAdmin(): Chainable<Element>;
|
||||
loginAdmin(): Chainable<Element>;
|
||||
}
|
||||
}
|
7
cypress/tsconfig.json
Normal file
7
cypress/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"inlineSourceMap": true,
|
||||
"sourceMap": false
|
||||
}
|
||||
}
|
1611
package-lock.json
generated
1611
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -14,7 +14,8 @@
|
||||
"lint:backend": "pylint backend/",
|
||||
"format": "prettier --plugin-search-dir --write '**/*.{js,ts,svelte,css,md,html,json}'",
|
||||
"format:backend": "black . --exclude \"/venv/\"",
|
||||
"i18n:parse": "i18next --config i18next-parser.config.ts && prettier --write 'src/lib/i18n/**/*.{js,json}'"
|
||||
"i18n:parse": "i18next --config i18next-parser.config.ts && prettier --write 'src/lib/i18n/**/*.{js,json}'",
|
||||
"cy:open": "cypress open"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^2.0.0",
|
||||
@ -25,8 +26,10 @@
|
||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||
"@typescript-eslint/parser": "^6.17.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"cypress": "^13.8.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-cypress": "^3.0.2",
|
||||
"eslint-plugin-svelte": "^2.30.0",
|
||||
"i18next-parser": "^8.13.0",
|
||||
"postcss": "^8.4.31",
|
||||
|
@ -34,7 +34,7 @@ async function* openAIStreamToIterator(
|
||||
} else if (line.startsWith(':')) {
|
||||
// Events starting with : are comments https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format
|
||||
// OpenRouter sends heartbeats like ": OPENROUTER PROCESSING"
|
||||
continue
|
||||
continue;
|
||||
} else {
|
||||
try {
|
||||
const data = JSON.parse(line.replace(/^data: /, ''));
|
||||
|
Loading…
Reference in New Issue
Block a user