This commit is contained in:
tidusjar 2025-08-23 21:44:22 +01:00
commit 4114c2356a
5 changed files with 467 additions and 161 deletions

View file

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

View file

@ -1,10 +1,79 @@
import { navBar as NavBar } from './shared/NavBar';
export abstract class BasePage {
abstract visit(options: Cypress.VisitOptions): Cypress.Chainable<Cypress.AUTWindow>;
abstract visit(): Cypress.Chainable<Cypress.AUTWindow>;
abstract visit(id: string): Cypress.Chainable<Cypress.AUTWindow>;
abstract visit(id: string, options: Cypress.VisitOptions): Cypress.Chainable<Cypress.AUTWindow>;
navbar = NavBar;
export abstract class BasePage {
// Abstract visit methods
abstract visit(options?: any): Cypress.Chainable<any>;
abstract visit(id?: string, options?: any): Cypress.Chainable<any>;
// Common page properties
navbar = NavBar;
// Common page methods
/**
* Wait for page to be fully loaded
*/
waitForPageLoad(): Cypress.Chainable<any> {
return cy.get('body').should('be.visible');
}
/**
* Check if page is loaded by verifying a unique element
*/
isPageLoaded(uniqueSelector: string): Cypress.Chainable<any> {
return cy.get(uniqueSelector).should('exist').then(() => true);
}
/**
* Scroll to element with smooth behavior
*/
scrollToElement(selector: string): Cypress.Chainable<any> {
return cy.get(selector).scrollIntoView({ behavior: 'smooth' });
}
/**
* Wait for loading spinner to disappear
*/
waitForLoadingToComplete(): Cypress.Chainable<any> {
return cy.get('[data-test="loading-spinner"]', { timeout: 10000 }).should('not.exist');
}
/**
* Get element by data-test attribute
*/
getByData(selector: string): Cypress.Chainable<any> {
return cy.get(`[data-test="${selector}"]`);
}
/**
* Get element by data-test attribute with partial match
*/
getByDataLike(selector: string): Cypress.Chainable<any> {
return cy.get(`[data-test*="${selector}"]`);
}
/**
* Check if element is visible and enabled
*/
isElementInteractive(selector: string): Cypress.Chainable<any> {
return cy.get(selector)
.should('be.visible')
.should('not.be.disabled')
.then(() => true);
}
/**
* Take screenshot of current page
*/
takeScreenshot(name?: string): Cypress.Chainable<any> {
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);
}
}

View file

@ -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<any> {
return cy.get('#username-field');
}
get password() {
return cy.get(this.selectors.password);
}
get password(): Cypress.Chainable<any> {
return cy.get('#password-field');
}
get ombiSignInButton() {
return cy.get(this.selectors.ombiSignInButton);
}
get ombiSignInButton(): Cypress.Chainable<any> {
return cy.get('[data-cy=OmbiButton]');
}
get plexSignInButton() {
return cy.get(this.selectors.plexSignInButton);
}
get plexSignInButton(): Cypress.Chainable<any> {
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<Cypress.AUTWindow>;
visit(): Cypress.Chainable<Cypress.AUTWindow>;
visit(id: string): Cypress.Chainable<Cypress.AUTWindow>;
visit(id: string, options: Cypress.VisitOptions): Cypress.Chainable<Cypress.AUTWindow>;
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<any> {
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<any> {
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<any> {
this.logAction('Submitting login form');
return this.ombiSignInButton.click();
}
/**
* Complete login flow
*/
login(username: string, password: string): Cypress.Chainable<any> {
this.logAction('Performing login', { username });
return this.fillLoginForm(username, password)
.then(() => this.submitLoginForm());
}
/**
* Wait for login to complete
*/
waitForLoginComplete(): Cypress.Chainable<any> {
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<any> {
return this.loginForm.should('be.visible').then(() => true);
}
/**
* Check if Plex OAuth button is visible
*/
isPlexOAuthVisible(): Cypress.Chainable<any> {
return this.plexSignInButton.should('be.visible').then(() => true);
}
/**
* Check if error message is displayed
*/
isErrorMessageVisible(): Cypress.Chainable<any> {
return this.errorMessage.should('be.visible').then(() => true);
}
/**
* Get error message text
*/
getErrorMessageText(): Cypress.Chainable<any> {
return this.errorMessage.invoke('text');
}
/**
* Clear login form
*/
clearLoginForm(): Cypress.Chainable<any> {
this.logAction('Clearing login form');
return cy.wrap(null).then(() => {
this.username.clear();
this.password.clear();
});
}
/**
* Verify page is loaded
*/
verifyPageLoaded(): Cypress.Chainable<any> {
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();

View file

@ -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<void>;
loginWithCreds(username: string, password: string): Chainable<void>;
login(): Chainable<void>;
removeLogin(): Chainable<void>;
verifyNotification(text: string): Chainable<void>;
createUser(username: string, password: string, claims: string[]): Chainable<void>;
generateUniqueId(): Chainable<string>;
getByData(selector: string): Chainable<JQuery<HTMLElement>>;
getByDataLike(selector: string): Chainable<JQuery<HTMLElement>>;
triggerHover(elements: JQuery<HTMLElement>): Chainable<void>;
waitForApiResponse(alias: string, timeout?: number): Chainable<void>;
clearTestData(): Chainable<void>;
seedTestData(fixture: string): Chainable<void>;
}
}
}
// 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<HTMLElement>) {
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);
});

View file

@ -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"
]
}