diff --git a/docs/els.md b/docs/els.md new file mode 100644 index 000000000..91acc10f3 --- /dev/null +++ b/docs/els.md @@ -0,0 +1,289 @@ +## Element Access + +The `els` module provides low-level element manipulation functions for CodeceptJS tests, allowing for more granular control over element interactions and assertions. However, because element representation differs between frameworks, tests using element functions are not portable between helpers. So if you set to use Playwright you won't be able to witch to WebDriver with one config change in CodeceptJS. + +### Usage + +Import the els functions in your test file: + +```js +const { element, eachElement, expectElement, expectAnyElement, expectAllElements } = require('codeceptjs/els'); +``` + +## element + +The `element` function allows you to perform custom operations on the first matching element found by a locator. It provides a low-level way to interact with elements when the built-in helper methods aren't sufficient. + +### Syntax + +```js +element(purpose, locator, fn); +// or +element(locator, fn); +``` + +### Parameters + +- `purpose` (optional) - A string describing the operation being performed. If omitted, a default purpose will be generated from the function. +- `locator` - A locator string/object to find the element(s). +- `fn` - An async function that receives the element as its argument and performs the desired operation. `el` argument represents an element of an underlying engine used: Playwright, WebDriver, or Puppeteer. + +### Returns + +Returns the result of the provided async function executed on the first matching element. + +### Example + +```js +Scenario('my test', async ({ I }) => { + // combine element function with standard steps: + I.amOnPage('/cart'); + + // but use await every time you use element function + await element( + // with explicit purpose + 'check custom attribute', + '.button', + async el => await el.getAttribute('data-test'), + ); + + // or simply + await element('.button', async el => { + return await el.isEnabled(); + }); +}); +``` + +### Notes + +- Only works with helpers that implement the `_locate` method +- The function will only operate on the first element found, even if multiple elements match the locator +- The provided callback must be an async function +- Throws an error if no helper with `_locate` method is enabled + +## eachElement + +The `eachElement` function allows you to perform operations on each element that matches a locator. It's useful for iterating through multiple elements and performing the same operation on each one. + +### Syntax + +```js +eachElement(purpose, locator, fn); +// or +eachElement(locator, fn); +``` + +### Parameters + +- `purpose` (optional) - A string describing the operation being performed. If omitted, a default purpose will be generated from the function. +- `locator` - A locator string/object to find the element(s). +- `fn` - An async function that receives two arguments: + - `el` - The current element being processed + - `index` - The index of the current element in the collection + +### Returns + +Returns a promise that resolves when all elements have been processed. If any element operation fails, the function will throw the first encountered error. + +### Example + +```js +Scenario('my test', async ({ I }) => { + // combine element function with standard steps: + I.click('/hotels'); + + // iterate over elements but don't forget to put await + await eachElement( + 'validate list items', // explain your actions for future review + '.list-item', // locator + async (el, index) => { + const text = await el.getText(); + console.log(`Item ${index}: ${text}`); + }, + ); + + // Or simply check if all checkboxes are checked + await eachElement('input[type="checkbox"]', async el => { + const isChecked = await el.isSelected(); + if (!isChecked) { + throw new Error('Found unchecked checkbox'); + } + }); +}); +``` + +### Notes + +- Only works with helpers that implement the `_locate` method +- The function will process all elements that match the locator +- The provided callback must be an async function +- If an operation fails on any element, the error is logged and the function continues processing remaining elements +- After all elements are processed, if any errors occurred, the first error is thrown +- Throws an error if no helper with `_locate` method is enabled + +## expectElement + +The `expectElement` function allows you to perform assertions on the first element that matches a locator. It's designed for validating element properties or states and will throw an assertion error if the condition is not met. + +### Syntax + +```js +expectElement(locator, fn); +``` + +### Parameters + +- `locator` - A locator string/object to find the element(s). +- `fn` - An async function that receives the element as its argument and should return a boolean value: + - `true` - The assertion passed + - `false` - The assertion failed + +### Returns + +Returns a promise that resolves when the assertion is complete. Throws an assertion error if the condition is not met. + +### Example + +```js +// Check if a button is enabled +await expectElement('.submit-button', async el => { + return await el.isEnabled(); +}); + +// Verify element has specific text content +await expectElement('.header', async el => { + const text = await el.getText(); + return text === 'Welcome'; +}); + +// Check for specific attribute value +await expectElement('#user-profile', async el => { + const role = await el.getAttribute('role'); + return role === 'button'; +}); +``` + +### Notes + +- Only works with helpers that implement the `_locate` method +- The function will only check the first element found, even if multiple elements match the locator +- The provided callback must be an async function that returns a boolean +- The assertion message will include both the locator and the function used for validation +- Throws an error if no helper with `_locate` method is enabled + +## expectAnyElement + +The `expectAnyElement` function allows you to perform assertions where at least one element from a collection should satisfy the condition. It's useful when you need to verify that at least one element among many matches your criteria. + +### Syntax + +```js +expectAnyElement(locator, fn); +``` + +### Parameters + +- `locator` - A locator string/object to find the element(s). +- `fn` - An async function that receives the element as its argument and should return a boolean value: + - `true` - The assertion passed for this element + - `false` - The assertion failed for this element + +### Returns + +Returns a promise that resolves when the assertion is complete. Throws an assertion error if no elements satisfy the condition. + +### Example + +```js +Scenario('validate any element matches criteria', async ({ I }) => { + // Navigate to the page + I.amOnPage('/products'); + + // Check if any product is marked as "in stock" + await expectAnyElement('.product-item', async el => { + const status = await el.getAttribute('data-status'); + return status === 'in-stock'; + }); + + // Verify at least one price is below $100 + await expectAnyElement('.price-tag', async el => { + const price = await el.getText(); + return parseFloat(price.replace('$', '')) < 100; + }); + + // Check if any button in the list is enabled + await expectAnyElement('.action-button', async el => { + return await el.isEnabled(); + }); +}); +``` + +### Notes + +- Only works with helpers that implement the `_locate` method +- The function will check all matching elements until it finds one that satisfies the condition +- Stops checking elements once the first matching condition is found +- The provided callback must be an async function that returns a boolean +- Throws an assertion error if no elements satisfy the condition +- Throws an error if no helper with `_locate` method is enabled + +## expectAllElements + +The `expectAllElements` function verifies that every element matching the locator satisfies the given condition. It's useful when you need to ensure that all elements in a collection meet specific criteria. + +### Syntax + +```js +expectAllElements(locator, fn); +``` + +### Parameters + +- `locator` - A locator string/object to find the element(s). +- `fn` - An async function that receives the element as its argument and should return a boolean value: + - `true` - The assertion passed for this element + - `false` - The assertion failed for this element + +### Returns + +Returns a promise that resolves when all assertions are complete. Throws an assertion error as soon as any element fails the condition. + +### Example + +```js +Scenario('validate all elements meet criteria', async ({ I }) => { + // Navigate to the page + I.amOnPage('/dashboard'); + + // Verify all required fields have the required attribute + await expectAllElements('.required-field', async el => { + const required = await el.getAttribute('required'); + return required !== null; + }); + + // Check if all checkboxes in a form are checked + await expectAllElements('input[type="checkbox"]', async el => { + return await el.isSelected(); + }); + + // Verify all items in a list have non-empty text + await expectAllElements('.list-item', async el => { + const text = await el.getText(); + return text.trim().length > 0; + }); + + // Ensure all buttons in a section are enabled + await expectAllElements('#action-section button', async el => { + return await el.isEnabled(); + }); +}); +``` + +### Notes + +- Only works with helpers that implement the `_locate` method +- The function checks every element that matches the locator +- Fails fast: stops checking elements as soon as one fails the condition +- The provided callback must be an async function that returns a boolean +- The assertion message will include which element number failed (e.g., "element #2 of...") +- Throws an error if no helper with `_locate` method is enabled diff --git a/lib/els.js b/lib/els.js new file mode 100644 index 000000000..31242a969 --- /dev/null +++ b/lib/els.js @@ -0,0 +1,177 @@ +const output = require('./output'); +const store = require('./store'); +const recorder = require('./recorder'); +const container = require('./container'); +const event = require('./event'); +const Step = require('./step'); +const { truth } = require('./assert/truth'); +const { isAsyncFunction, humanizeFunction } = require('./utils'); + +function element(purpose, locator, fn) { + if (!fn) { + fn = locator; + locator = purpose; + purpose = 'first element'; + } + + const step = prepareStep(purpose, locator, fn); + if (!step) return; + + return executeStep(step, async () => { + const els = await step.helper._locate(locator); + output.debug(`Found ${els.length} elements, using first element`); + + return fn(els[0]); + }); +} + +function eachElement(purpose, locator, fn) { + if (!fn) { + fn = locator; + locator = purpose; + purpose = 'for each element'; + } + + const step = prepareStep(purpose, locator, fn); + if (!step) return; + + return executeStep(step, async () => { + const els = await step.helper._locate(locator); + output.debug(`Found ${els.length} elements for each elements to iterate`); + + const errs = []; + let i = 0; + for (const el of els) { + try { + await fn(el, i); + } catch (err) { + output.error(`eachElement: failed operation on element #${i} ${el}`); + errs.push(err); + } + i++; + } + + if (errs.length) { + throw errs[0]; + } + }); +} + +function expectElement(locator, fn) { + const step = prepareStep('expect element to be', locator, fn); + if (!step) return; + + return executeStep(step, async () => { + const els = await step.helper._locate(locator); + output.debug(`Found ${els.length} elements, first will be used for assertion`); + + const result = await fn(els[0]); + const assertion = truth(`element (${locator})`, fn.toString()); + assertion.assert(result); + }); +} + +function expectAnyElement(locator, fn) { + const step = prepareStep('expect any element to be', locator, fn); + if (!step) return; + + return executeStep(step, async () => { + const els = await step.helper._locate(locator); + output.debug(`Found ${els.length} elements, at least one should pass the assertion`); + + const assertion = truth(`any element of (${locator})`, fn.toString()); + + let found = false; + for (const el of els) { + const result = await fn(el); + if (result) { + found = true; + break; + } + } + if (!found) throw assertion.getException(); + }); +} + +function expectAllElements(locator, fn) { + const step = prepareStep('expect all elements', locator, fn); + if (!step) return; + + return executeStep(step, async () => { + const els = await step.helper._locate(locator); + output.debug(`Found ${els.length} elements, all should pass the assertion`); + + let i = 1; + for (const el of els) { + output.debug(`checking element #${i}: ${el}`); + const result = await fn(el); + const assertion = truth(`element #${i} of (${locator})`, humanizeFunction(fn)); + assertion.assert(result); + i++; + } + }); +} + +module.exports = { + element, + eachElement, + expectElement, + expectAnyElement, + expectAllElements, +}; + +function prepareStep(purpose, locator, fn) { + if (store.dryRun) return; + const helpers = Object.values(container.helpers()); + + const helper = helpers.filter(h => !!h._locate)[0]; + + if (!helper) { + throw new Error('No helper enabled with _locate method with returns a list of elements.'); + } + + if (!isAsyncFunction(fn)) { + throw new Error('Async function should be passed into each element'); + } + + const isAssertion = purpose.startsWith('expect'); + + const step = new Step(helper, `${purpose} within "${locator}" ${isAssertion ? 'to be' : 'to'}`); + step.setActor('EL'); + step.setArguments([humanizeFunction(fn)]); + step.helperMethod = '_locate'; + + return step; +} + +async function executeStep(step, action) { + let error; + const promise = recorder.add('register element wrapper', async () => { + event.emit(event.step.started, step); + + try { + await action(); + } catch (err) { + recorder.throw(err); + event.emit(event.step.failed, step, err); + event.emit(event.step.finished, step); + // event.emit(event.step.after, step) + error = err; + // await recorder.promise(); + return; + } + + event.emit(event.step.after, step); + event.emit(event.step.passed, step); + event.emit(event.step.finished, step); + }); + + // await recorder.promise(); + + // if (error) { + // console.log('error', error.inspect()) + // return recorder.throw(error); + // } + + return promise; +} diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index 6f9b7e132..6eeb8098b 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -7,6 +7,7 @@ const assert = require('assert') const promiseRetry = require('promise-retry') const Locator = require('../locator') const recorder = require('../recorder') +const store = require('../store') const stringIncludes = require('../assert/include').includes const { urlEquals } = require('../assert/equal') const { equals } = require('../assert/equal') @@ -1249,7 +1250,36 @@ class Playwright extends Helper { if (this.frame) return findElements(this.frame, locator) - return findElements(context, locator) + const els = await findElements(context, locator) + + if (store.debugMode) { + const previewElements = els.slice(0, 3) + let htmls = await Promise.all( + previewElements.map(async el => { + const html = await el.evaluate(node => node.outerHTML) + return ( + html + .replace(/\n/g, '') + .replace(/\s+/g, ' ') + .substring(0, 100 / previewElements.length) + .trim() + '...' + ) + }), + ) + if (els.length > 3) { + htmls.push('...') + } + + if (els.length > 1) { + this.debugSection(`Elements (${els.length})`, htmls.join('|').trim()) + } else if (els.length === 1) { + this.debugSection('Element', htmls.join('|').trim()) + } else { + this.debug('No elements found') + } + } + + return els } /** diff --git a/lib/output.js b/lib/output.js index 70e9909ab..145dc9d82 100644 --- a/lib/output.js +++ b/lib/output.js @@ -114,10 +114,10 @@ module.exports = { } } - let stepLine = step.toString(); + let stepLine = step.toCliStyled(); if (step.metaStep && outputLevel >= 1) { // this.stepShift += 2; - stepLine = colors.green(truncate(stepLine, this.spaceShift)); + stepLine = colors.dim(truncate(stepLine, this.spaceShift)); } if (step.comment) { stepLine += colors.grey(step.comment.split('\n').join('\n' + ' '.repeat(4))); diff --git a/lib/step.js b/lib/step.js index 33865d71c..51f1ff49b 100644 --- a/lib/step.js +++ b/lib/step.js @@ -1,10 +1,12 @@ // TODO: place MetaStep in other file, disable rule -const store = require('./store'); -const Secret = require('./secret'); -const event = require('./event'); +const color = require('chalk') +const store = require('./store') +const Secret = require('./secret') +const event = require('./event') +const { ucfirst } = require('./utils') -const STACK_LINE = 4; +const STACK_LINE = 4 /** * Each command in test executed through `I.` object is wrapped in Step. @@ -31,42 +33,42 @@ class Step { * 20-29 - designated for timeout settings which could be overriden in tests code, 25 is used by stepTimeout plugin when stepTimeout.config.overrideStepLimits=false */ stepTimeoutSoft: 25, - }; + } } constructor(helper, name) { /** @member {string} */ - this.actor = 'I'; // I = actor + this.actor = 'I' // I = actor /** @member {CodeceptJS.Helper} */ - this.helper = helper; // corresponding helper + this.helper = helper // corresponding helper /** @member {string} */ - this.name = name; // name of a step console + this.name = name // name of a step console /** @member {string} */ - this.helperMethod = name; // helper method + this.helperMethod = name // helper method /** @member {string} */ - this.status = 'pending'; + this.status = 'pending' /** * @member {string} suffix * @memberof CodeceptJS.Step# */ /** @member {string} */ - this.prefix = this.suffix = ''; + this.prefix = this.suffix = '' /** @member {string} */ - this.comment = ''; + this.comment = '' /** @member {Array<*>} */ - this.args = []; + this.args = [] /** @member {MetaStep} */ - this.metaStep = undefined; + this.metaStep = undefined /** @member {string} */ - this.stack = ''; + this.stack = '' - const timeouts = new Map(); + const timeouts = new Map() /** * @method * @returns {number|undefined} */ this.getTimeout = function () { - let totalTimeout; + let totalTimeout // iterate over all timeouts starting from highest values of order new Map([...timeouts.entries()].sort().reverse()).forEach((timeout, order) => { if ( @@ -78,11 +80,11 @@ class Step { // when `order < 0` - timeout overrides higher values of timeout or 'no timeout' (totalTimeout === 0) set by elements with higher order (timeout > 0 && (timeout < totalTimeout || totalTimeout === 0))) ) { - totalTimeout = timeout; + totalTimeout = timeout } - }); - return totalTimeout; - }; + }) + return totalTimeout + } /** * @method * @param {number} timeout - timeout in milliseconds or 0 if no timeout @@ -90,20 +92,20 @@ class Step { * When order below 0 value of timeout only override if new value is lower */ this.setTimeout = function (timeout, order) { - timeouts.set(order, timeout); - }; + timeouts.set(order, timeout) + } - this.setTrace(); + this.setTrace() } /** @function */ setTrace() { - Error.captureStackTrace(this); + Error.captureStackTrace(this) } /** @param {Array<*>} args */ setArguments(args) { - this.args = args; + this.args = args } /** @@ -111,35 +113,39 @@ class Step { * @return {*} */ run() { - this.args = Array.prototype.slice.call(arguments); + this.args = Array.prototype.slice.call(arguments) if (store.dryRun) { - this.setStatus('success'); - return Promise.resolve(new Proxy({}, dryRunResolver())); + this.setStatus('success') + return Promise.resolve(new Proxy({}, dryRunResolver())) } - let result; + let result try { if (this.helperMethod !== 'say') { - result = this.helper[this.helperMethod].apply(this.helper, this.args); + result = this.helper[this.helperMethod].apply(this.helper, this.args) } - this.setStatus('success'); + this.setStatus('success') } catch (err) { - this.setStatus('failed'); - throw err; + this.setStatus('failed') + throw err } - return result; + return result + } + + setActor(actor) { + this.actor = actor || '' } /** @param {string} status */ setStatus(status) { - this.status = status; + this.status = status if (this.metaStep) { - this.metaStep.setStatus(status); + this.metaStep.setStatus(status) } } /** @return {string} */ humanize() { - return humanizeString(this.name); + return humanizeString(this.name) } /** @return {string} */ @@ -147,185 +153,188 @@ class Step { return this.args .map(arg => { if (!arg) { - return ''; + return '' } if (typeof arg === 'string') { - return `"${arg}"`; + return `"${arg}"` } if (Array.isArray(arg)) { try { - const res = JSON.stringify(arg); - return res; + const res = JSON.stringify(arg) + return res } catch (err) { - return `[${arg.toString()}]`; + return `[${arg.toString()}]` } } else if (typeof arg === 'function') { - return arg.toString(); + return arg.toString() } else if (typeof arg === 'undefined') { - return `${arg}`; + return `${arg}` } else if (arg instanceof Secret) { - return arg.getMasked(); + return arg.getMasked() } else if (arg.toString && arg.toString() !== '[object Object]') { - return arg.toString(); + return arg.toString() } else if (typeof arg === 'object') { - const returnedArg = {}; + const returnedArg = {} for (const [key, value] of Object.entries(arg)) { - returnedArg[key] = value; - if (value instanceof Secret) returnedArg[key] = value.getMasked(); + returnedArg[key] = value + if (value instanceof Secret) returnedArg[key] = value.getMasked() } - return JSON.stringify(returnedArg); + return JSON.stringify(returnedArg) } - return arg; + return arg }) - .join(', '); + .join(', ') } /** @return {string} */ line() { - const lines = this.stack.split('\n'); + const lines = this.stack.split('\n') if (lines[STACK_LINE]) { return lines[STACK_LINE].trim() .replace(global.codecept_dir || '', '.') - .trim(); + .trim() } - return ''; + return '' } /** @return {string} */ toString() { - return `${this.prefix}${this.actor} ${this.humanize()} ${this.humanizeArgs()}${this.suffix}`; + return ucfirst(`${this.prefix}${this.actor} ${this.humanize()} ${this.humanizeArgs()}${this.suffix}`).trim() + } + + /** @return {string} */ + toCliStyled() { + return `${this.prefix}${this.actor} ${color.italic(this.humanize())} ${color.yellow(this.humanizeArgs())}${this.suffix}` } /** @return {string} */ toCode() { - return `${this.prefix}${this.actor}.${this.name}(${this.humanizeArgs()})${this.suffix}`; + return `${this.prefix}${this.actor}.${this.name}(${this.humanizeArgs()})${this.suffix}` } isMetaStep() { - return this.constructor.name === 'MetaStep'; + return this.constructor.name === 'MetaStep' } /** @return {boolean} */ hasBDDAncestor() { - let hasBDD = false; - let processingStep; - processingStep = this; + let hasBDD = false + let processingStep + processingStep = this while (processingStep.metaStep) { if (processingStep.metaStep.actor.match(/^(Given|When|Then|And)/)) { - hasBDD = true; - break; + hasBDD = true + break } else { - processingStep = processingStep.metaStep; + processingStep = processingStep.metaStep } } - return hasBDD; + return hasBDD } } /** @extends Step */ class MetaStep extends Step { constructor(obj, method) { - super(null, method); - this.actor = obj; + if (!method) method = '' + super(null, method) + this.actor = obj } /** @return {boolean} */ isBDD() { if (this.actor && this.actor.match && this.actor.match(/^(Given|When|Then|And)/)) { - return true; + return true } - return false; + return false } - isWithin() { - if (this.actor && this.actor.match && this.actor.match(/^(Within)/)) { - return true; - } - return false; + toCliStyled() { + return this.toString() } toString() { - const actorText = this.actor; + const actorText = this.actor - if (this.isBDD() || this.isWithin()) { - return `${this.prefix}${actorText} ${this.name} "${this.humanizeArgs()}${this.suffix}"`; + if (this.isBDD()) { + return `${this.prefix}${actorText} ${this.name} "${this.humanizeArgs()}${this.suffix}"` } if (actorText === 'I') { - return `${this.prefix}${actorText} ${this.humanize()} ${this.humanizeArgs()}${this.suffix}`; + return `${this.prefix}${actorText} ${this.humanize()} ${this.humanizeArgs()}${this.suffix}` } - return `On ${this.prefix}${actorText}: ${this.humanize()} ${this.humanizeArgs()}${this.suffix}`; + return `On ${this.prefix}${actorText}: ${this.humanize()} ${this.humanizeArgs()}${this.suffix}` } humanize() { - return humanizeString(this.name); + return humanizeString(this.name) } setTrace() {} setContext(context) { - this.context = context; + this.context = context } /** @return {*} */ run(fn) { - this.status = 'queued'; - this.setArguments(Array.from(arguments).slice(1)); - let result; + this.status = 'queued' + this.setArguments(Array.from(arguments).slice(1)) + let result const registerStep = step => { - this.metaStep = null; - step.metaStep = this; - }; - event.dispatcher.prependListener(event.step.before, registerStep); + this.metaStep = null + step.metaStep = this + } + event.dispatcher.prependListener(event.step.before, registerStep) // Handle async and sync methods. if (fn.constructor.name === 'AsyncFunction') { result = fn .apply(this.context, this.args) .then(result => { - return result; + return result }) .catch(error => { - this.setStatus('failed'); - throw error; + this.setStatus('failed') + throw error }) .finally(() => { - this.endTime = Date.now(); - event.dispatcher.removeListener(event.step.before, registerStep); - }); + this.endTime = Date.now() + event.dispatcher.removeListener(event.step.before, registerStep) + }) } else { try { - this.startTime = Date.now(); - result = fn.apply(this.context, this.args); + this.startTime = Date.now() + result = fn.apply(this.context, this.args) } catch (error) { - this.setStatus('failed'); - throw error; + this.setStatus('failed') + throw error } finally { - this.endTime = Date.now(); - event.dispatcher.removeListener(event.step.before, registerStep); + this.endTime = Date.now() + event.dispatcher.removeListener(event.step.before, registerStep) } } - return result; + return result } } -Step.TIMEOUTS = {}; +Step.TIMEOUTS = {} /** @type {Class} */ -Step.MetaStep = MetaStep; +Step.MetaStep = MetaStep -module.exports = Step; +module.exports = Step function dryRunResolver() { return { get(target, prop) { - if (prop === 'toString') return () => ''; - return new Proxy({}, dryRunResolver()); + if (prop === 'toString') return () => '' + return new Proxy({}, dryRunResolver()) }, - }; + } } function humanizeString(string) { @@ -333,12 +342,12 @@ function humanizeString(string) { const _result = string .replace(/([a-z](?=[A-Z]))/g, '$1 ') .split(' ') - .map(word => word.toLowerCase()); + .map(word => word.toLowerCase()) - _result[0] = _result[0] === 'i' ? capitalizeFLetter(_result[0]) : _result[0]; - return _result.join(' ').trim(); + _result[0] = _result[0] === 'i' ? capitalizeFLetter(_result[0]) : _result[0] + return _result.join(' ').trim() } function capitalizeFLetter(string) { - return string[0].toUpperCase() + string.slice(1); + return string[0].toUpperCase() + string.slice(1) } diff --git a/lib/utils.js b/lib/utils.js index fc73a5afb..d52491f1d 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -10,9 +10,8 @@ function deepMerge(target, source) { return merge(target, source); } -module.exports.genTestId = (test) => { - return require('crypto').createHash('sha256').update(test.fullTitle()).digest('base64') - .slice(0, -2); +module.exports.genTestId = test => { + return require('crypto').createHash('sha256').update(test.fullTitle()).digest('base64').slice(0, -2); }; module.exports.deepMerge = deepMerge; @@ -23,14 +22,14 @@ module.exports.isGenerator = function (fn) { return fn.constructor.name === 'GeneratorFunction'; }; -const isFunction = module.exports.isFunction = function (fn) { +const isFunction = (module.exports.isFunction = function (fn) { return typeof fn === 'function'; -}; +}); -const isAsyncFunction = module.exports.isAsyncFunction = function (fn) { +const isAsyncFunction = (module.exports.isAsyncFunction = function (fn) { if (!fn) return false; return fn[Symbol.toStringTag] === 'AsyncFunction'; -}; +}); module.exports.fileExists = function (filePath) { return fs.existsSync(filePath); @@ -59,23 +58,13 @@ module.exports.installedLocally = function () { module.exports.methodsOfObject = function (obj, className) { const methods = []; - const standard = [ - 'constructor', - 'toString', - 'toLocaleString', - 'valueOf', - 'hasOwnProperty', - 'bind', - 'apply', - 'call', - 'isPrototypeOf', - 'propertyIsEnumerable', - ]; + const standard = ['constructor', 'toString', 'toLocaleString', 'valueOf', 'hasOwnProperty', 'bind', 'apply', 'call', 'isPrototypeOf', 'propertyIsEnumerable']; function pushToMethods(prop) { try { if (!isFunction(obj[prop]) && !isAsyncFunction(obj[prop])) return; - } catch (err) { // can't access property + } catch (err) { + // can't access property return; } if (standard.indexOf(prop) >= 0) return; @@ -160,9 +149,12 @@ module.exports.xpathLocator = { * @param {string} string * @returns {string} */ - literal: (string) => { + literal: string => { if (string.indexOf("'") > -1) { - string = string.split("'", -1).map(substr => `'${substr}'`).join(',"\'",'); + string = string + .split("'", -1) + .map(substr => `'${substr}'`) + .join(',"\'",'); return `concat(${string})`; } return `'${string}'`; @@ -177,7 +169,6 @@ module.exports.xpathLocator = { }; module.exports.test = { - grepLines(array, startString, endString) { let startIndex = 0; let endIndex; @@ -211,7 +202,6 @@ module.exports.test = { return data; }; }, - }; function toCamelCase(name) { @@ -255,7 +245,7 @@ function isFontWeightProperty(prop) { module.exports.convertCssPropertiesToCamelCase = function (props) { const output = {}; - Object.keys(props).forEach((key) => { + Object.keys(props).forEach(key => { const keyCamel = toCamelCase(key); if (isFontWeightProperty(keyCamel)) { @@ -366,10 +356,7 @@ module.exports.replaceValueDeep = function replaceValueDeep(obj, key, value) { }; module.exports.ansiRegExp = function ({ onlyFirst = false } = {}) { - const pattern = [ - '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)', - '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))', - ].join('|'); + const pattern = ['[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)', '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))'].join('|'); return new RegExp(pattern, onlyFirst ? undefined : 'g'); }; @@ -418,12 +405,7 @@ module.exports.getNormalizedKeyAttributeValue = function (key) { return key; }; -const modifierKeys = [ - 'Alt', 'AltGraph', 'AltLeft', 'AltRight', - 'Control', 'ControlLeft', 'ControlRight', - 'Meta', 'MetaLeft', 'MetaRight', - 'Shift', 'ShiftLeft', 'ShiftRight', -]; +const modifierKeys = ['Alt', 'AltGraph', 'AltLeft', 'AltRight', 'Control', 'ControlLeft', 'ControlRight', 'Meta', 'MetaLeft', 'MetaRight', 'Shift', 'ShiftLeft', 'ShiftRight']; module.exports.modifierKeys = modifierKeys; module.exports.isModifierKey = function (key) { @@ -456,11 +438,11 @@ module.exports.isNotSet = function (obj) { return false; }; -module.exports.emptyFolder = async (directoryPath) => { +module.exports.emptyFolder = async directoryPath => { require('child_process').execSync(`rm -rf ${directoryPath}/*`); }; -module.exports.printObjectProperties = (obj) => { +module.exports.printObjectProperties = obj => { if (typeof obj !== 'object' || obj === null) { return obj; } @@ -473,6 +455,28 @@ module.exports.printObjectProperties = (obj) => { return `{${result}}`; }; -module.exports.normalizeSpacesInString = (string) => { +module.exports.normalizeSpacesInString = string => { return string.replace(/\s+/g, ' '); }; + +module.exports.humanizeFunction = function (fn) { + const fnStr = fn.toString().trim(); + // Remove arrow function syntax, async, and parentheses + let simplified = fnStr + .replace(/^async\s*/, '') + .replace(/^\([^)]*\)\s*=>/, '') + .replace(/^function\s*\([^)]*\)/, '') + // Remove curly braces and any whitespace around them + .replace(/{\s*(.*)\s*}/, '$1') + // Remove return statement + .replace(/return\s+/, '') + // Remove trailing semicolon + .replace(/;$/, '') + .trim(); + + if (simplified.length > 100) { + simplified = simplified.slice(0, 97) + '...'; + } + + return simplified; +}; diff --git a/lib/within.js b/lib/within.js index d0e651668..8893b4c74 100644 --- a/lib/within.js +++ b/lib/within.js @@ -1,10 +1,10 @@ -const output = require('./output'); -const store = require('./store'); -const recorder = require('./recorder'); -const container = require('./container'); -const event = require('./event'); -const Step = require('./step'); -const { isAsyncFunction } = require('./utils'); +const output = require('./output') +const store = require('./store') +const recorder = require('./recorder') +const container = require('./container') +const event = require('./event') +const Step = require('./step') +const { isAsyncFunction } = require('./utils') /** * @param {CodeceptJS.LocatorOrString} context @@ -12,59 +12,77 @@ const { isAsyncFunction } = require('./utils'); * @return {Promise<*> | undefined} */ function within(context, fn) { - const helpers = store.dryRun ? {} : container.helpers(); - const locator = typeof context === 'object' ? JSON.stringify(context) : context; + const helpers = store.dryRun ? {} : container.helpers() + const locator = typeof context === 'object' ? JSON.stringify(context) : context - return recorder.add('register within wrapper', () => { - const metaStep = new Step.MetaStep('Within', `"${locator}"`); - const defineMetaStep = step => step.metaStep = metaStep; - recorder.session.start('within'); + return recorder.add( + 'register within wrapper', + () => { + const metaStep = new WithinStep(locator, fn) + const defineMetaStep = step => (step.metaStep = metaStep) + recorder.session.start('within') - event.dispatcher.prependListener(event.step.before, defineMetaStep); + event.dispatcher.prependListener(event.step.before, defineMetaStep) - Object.keys(helpers).forEach((helper) => { - if (helpers[helper]._withinBegin) recorder.add(`[${helper}] start within`, () => helpers[helper]._withinBegin(context)); - }); + Object.keys(helpers).forEach(helper => { + if (helpers[helper]._withinBegin) recorder.add(`[${helper}] start within`, () => helpers[helper]._withinBegin(context)) + }) - const finalize = () => { - event.dispatcher.removeListener(event.step.before, defineMetaStep); - recorder.add('Finalize session within session', () => { - output.stepShift = 1; - recorder.session.restore('within'); - }); - }; - const finishHelpers = () => { - Object.keys(helpers).forEach((helper) => { - if (helpers[helper]._withinEnd) recorder.add(`[${helper}] finish within`, () => helpers[helper]._withinEnd()); - }); - }; + const finalize = () => { + event.dispatcher.removeListener(event.step.before, defineMetaStep) + recorder.add('Finalize session within session', () => { + output.stepShift = 1 + recorder.session.restore('within') + }) + } + const finishHelpers = () => { + Object.keys(helpers).forEach(helper => { + if (helpers[helper]._withinEnd) recorder.add(`[${helper}] finish within`, () => helpers[helper]._withinEnd()) + }) + } - if (isAsyncFunction(fn)) { - return fn().then((res) => { - finishHelpers(); - finalize(); - return recorder.promise().then(() => res); - }).catch((e) => { - finalize(); - recorder.throw(e); - }); - } + if (isAsyncFunction(fn)) { + return fn() + .then(res => { + finishHelpers() + finalize() + return recorder.promise().then(() => res) + }) + .catch(e => { + finalize() + recorder.throw(e) + }) + } - let res; - try { - res = fn(); - } catch (err) { - recorder.throw(err); - } finally { - finishHelpers(); - recorder.catch((err) => { - output.stepShift = 1; - throw err; - }); - } - finalize(); - return recorder.promise().then(() => res); - }, false, false); + let res + try { + res = fn() + } catch (err) { + recorder.throw(err) + } finally { + finishHelpers() + recorder.catch(err => { + output.stepShift = 1 + throw err + }) + } + finalize() + return recorder.promise().then(() => res) + }, + false, + false, + ) } -module.exports = within; +module.exports = within + +class WithinStep extends Step.MetaStep { + constructor(locator, fn) { + super('Within') + this.args = [locator] + } + + toString() { + return `${this.prefix}Within ${this.humanizeArgs()}${this.suffix}` + } +} diff --git a/package.json b/package.json index 8f9a38c22..23b6fc6e4 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,10 @@ "docs/webapi/**" ], "main": "lib/index.js", + "exports": { + ".": "./lib/index.js", + "./els": "./lib/els.js" + }, "types": "typings/index.d.ts", "bin": { "codeceptjs": "./bin/codecept.js" @@ -123,7 +127,7 @@ "@types/chai": "4.3.19", "@types/inquirer": "9.0.7", "@types/node": "22.10.5", - "@wdio/sauce-service": "9.5.0", + "@wdio/sauce-service": "9.5.1", "@wdio/selenium-standalone-service": "8.15.0", "@wdio/utils": "9.5.0", "@xmldom/xmldom": "0.9.6", diff --git a/test/runner/within_test.js b/test/runner/within_test.js index 25e2283bb..5bd947e91 100644 --- a/test/runner/within_test.js +++ b/test/runner/within_test.js @@ -23,7 +23,7 @@ describe('CodeceptJS within', function () { testStatus = withoutGeneratorList.pop() testStatus.should.include('OK') withoutGeneratorList.should.eql( - ['I small promise ', 'I small promise was finished ', 'I hey! i am within begin. i get blabla ', 'Within "blabla" ""', 'I small promise ', 'I small promise was finished ', 'I oh! i am within end( '], + ['I small promise ', 'I small promise was finished ', 'I hey! i am within begin. i get blabla ', 'Within "blabla"', 'I small promise ', 'I small promise was finished ', 'I oh! i am within end( '], 'check steps execution order', ) done() @@ -44,7 +44,7 @@ describe('CodeceptJS within', function () { 'I small yield ', 'I am small yield string await', 'I hey! i am within begin. i get blabla ', - 'Within "blabla" ""', + 'Within "blabla"', 'I small yield ', 'I am small yield string await', 'I small promise ', @@ -72,7 +72,7 @@ describe('CodeceptJS within', function () { 'I small yield ', 'I am small yield string await', 'I hey! i am within begin. i get blabla ', - 'Within "blabla" ""', + 'Within "blabla"', 'I small promise ', 'I small promise was finished ', 'I small yield ', diff --git a/test/unit/els_test.js b/test/unit/els_test.js new file mode 100644 index 000000000..2b128f778 --- /dev/null +++ b/test/unit/els_test.js @@ -0,0 +1,178 @@ +const assert = require('assert'); +const { expect } = require('chai'); +const els = require('../../lib/els'); +const recorder = require('../../lib/recorder'); +const Container = require('../../lib/container'); +const Helper = require('../../lib/helper'); + +class TestHelper extends Helper { + constructor() { + super(); + this.elements = []; + } + + async _locate(locator) { + return this.elements; + } +} + +describe('els', () => { + let helper; + + beforeEach(() => { + helper = new TestHelper(); + Container.clear(); + Container.append({ + helpers: { + test: helper, + }, + }); + recorder.reset(); + recorder.startUnlessRunning(); + }); + + describe('#element', () => { + it('should execute function on first found element', async () => { + helper.elements = ['el1', 'el2', 'el3']; + let elementUsed; + + await els.element('my test', '.selector', async el => { + elementUsed = el; + }); + + await recorder.promise(); + + assert.equal(elementUsed, 'el1'); + }); + + it('should work without purpose parameter', async () => { + helper.elements = ['el1', 'el2']; + let elementUsed; + + await els.element('.selector', async el => { + elementUsed = el; + }); + + assert.equal(elementUsed, 'el1'); + }); + + it('should throw error when no helper with _locate available', async () => { + Container.clear(); + try { + await els.element('.selector', async () => {}); + throw new Error('should have thrown error'); + } catch (e) { + expect(e.message).to.include('No helper enabled with _locate method'); + } + }); + }); + + describe('#eachElement', () => { + it('should execute function on each element', async () => { + helper.elements = ['el1', 'el2', 'el3']; + const usedElements = []; + + await els.eachElement('.selector', async el => { + usedElements.push(el); + }); + + assert.deepEqual(usedElements, ['el1', 'el2', 'el3']); + }); + + it('should provide index as second parameter', async () => { + helper.elements = ['el1', 'el2']; + const indices = []; + + await els.eachElement('.selector', async (el, i) => { + indices.push(i); + }); + + assert.deepEqual(indices, [0, 1]); + }); + + it('should work without purpose parameter', async () => { + helper.elements = ['el1', 'el2']; + const usedElements = []; + + await els.eachElement('.selector', async el => { + usedElements.push(el); + }); + + assert.deepEqual(usedElements, ['el1', 'el2']); + }); + + it('should throw first error if operation fails', async () => { + helper.elements = ['el1', 'el2']; + + try { + await els.eachElement('.selector', async el => { + throw new Error(`failed on ${el}`); + }); + await recorder.promise(); + throw new Error('should have thrown error'); + } catch (e) { + expect(e.message).to.equal('failed on el1'); + } + }); + }); + + describe('#expectElement', () => { + it('should pass when condition is true', async () => { + helper.elements = ['el1']; + + await els.expectElement('.selector', async () => true); + }); + + it('should fail when condition is false', async () => { + helper.elements = ['el1']; + + try { + await els.expectElement('.selector', async () => false); + await recorder.promise(); + throw new Error('should have thrown error'); + } catch (e) { + expect(e.cliMessage()).to.include('element (.selector)'); + } + }); + }); + + describe('#expectAnyElement', () => { + it('should pass when any element matches condition', async () => { + helper.elements = ['el1', 'el2', 'el3']; + + await els.expectAnyElement('.selector', async el => el === 'el2'); + }); + + it('should fail when no element matches condition', async () => { + helper.elements = ['el1', 'el2']; + + try { + await els.expectAnyElement('.selector', async () => false); + await recorder.promise(); + throw new Error('should have thrown error'); + } catch (e) { + expect(e.cliMessage()).to.include('any element of (.selector)'); + } + }); + }); + + describe('#expectAllElements', () => { + it('should pass when all elements match condition', async () => { + helper.elements = ['el1', 'el2']; + + await els.expectAllElements('.selector', async () => true); + }); + + it('should fail when any element does not match condition', async () => { + helper.elements = ['el1', 'el2', 'el3']; + + try { + await els.expectAllElements('.selector', async el => el !== 'el2'); + await recorder.promise(); + throw new Error('should have thrown error'); + } catch (e) { + expect(e.cliMessage()).to.include('element #2 of (.selector)'); + } + }); + }); +}); diff --git a/test/unit/steps_test.js b/test/unit/steps_test.js index 3c5a71f62..67eb68544 100644 --- a/test/unit/steps_test.js +++ b/test/unit/steps_test.js @@ -100,11 +100,6 @@ describe('Steps', () => { }) }) - it('#isWithin should return true if it Within step', () => { - const metaStep = new MetaStep('Within', 'clickByName') - expect(metaStep.isWithin()).to.be.true - }) - describe('#toString', () => { ;['Given', 'When', 'Then', 'And'].forEach(key => { it(`[${key}] should correct print BDD step`, () => {