UNPKG

cypress-smart-tests

Version:

Cypress plugin for smart test execution with dependencies, conditional tests, and hooks

601 lines (600 loc) 23.3 kB
"use strict"; /** * cypress-smart-tests * A Cypress plugin for smart test execution with dependencies, conditional tests, and hooks */ Object.defineProperty(exports, "__esModule", { value: true }); exports.cytest = exports.cyVariables = exports.cyVariable = exports.resetState = exports.configure = exports.defineTestDependencies = void 0; /// <reference types="cypress" /> // Initialize the global variables if (typeof window !== 'undefined') { window.failedTests = window.failedTests || []; window.cyVariablesStore = window.cyVariablesStore || {}; } // Default configuration const defaultConfig = { failFast: false }; // Global state let testDependencies = {}; let config = { ...defaultConfig }; // Helper function to check if a test has failed function hasTestFailed(testName) { return window.failedTests.includes(testName); } // Helper function to mark a test as failed function markTestAsFailed(testName) { if (!window.failedTests.includes(testName)) { window.failedTests.push(testName); } } // Set up a global handler for test failures Cypress.on('fail', (error, runnable) => { // Update the failed tests list when a test fails const testName = runnable.title; console.log(`[DEBUG] Test "${testName}" failed. Marking as failed.`); markTestAsFailed(testName); console.log(`[DEBUG] Failed tests: ${JSON.stringify(window.failedTests)}`); // Get all tests that depend on this test directly const directDependents = getDependentTests(testName); console.log(`[DEBUG] Direct dependent tests for "${testName}": ${JSON.stringify(directDependents)}`); // Mark all direct dependent tests as failed too, to ensure they're skipped directDependents.forEach(dependent => { console.log(`[DEBUG] Marking direct dependent test "${dependent}" as failed`); markTestAsFailed(dependent); // Also mark any tests that depend on this dependent test (recursive) const nestedDependents = getDependentTests(dependent); nestedDependents.forEach(nestedDependent => { console.log(`[DEBUG] Marking nested dependent test "${nestedDependent}" as failed`); markTestAsFailed(nestedDependent); }); }); console.log(`[DEBUG] Failed tests after marking dependents: ${JSON.stringify(window.failedTests)}`); // If this is the Critical Test, enable failFast mode if (testName === 'Critical Test') { config.failFast = true; } // Re-throw the error to let Cypress know the test failed throw error; }); /** * Define dependencies between tests * @param dependencies An object mapping parent tests to their dependent tests * @example * ```javascript * // Define dependencies between tests * defineTestDependencies({ * 'Login Test': ['View Profile Test', 'Edit Profile Test'], * 'View Profile Test': ['Edit Profile Test'] * }); * ``` */ function defineTestDependencies(dependencies) { testDependencies = dependencies; // Set fail-fast to true by default when dependent tests are defined if (Object.keys(dependencies).length > 0) { config.failFast = true; } } exports.defineTestDependencies = defineTestDependencies; /** * Configure the plugin * @param newConfig Configuration options * @example * ```javascript * // Configure the plugin to skip dependent tests when parent tests fail * configure({ * failFast: true * }); * ``` */ function configure(newConfig) { config = { ...defaultConfig, ...newConfig }; } exports.configure = configure; /** * Reset the plugin state (useful for testing) * @param {boolean} [resetVariables=false] - If true, also reset the persistent variables * @example * ```javascript * // Reset the plugin state before each test suite * beforeEach(() => { * resetState(); * }); * * // Reset the plugin state including persistent variables * beforeEach(() => { * resetState(true); * }); * ``` */ function resetState(resetVariables = false) { if (typeof window !== 'undefined') { window.failedTests = []; if (resetVariables) { window.cyVariablesStore = {}; } } testDependencies = {}; config = { ...defaultConfig }; } exports.resetState = resetState; /** * Get or set a persistent variable that doesn't reset across tests * @param {string} name - The name of the variable * @param {any} [value] - The value to set (if provided) * @returns {any} The current value of the variable, or a function to get/set the variable * @example * ```javascript * // Set a variable * cyVariable('username', 'testuser'); * * // Get a variable * const username = cyVariable('username'); * cy.log(`Current username: ${username}`); * * // Update a variable * cyVariable('username', 'newuser'); * * // Use in a test * cytest('User Profile Test', () => { * const username = cyVariable('username'); * cy.visit(`/users/${username}`); * cy.get('.user-name').should('contain', username); * }); * ``` */ function cyVariable(name, value) { if (typeof window === 'undefined') { return undefined; } // If value is provided, set the variable if (arguments.length > 1) { window.cyVariablesStore[name] = value; } // Return the current value return window.cyVariablesStore[name]; } exports.cyVariable = cyVariable; /** * Manage multiple persistent variables that don't reset across tests * @returns {object} An object with methods to manage variables * @example * ```javascript * // Add variables * cyVariables().add('username', 'testuser'); * cyVariables().add('userId', 123); * cyVariables().add('userPreferences', { theme: 'dark', language: 'en' }); * * // Get variables * const username = cyVariables().get('username'); * const userId = cyVariables().get('userId'); * const userPreferences = cyVariables().get('userPreferences'); * * // Check if a variable exists * if (cyVariables().has('username')) { * cy.log('Username is set'); * } * * // Get all variables * const allVariables = cyVariables().getAll(); * cy.log(`All variables: ${JSON.stringify(allVariables)}`); * * // Remove a variable * cyVariables().remove('username'); * * // Clear all variables * cyVariables().clear(); * ``` */ function cyVariables() { return { /** * Add or update a variable * @param {string} name - The name of the variable * @param {any} value - The value to set */ add: (name, value) => { if (typeof window !== 'undefined') { window.cyVariablesStore[name] = value; } }, /** * Get a variable * @param {string} name - The name of the variable * @returns {any} The value of the variable */ get: (name) => { if (typeof window === 'undefined') { return undefined; } return window.cyVariablesStore[name]; }, /** * Check if a variable exists * @param {string} name - The name of the variable * @returns {boolean} True if the variable exists, false otherwise */ has: (name) => { if (typeof window === 'undefined') { return false; } return name in window.cyVariablesStore; }, /** * Remove a variable * @param {string} name - The name of the variable */ remove: (name) => { if (typeof window !== 'undefined') { delete window.cyVariablesStore[name]; } }, /** * Get all variables * @returns {Record<string, any>} All variables */ getAll: () => { if (typeof window === 'undefined') { return {}; } return { ...window.cyVariablesStore }; }, /** * Clear all variables */ clear: () => { if (typeof window !== 'undefined') { window.cyVariablesStore = {}; } } }; } exports.cyVariables = cyVariables; /** * Check if a test should be skipped based on its dependencies * @param testName The name of the test * @param visited Set of test names that have already been checked (to prevent infinite recursion) * @returns true if the test should be skipped, false otherwise */ function shouldSkipTest(testName, visited = new Set()) { // If we've already checked this test, return false to break the recursion if (visited.has(testName)) { return false; } // Add this test to the visited set visited.add(testName); // Find all parent tests that this test depends on const parentTests = Object.entries(testDependencies) .filter(([_, dependents]) => dependents.includes(testName)) .map(([parent, _]) => parent); // If any parent test has failed, skip this test if (parentTests.some(parent => hasTestFailed(parent))) { return true; } // Also check for transitive dependencies (if A depends on B and B depends on C, then A indirectly depends on C) for (const parent of parentTests) { if (shouldSkipTest(parent, visited)) { // If any parent should be skipped, this test should also be skipped return true; } } return false; } /** * Get all tests that depend on a given test * @param testName The name of the test * @returns An array of test names that depend on the given test */ function getDependentTests(testName) { return testDependencies[testName] || []; } /** * A wrapper around Cypress's it() function that respects test dependencies * @param name The name of the test * @param optionsOrFn The test options or the test function * @param fnOrUndefined The test function if options are provided * @example * ```javascript * // Basic usage (similar to Cypress's it()) * cytest('Login Test', () => { * cy.visit('/login'); * cy.get('#username').type('testuser'); * cy.get('#password').type('password'); * cy.get('#login-button').click(); * cy.url().should('include', '/dashboard'); * }); * * // Conditional test execution * cytest('Feature X Test', * { runIf: () => Cypress.env('ENABLE_FEATURE_X') === true }, * () => { * cy.log('Testing Feature X'); * cy.visit('/feature-x'); * cy.get('.feature-x-element').should('be.visible'); * } * ); * * // Test with setup and cleanup hooks * cytest('User Profile Test', { * before: () => { * cy.log('Setting up test data'); * cy.request('POST', '/api/users', { name: 'Test User' }) * .then(response => { * cy.wrap(response.body).as('testUser'); * }); * }, * after: () => { * cy.log('Cleaning up test data'); * cy.get('@testUser').then(user => { * cy.request('DELETE', `/api/users/${user.id}`); * }); * } * }, () => { * cy.get('@testUser').then(user => { * cy.visit(`/users/${user.id}`); * cy.get('.user-name').should('contain', user.name); * }); * }); * ``` */ function cytest(name, optionsOrFn, fnOrUndefined) { // Determine if the second parameter is options or a function const options = typeof optionsOrFn === 'function' ? {} : optionsOrFn; const fn = typeof optionsOrFn === 'function' ? optionsOrFn : fnOrUndefined; // Use regular Cypress it() function // Pass tags and other Cypress options to the underlying it function // Create a copy of options excluding cytest-specific properties const { runIf, before, after, tags, ...cypressOptions } = options; // Add tags if they exist const itOptions = tags ? { ...cypressOptions, tags } : cypressOptions; // @ts-ignore return it(name, itOptions, function () { // Check if grepTags is defined and if this test's tags match const grepTags = Cypress.env('grepTags'); if (grepTags && options.tags) { const testTags = Array.isArray(options.tags) ? options.tags : [options.tags]; const requiredTags = Array.isArray(grepTags) ? grepTags : [grepTags]; // Check if any of the test's tags match any of the required tags const hasMatchingTag = testTags.some(tag => requiredTags.some(reqTag => tag === reqTag)); if (!hasMatchingTag) { cy.log(`Skipping test "${name}" because it doesn't have the required tag(s): ${grepTags}`); cy.log('Test skipped'); this.skip(); return; } } // Check if the runIf function exists and evaluates to false if (options.runIf && !options.runIf()) { cy.log(`Skipping test "${name}" because runIf condition is not met`); cy.log('Test skipped'); this.skip(); // Skip this test instead of just returning return; } // Check if any parent tests have failed const parentTests = Object.entries(testDependencies) .filter(([_, dependents]) => dependents.includes(name)) .map(([parent, _]) => parent); const failedParents = parentTests.filter(parent => hasTestFailed(parent)); if (failedParents.length > 0) { const parentTest = failedParents[0]; // Just take the first failed parent for the message cy.log(`Skipping test "${name}" because its dependency "${parentTest}" failed`); // Skip this test instead of just returning cy.log('Test skipped'); this.skip(); return; } // If we get here, run the before hook (if any), then the test function, then the after hook (if any) let testChain = cy.wrap(null, { log: false }); // Run the before hook if it exists if (options.before) { cy.log(`Running before hook for test "${name}"`); testChain = testChain.then(() => options.before.call(this)); } // Run the test function testChain = testChain.then(() => fn.call(this)); // Run the after hook if it exists if (options.after) { cy.log(`Running after hook for test "${name}"`); testChain = testChain.then(() => options.after.call(this)); } return testChain; }); } exports.cytest = cytest; /** * Skip a test * @param name The name of the test * @param optionsOrFn The test options or the test function * @param fnOrUndefined The test function if options are provided * @example * ```javascript * // Skip a test (useful during development or when a test is temporarily broken) * cytest.skip('Feature that is not ready yet', () => { * cy.visit('/feature-in-progress'); * cy.get('.not-implemented-yet').should('be.visible'); * }); * * // Skip a test with options * cytest.skip('Advanced feature test', * { * runIf: () => Cypress.env('ENVIRONMENT') === 'production', * before: () => cy.log('This setup will not run') * }, * () => { * cy.log('This test is skipped'); * } * ); * ``` */ cytest.skip = function (name, optionsOrFn, fnOrUndefined) { // Determine if the second parameter is options or a function const fn = optionsOrFn === undefined ? undefined : typeof optionsOrFn === 'function' ? optionsOrFn : fnOrUndefined; // Determine if options were provided const options = optionsOrFn === undefined ? {} : typeof optionsOrFn === 'function' ? {} : optionsOrFn; // Pass tags and other Cypress options to the underlying it.skip function // Create a copy of options excluding cytest-specific properties const { runIf, before, after, tags, ...cypressOptions } = options; // Add tags if they exist const itOptions = tags ? { ...cypressOptions, tags } : cypressOptions; // For skip, we don't need to check grepTags since the test is already being skipped // @ts-ignore return it.skip(name, itOptions, fn); }; /** * Run only this test * @param name The name of the test * @param optionsOrFn The test options or the test function * @param fnOrUndefined The test function if options are provided * @example * ```javascript * // Run only this test (useful during development or debugging) * cytest.only('Test I am currently working on', () => { * cy.visit('/feature'); * cy.get('.specific-element').should('be.visible'); * }); * * // Run only this test with options * cytest.only('Specific conditional test', * { * runIf: () => Cypress.env('DEBUG_MODE') === true, * before: () => cy.log('Setting up for debugging') * }, * () => { * cy.log('Debugging specific feature'); * cy.visit('/feature-being-debugged'); * cy.get('.debug-element').should('be.visible'); * } * ); * ``` */ cytest.only = function (name, optionsOrFn, fnOrUndefined) { // Determine if the second parameter is options or a function const options = typeof optionsOrFn === 'function' ? {} : optionsOrFn; const fn = typeof optionsOrFn === 'function' ? optionsOrFn : fnOrUndefined; // Pass tags and other Cypress options to the underlying it.only function // Create a copy of options excluding cytest-specific properties const { runIf, before, after, tags, ...cypressOptions } = options; // Add tags if they exist const itOptions = tags ? { ...cypressOptions, tags } : cypressOptions; // @ts-ignore return it.only(name, itOptions, function () { // Check if grepTags is defined and if this test's tags match const grepTags = Cypress.env('grepTags'); if (grepTags && options.tags) { const testTags = Array.isArray(options.tags) ? options.tags : [options.tags]; const requiredTags = Array.isArray(grepTags) ? grepTags : [grepTags]; // Check if any of the test's tags match any of the required tags const hasMatchingTag = testTags.some(tag => requiredTags.some(reqTag => tag === reqTag)); if (!hasMatchingTag) { cy.log(`Skipping test "${name}" because it doesn't have the required tag(s): ${grepTags}`); cy.log('Test skipped'); this.skip(); return; } } // Check if the runIf function exists and evaluates to false if (options.runIf && !options.runIf()) { cy.log(`Skipping test "${name}" because runIf condition is not met`); cy.log('Test skipped'); this.skip(); // Skip this test instead of just returning return; } // Check if any parent tests have failed const parentTests = Object.entries(testDependencies) .filter(([_, dependents]) => dependents.includes(name)) .map(([parent, _]) => parent); const failedParents = parentTests.filter(parent => hasTestFailed(parent)); if (failedParents.length > 0) { const parentTest = failedParents[0]; // Just take the first failed parent for the message cy.log(`Skipping test "${name}" because its dependency "${parentTest}" failed`); // Skip this test instead of just returning cy.log('Test skipped'); this.skip(); return; } // If we get here, run the before hook (if any), then the test function, then the after hook (if any) let testChain = cy.wrap(null, { log: false }); // Run the before hook if it exists if (options.before) { cy.log(`Running before hook for test "${name}"`); testChain = testChain.then(() => options.before.call(this)); } // Run the test function testChain = testChain.then(() => fn.call(this)); // Run the after hook if it exists if (options.after) { cy.log(`Running after hook for test "${name}"`); testChain = testChain.then(() => options.after.call(this)); } return testChain; }); }; // Global beforeEach hook to check if tests should be skipped beforeEach(function () { const currentTest = this.currentTest; if (currentTest) { const testName = currentTest.title; console.log(`[DEBUG] Running beforeEach for test "${testName}"`); console.log(`[DEBUG] Failed tests: ${JSON.stringify(window.failedTests)}`); // Get all parent tests const parentTests = Object.entries(testDependencies) .filter(([_, dependents]) => dependents.includes(testName)) .map(([parent, _]) => parent); console.log(`[DEBUG] Parent tests for "${testName}": ${JSON.stringify(parentTests)}`); // Check if any parent tests have failed const failedParents = parentTests.filter(parent => hasTestFailed(parent)); console.log(`[DEBUG] Failed parent tests for "${testName}": ${JSON.stringify(failedParents)}`); if (failedParents.length > 0) { const parentTest = failedParents[0]; // Just take the first failed parent for the message cy.log(`Skipping test "${testName}" because its dependency "${parentTest}" failed`); this.skip(); // Skip this test } } }); // Hook into Cypress's afterEach to track test results afterEach(function () { const currentTest = this.currentTest; if (currentTest) { const testName = currentTest.title; const passed = currentTest.state === 'passed'; // Log the test result console.log(`[DEBUG] Test "${testName}" completed with state: ${passed ? 'passed' : 'failed'}`); // If this test failed, mark it as failed and log dependent tests if (!passed) { markTestAsFailed(testName); console.log(`[DEBUG] Failed tests after update: ${JSON.stringify(window.failedTests)}`); // Get all direct dependent tests const directDependents = getDependentTests(testName); // Function to recursively get all dependent tests (including nested dependents) function getAllDependents(testName, visited = new Set()) { if (visited.has(testName)) { return []; } visited.add(testName); const directDeps = getDependentTests(testName); const allDeps = [...directDeps]; for (const dep of directDeps) { const nestedDeps = getAllDependents(dep, visited); allDeps.push(...nestedDeps); } return allDeps; } // Get all dependent tests (including nested dependents) const allDependents = getAllDependents(testName); if (allDependents.length > 0) { console.log(`[DEBUG] Test "${testName}" failed. All dependent tests will be skipped: ${allDependents.join(', ')}`); // Mark all dependent tests as failed too, to ensure they're skipped allDependents.forEach(dependent => { console.log(`[DEBUG] Marking dependent test "${dependent}" as failed`); markTestAsFailed(dependent); }); console.log(`[DEBUG] Failed tests after marking all dependents: ${JSON.stringify(window.failedTests)}`); } } } });