Forráskód Böngészése

feat: add basic cypress test as initial work towards e2e tests

Jun Siang Cheah 1 éve
szülő
commit
730befce45

+ 1 - 0
.eslintrc.cjs

@@ -4,6 +4,7 @@ module.exports = {
 		'eslint:recommended',
 		'plugin:@typescript-eslint/recommended',
 		'plugin:svelte/recommended',
+		'plugin:cypress/recommended',
 		'prettier'
 	],
 	parser: '@typescript-eslint/parser',

+ 55 - 0
.github/workflows/integration-test.yml

@@ -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

+ 5 - 1
.gitignore

@@ -297,4 +297,8 @@ dist
 .yarn/unplugged
 .yarn/build-state.yml
 .yarn/install-state.gz
-.pnp.*
+.pnp.*
+
+# cypress artifacts
+cypress/videos
+cypress/screenshots

+ 8 - 0
cypress.config.ts

@@ -0,0 +1,8 @@
+import { defineConfig } from 'cypress';
+
+export default defineConfig({
+	e2e: {
+		baseUrl: 'http://localhost:8080'
+	},
+	video: true
+});

+ 46 - 0
cypress/e2e/chat.cy.ts

@@ -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 - 0
cypress/e2e/registration.cy.ts

@@ -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 - 0
cypress/e2e/settings.cy.ts

@@ -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 - 0
cypress/support/e2e.ts

@@ -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 - 0
cypress/support/index.d.ts

@@ -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 - 0
cypress/tsconfig.json

@@ -0,0 +1,7 @@
+{
+	"extends": "../tsconfig.json",
+	"compilerOptions": {
+		"inlineSourceMap": true,
+		"sourceMap": false
+	}
+}

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 839 - 11
package-lock.json


+ 4 - 1
package.json

@@ -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",

+ 1 - 1
src/lib/apis/streaming/index.ts

@@ -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: /, ''));

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott