forked from open-webui/open-webui
feat: add basic cypress test as initial work towards e2e tests
This commit is contained in:
parent
81fb53e757
commit
730befce45
13 changed files with 1961 additions and 4 deletions
|
@ -4,6 +4,7 @@ module.exports = {
|
||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'plugin:@typescript-eslint/recommended',
|
'plugin:@typescript-eslint/recommended',
|
||||||
'plugin:svelte/recommended',
|
'plugin:svelte/recommended',
|
||||||
|
'plugin:cypress/recommended',
|
||||||
'prettier'
|
'prettier'
|
||||||
],
|
],
|
||||||
parser: '@typescript-eslint/parser',
|
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
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -298,3 +298,7 @@ dist
|
||||||
.yarn/build-state.yml
|
.yarn/build-state.yml
|
||||||
.yarn/install-state.gz
|
.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/",
|
"lint:backend": "pylint backend/",
|
||||||
"format": "prettier --plugin-search-dir --write '**/*.{js,ts,svelte,css,md,html,json}'",
|
"format": "prettier --plugin-search-dir --write '**/*.{js,ts,svelte,css,md,html,json}'",
|
||||||
"format:backend": "black . --exclude \"/venv/\"",
|
"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": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^2.0.0",
|
"@sveltejs/adapter-auto": "^2.0.0",
|
||||||
|
@ -25,8 +26,10 @@
|
||||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||||
"@typescript-eslint/parser": "^6.17.0",
|
"@typescript-eslint/parser": "^6.17.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
|
"cypress": "^13.8.1",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-prettier": "^8.5.0",
|
||||||
|
"eslint-plugin-cypress": "^3.0.2",
|
||||||
"eslint-plugin-svelte": "^2.30.0",
|
"eslint-plugin-svelte": "^2.30.0",
|
||||||
"i18next-parser": "^8.13.0",
|
"i18next-parser": "^8.13.0",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
|
|
|
@ -34,7 +34,7 @@ async function* openAIStreamToIterator(
|
||||||
} else if (line.startsWith(':')) {
|
} 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
|
// 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"
|
// OpenRouter sends heartbeats like ": OPENROUTER PROCESSING"
|
||||||
continue
|
continue;
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(line.replace(/^data: /, ''));
|
const data = JSON.parse(line.replace(/^data: /, ''));
|
||||||
|
|
Loading…
Reference in a new issue