mirror of
				https://github.com/open-webui/open-webui
				synced 2025-06-26 18:26:48 +00: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