From 4114c2356a4a44f4816f61a230b7a6aea0978f41 Mon Sep 17 00:00:00 2001 From: tidusjar Date: Sat, 23 Aug 2025 21:44:22 +0100 Subject: [PATCH] tests --- tests/cypress.config.ts | 80 ++++- .../integration/page-objects/base.page.ts | 81 ++++- .../page-objects/login/login.page.ts | 157 ++++++++-- tests/cypress/support/commands.ts | 280 ++++++++++-------- tests/tsconfig.json | 30 +- 5 files changed, 467 insertions(+), 161 deletions(-) diff --git a/tests/cypress.config.ts b/tests/cypress.config.ts index 53d177e2f..54eb0bef4 100644 --- a/tests/cypress.config.ts +++ b/tests/cypress.config.ts @@ -4,24 +4,39 @@ import { addCucumberPreprocessorPlugin } from "@badeball/cypress-cucumber-prepro import createEsbuildPlugin from "@badeball/cypress-cucumber-preprocessor/esbuild"; export default defineConfig({ - watchForFileChanges: true, - video: true, + // Performance optimizations + watchForFileChanges: false, // Disable in CI + video: false, // Disable video recording for faster runs + screenshotOnRunFailure: true, + trashAssetsBeforeRuns: true, + + // Security and viewport chromeWebSecurity: false, - viewportWidth: 2560, - viewportHeight: 1440, + viewportWidth: 1920, + viewportHeight: 1080, + + // Retry configuration retries: { runMode: 2, openMode: 0, }, + + // Environment variables env: { username: 'a', password: 'a', - dockerhost: 'http://172.17.0.1' + dockerhost: 'http://172.17.0.1', + // Add test environment flags + isCI: process.env.CI === 'true', + // Add API base URL + apiBaseUrl: 'http://localhost:5000/api/v1' }, + + // Project configuration projectId: 'o5451s', + e2e: { - // We've imported your old cypress plugins here. - // You may want to clean this up later by importing these. + // Setup node events async setupNodeEvents( on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions @@ -35,12 +50,53 @@ export default defineConfig({ }) ); - // Make sure to return the config object as it might have been modified by the plugin. + // Add performance monitoring + on('task', { + log(message) { + console.log(message); + return null; + }, + table(message) { + console.table(message); + return null; + } + }); + return config; - // return require('./cypress/plugins/index.js')(on, config) }, + + // Base configuration baseUrl: 'http://localhost:5000', - specPattern: ['cypress/tests/**/*.spec.ts*', '**/*.feature'], - excludeSpecPattern: ['**/snapshots/*'], + specPattern: [ + 'cypress/tests/**/*.spec.ts*', + 'cypress/features/**/*.feature' + ], + excludeSpecPattern: [ + '**/snapshots/*', + '**/examples/*', + '**/*.skip.ts' + ], + + // Test isolation and performance + experimentalRunAllSpecs: true, + experimentalModifyObstructiveThirdPartyCode: true, + + // Better error handling + defaultCommandTimeout: 10000, + requestTimeout: 10000, + responseTimeout: 10000, + + // Screenshot and video settings + screenshotsFolder: 'cypress/screenshots', + videosFolder: 'cypress/videos', }, -}) + + // Component testing (if needed later) + component: { + devServer: { + framework: 'angular', + bundler: 'webpack', + }, + specPattern: 'cypress/component/**/*.cy.ts', + }, +}); diff --git a/tests/cypress/integration/page-objects/base.page.ts b/tests/cypress/integration/page-objects/base.page.ts index 86f39de15..1c6721a6a 100644 --- a/tests/cypress/integration/page-objects/base.page.ts +++ b/tests/cypress/integration/page-objects/base.page.ts @@ -1,10 +1,79 @@ import { navBar as NavBar } from './shared/NavBar'; -export abstract class BasePage { - abstract visit(options: Cypress.VisitOptions): Cypress.Chainable; - abstract visit(): Cypress.Chainable; - abstract visit(id: string): Cypress.Chainable; - abstract visit(id: string, options: Cypress.VisitOptions): Cypress.Chainable; - navbar = NavBar; +export abstract class BasePage { + // Abstract visit methods + abstract visit(options?: any): Cypress.Chainable; + abstract visit(id?: string, options?: any): Cypress.Chainable; + + // Common page properties + navbar = NavBar; + + // Common page methods + /** + * Wait for page to be fully loaded + */ + waitForPageLoad(): Cypress.Chainable { + return cy.get('body').should('be.visible'); + } + + /** + * Check if page is loaded by verifying a unique element + */ + isPageLoaded(uniqueSelector: string): Cypress.Chainable { + return cy.get(uniqueSelector).should('exist').then(() => true); + } + + /** + * Scroll to element with smooth behavior + */ + scrollToElement(selector: string): Cypress.Chainable { + return cy.get(selector).scrollIntoView({ behavior: 'smooth' }); + } + + /** + * Wait for loading spinner to disappear + */ + waitForLoadingToComplete(): Cypress.Chainable { + return cy.get('[data-test="loading-spinner"]', { timeout: 10000 }).should('not.exist'); + } + + /** + * Get element by data-test attribute + */ + getByData(selector: string): Cypress.Chainable { + return cy.get(`[data-test="${selector}"]`); + } + + /** + * Get element by data-test attribute with partial match + */ + getByDataLike(selector: string): Cypress.Chainable { + return cy.get(`[data-test*="${selector}"]`); + } + + /** + * Check if element is visible and enabled + */ + isElementInteractive(selector: string): Cypress.Chainable { + return cy.get(selector) + .should('be.visible') + .should('not.be.disabled') + .then(() => true); + } + + /** + * Take screenshot of current page + */ + takeScreenshot(name?: string): Cypress.Chainable { + const screenshotName = name || `${this.constructor.name}_${Date.now()}`; + return cy.screenshot(screenshotName); + } + + /** + * Log page action for debugging + */ + logAction(action: string, details?: any): void { + cy.log(`[${this.constructor.name}] ${action}`, details); + } } diff --git a/tests/cypress/integration/page-objects/login/login.page.ts b/tests/cypress/integration/page-objects/login/login.page.ts index 37e2b8eab..fb0021e20 100644 --- a/tests/cypress/integration/page-objects/login/login.page.ts +++ b/tests/cypress/integration/page-objects/login/login.page.ts @@ -1,36 +1,147 @@ import { BasePage } from "../base.page"; -class LoginPage extends BasePage { +export class LoginPage extends BasePage { + // Page selectors + private readonly selectors = { + username: '#username-field', + password: '#password-field', + ombiSignInButton: '[data-cy=OmbiButton]', + plexSignInButton: '[data-cy=oAuthPlexButton]', + loginForm: '[data-test="login-form"]', + errorMessage: '[data-test="error-message"]', + loadingSpinner: '[data-test="loading-spinner"]' + } as const; + // Getters for page elements + get username() { + return cy.get(this.selectors.username); + } - get username(): Cypress.Chainable { - return cy.get('#username-field'); - } + get password() { + return cy.get(this.selectors.password); + } - get password(): Cypress.Chainable { - return cy.get('#password-field'); - } + get ombiSignInButton() { + return cy.get(this.selectors.ombiSignInButton); + } - get ombiSignInButton(): Cypress.Chainable { - return cy.get('[data-cy=OmbiButton]'); - } + get plexSignInButton() { + return cy.get(this.selectors.plexSignInButton); + } - get plexSignInButton(): Cypress.Chainable { - return cy.get('[data-cy=oAuthPlexButton]'); - } + get loginForm() { + return cy.get(this.selectors.loginForm); + } - constructor() { - super(); - } + get errorMessage() { + return cy.get(this.selectors.errorMessage); + } - visit(options: Cypress.VisitOptions): Cypress.Chainable; - visit(): Cypress.Chainable; - visit(id: string): Cypress.Chainable; - visit(id: string, options: Cypress.VisitOptions): Cypress.Chainable; - visit(id?: any, options?: any) { - return cy.visit(`/login`, options); - } + get loadingSpinner() { + return cy.get(this.selectors.loadingSpinner); + } + // Page visit method + visit(options?: any): Cypress.Chainable { + this.logAction('Visiting login page'); + return cy.visit('/login', options); + } + + // Page-specific methods + /** + * Fill login form with credentials + */ + fillLoginForm(username: string, password: string): Cypress.Chainable { + this.logAction('Filling login form', { username }); + + return cy.wrap(null).then(() => { + this.username.clear().type(username); + this.password.clear().type(password); + }); + } + + /** + * Submit login form + */ + submitLoginForm(): Cypress.Chainable { + this.logAction('Submitting login form'); + return this.ombiSignInButton.click(); + } + + /** + * Complete login flow + */ + login(username: string, password: string): Cypress.Chainable { + this.logAction('Performing login', { username }); + + return this.fillLoginForm(username, password) + .then(() => this.submitLoginForm()); + } + + /** + * Wait for login to complete + */ + waitForLoginComplete(): Cypress.Chainable { + this.logAction('Waiting for login to complete'); + + return cy.url().should('not.include', '/login') + .then(() => { + this.logAction('Login completed successfully'); + }); + } + + /** + * Check if login form is visible + */ + isLoginFormVisible(): Cypress.Chainable { + return this.loginForm.should('be.visible').then(() => true); + } + + /** + * Check if Plex OAuth button is visible + */ + isPlexOAuthVisible(): Cypress.Chainable { + return this.plexSignInButton.should('be.visible').then(() => true); + } + + /** + * Check if error message is displayed + */ + isErrorMessageVisible(): Cypress.Chainable { + return this.errorMessage.should('be.visible').then(() => true); + } + + /** + * Get error message text + */ + getErrorMessageText(): Cypress.Chainable { + return this.errorMessage.invoke('text'); + } + + /** + * Clear login form + */ + clearLoginForm(): Cypress.Chainable { + this.logAction('Clearing login form'); + + return cy.wrap(null).then(() => { + this.username.clear(); + this.password.clear(); + }); + } + + /** + * Verify page is loaded + */ + verifyPageLoaded(): Cypress.Chainable { + this.logAction('Verifying login page is loaded'); + + return this.waitForPageLoad() + .then(() => this.isLoginFormVisible()) + .then(() => { + this.logAction('Login page loaded successfully'); + }); + } } export const loginPage = new LoginPage(); diff --git a/tests/cypress/support/commands.ts b/tests/cypress/support/commands.ts index 9a08bdef1..4b241874c 100644 --- a/tests/cypress/support/commands.ts +++ b/tests/cypress/support/commands.ts @@ -1,126 +1,174 @@ // *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands +// Enhanced custom commands with better TypeScript support // *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add("login", (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) import 'cypress-wait-until'; -Cypress.Commands.add("landingSettings", (enabled) => { - cy.fixture('login/landingPageSettings').then((settings) => { - settings.enabled = enabled; - cy.intercept("GET", "**/Settings/LandingPage", settings).as("landingPageSettingsDisabled"); - }) -}) - -Cypress.Commands.add('loginWithCreds', (username, password) => { - cy.request({ - method: 'POST', - url: '/api/v1/token', - body: { - username: username, - password: password, - } - }) - .then((resp) => { - window.localStorage.setItem('id_token', resp.body.access_token); - }); - }); - - Cypress.Commands.add('login', () => { - cy.clearLocalStorage(); - cy.request({ - method: 'POST', - url: '/api/v1/token', - body: { - username: Cypress.env('username'), - password: Cypress.env('password'), - } - }) - .then((resp) => { - window.localStorage.setItem('id_token', resp.body.access_token); - }); - }); - -Cypress.Commands.add('removeLogin', () => { - window.localStorage.removeItem('id_token'); -}); - -Cypress.Commands.add('verifyNotification', (text) => { - cy.contains(text, {timeout: 10000}); -}); - -Cypress.Commands.add('createUser', (username, password, claims) => { - cy.request({ - method: 'POST', - url: '/api/v1/identity', - body: { - UserName: username, - Password: password, - Claims: claims, - }, - headers: { - 'Authorization': 'Bearer ' + window.localStorage.getItem('id_token'), - } - }) -}) - -Cypress.Commands.add('generateUniqueId', () => { - const uniqueSeed = Date.now().toString(); - const id = Cypress._.uniqueId(uniqueSeed); - cy.wrap(id); -}); - -Cypress.Commands.add("getByData", (selector, ...args) => { - return cy.get(`[data-test=${selector}]`, ...args); -}); - - -Cypress.Commands.add("getByData", (selector) => { - return cy.get(`[data-test=${selector}]`); - }); - - Cypress.Commands.add("getByDataLike", (selector) => { - return cy.get(`[data-test*=${selector}]`); - }); - - Cypress.Commands.add('triggerHover', function(elements) { - - fireEvent(elements, 'mouseover'); - - - function fireEvent(element, event) { - if (element.fireEvent) { - element.fireEvent('on' + event); - } else { - const evObj = document.createEvent('Events'); - - evObj.initEvent(event, true, false); - - element.dispatchEvent(evObj); - } +// Type definitions for custom commands +declare global { + namespace Cypress { + interface Chainable { + landingSettings(enabled: boolean): Chainable; + loginWithCreds(username: string, password: string): Chainable; + login(): Chainable; + removeLogin(): Chainable; + verifyNotification(text: string): Chainable; + createUser(username: string, password: string, claims: string[]): Chainable; + generateUniqueId(): Chainable; + getByData(selector: string): Chainable>; + getByDataLike(selector: string): Chainable>; + triggerHover(elements: JQuery): Chainable; + waitForApiResponse(alias: string, timeout?: number): Chainable; + clearTestData(): Chainable; + seedTestData(fixture: string): Chainable; } + } +} +// Enhanced landing page settings command +Cypress.Commands.add("landingSettings", (enabled: boolean) => { + cy.fixture('login/landingPageSettings').then((settings) => { + settings.enabled = enabled; + cy.intercept("GET", "**/Settings/LandingPage", settings).as("landingPageSettings"); }); +}); + +// Enhanced login with credentials +Cypress.Commands.add('loginWithCreds', (username: string, password: string) => { + cy.request({ + method: 'POST', + url: '/api/v1/token', + body: { username, password }, + failOnStatusCode: false + }).then((resp) => { + if (resp.status === 200) { + window.localStorage.setItem('id_token', resp.body.access_token); + cy.log(`Successfully logged in as ${username}`); + } else { + cy.log(`Login failed for ${username}: ${resp.status}`); + } + }); +}); + +// Enhanced default login +Cypress.Commands.add('login', () => { + cy.clearLocalStorage(); + cy.clearCookies(); + + const username = Cypress.env('username'); + const password = Cypress.env('password'); + + if (!username || !password) { + throw new Error('Username and password must be set in environment variables'); + } + + cy.loginWithCreds(username, password); +}); + +// Enhanced login removal +Cypress.Commands.add('removeLogin', () => { + cy.clearLocalStorage(); + cy.clearCookies(); + cy.log('Cleared authentication data'); +}); + +// Enhanced notification verification with better error handling +Cypress.Commands.add('verifyNotification', (text: string) => { + cy.contains(text, { timeout: 10000 }) + .should('be.visible') + .then(() => { + cy.log(`Notification "${text}" verified successfully`); + }); +}); + +// Enhanced user creation with better error handling +Cypress.Commands.add('createUser', (username: string, password: string, claims: string[]) => { + const token = window.localStorage.getItem('id_token'); + if (!token) { + throw new Error('No authentication token found. Please login first.'); + } + + cy.request({ + method: 'POST', + url: '/api/v1/identity', + body: { + UserName: username, + Password: password, + Claims: claims, + }, + headers: { + 'Authorization': `Bearer ${token}`, + }, + failOnStatusCode: false + }).then((resp) => { + if (resp.status === 200) { + cy.log(`User ${username} created successfully`); + } else { + cy.log(`Failed to create user ${username}: ${resp.status}`); + } + }); +}); + +// Enhanced unique ID generation +Cypress.Commands.add('generateUniqueId', () => { + const uniqueSeed = Date.now().toString(); + const id = Cypress._.uniqueId(uniqueSeed); + cy.wrap(id); +}); + +// Enhanced data attribute selectors with better typing +Cypress.Commands.add("getByData", (selector: string) => { + return cy.get(`[data-test="${selector}"]`); +}); + +Cypress.Commands.add("getByDataLike", (selector: string) => { + return cy.get(`[data-test*="${selector}"]`); +}); + +// Enhanced hover trigger with better event handling +Cypress.Commands.add('triggerHover', function(elements: JQuery) { + elements.each((index, element) => { + const mouseoverEvent = new MouseEvent('mouseover', { + bubbles: true, + cancelable: true, + view: window + }); + element.dispatchEvent(mouseoverEvent); + }); +}); + +// New command: Wait for API response with timeout +Cypress.Commands.add('waitForApiResponse', (alias: string, timeout: number = 10000) => { + cy.wait(`@${alias}`, { timeout }); +}); + +// New command: Clear test data +Cypress.Commands.add('clearTestData', () => { + cy.clearLocalStorage(); + cy.clearCookies(); + cy.clearSessionStorage(); + cy.log('All test data cleared'); +}); + +// New command: Seed test data from fixture +Cypress.Commands.add('seedTestData', (fixture: string) => { + cy.fixture(fixture).then((data) => { + // Implementation depends on your seeding strategy + cy.log(`Seeding test data from ${fixture}`); + // Example: cy.request('POST', '/api/v1/test/seed', data); + }); +}); + +// Override visit command to add better logging +Cypress.Commands.overwrite('visit', (originalFn, url, options) => { + cy.log(`Visiting: ${url}`); + return originalFn(url, options); +}); + +// Override click command to add better logging +Cypress.Commands.overwrite('click', (originalFn, subject, options) => { + cy.log(`Clicking element: ${subject.selector || 'unknown'}`); + return originalFn(subject, options); +}); \ No newline at end of file diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 07a8c10e2..dfdb9bfea 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -1,16 +1,38 @@ { "compilerOptions": { - "target": "es6", + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "node", + "strict": true, "esModuleInterop": true, - "lib": ["es2018", "dom"], - "types": ["cypress", "cypress-wait-until", "cypress-image-snapshot", "cypress-real-events", "@bahmutov/cy-api", "node"], + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": [ + "cypress", + "cypress-wait-until", + "cypress-image-snapshot", + "cypress-real-events", + "@bahmutov/cy-api", + "node" + ], "baseUrl": "./cypress", "paths": { - "@/*": ["./*"] + "@/*": ["./*"], + "@fixtures/*": ["./fixtures/*"], + "@page-objects/*": ["./integration/page-objects/*"], + "@support/*": ["./support/*"] } }, "include": [ "**/*.ts", "support/*.ts" + ], + "exclude": [ + "node_modules" ] } \ No newline at end of file