diff --git a/lib/event.js b/lib/event.js index d664fe846..208c6c047 100644 --- a/lib/event.js +++ b/lib/event.js @@ -131,7 +131,7 @@ module.exports = { /** * @param {string} event - * @param {*} param + * @param {*} [param] */ emit(event, param) { let msg = `Emitted | ${event}`; diff --git a/lib/locator.js b/lib/locator.js index f092f635a..0d7165fb7 100644 --- a/lib/locator.js +++ b/lib/locator.js @@ -7,14 +7,19 @@ const locatorTypes = ['css', 'by', 'xpath', 'id', 'name', 'fuzzy', 'frame', 'sha /** @class */ class Locator { /** - * @param {CodeceptJS.LocatorOrString} locator - * @param {string} [defaultType] + * @param {CodeceptJS.LocatorOrString} locator + * @param {string} [defaultType] */ constructor(locator, defaultType = '') { this.type = null; if (!locator) return; this.output = null; + + /** + * @private + * @type {boolean} + */ this.strict = false; if (typeof locator === 'object') { @@ -76,61 +81,89 @@ class Locator { } /** - * @return {string} + * @returns {string} */ toString() { return this.output || `{${this.type}: ${this.value}}`; } + /** + * @returns {boolean} + */ isFuzzy() { return this.type === 'fuzzy'; } + /** + * @returns {boolean} + */ isShadow() { return this.type === 'shadow'; } + /** + * @returns {boolean} + */ isFrame() { return this.type === 'frame'; } + /** + * @returns {boolean} + */ isCSS() { return this.type === 'css'; } + /** + * @returns {boolean} + */ isNull() { return this.type === null; } + /** + * @returns {boolean} + */ isXPath() { return this.type === 'xpath'; } + /** + * @returns {boolean} + */ isCustom() { - return this.type && !locatorTypes.includes(this.type); + return !!this.type && !locatorTypes.includes(this.type); } + /** + * @returns {boolean} + */ isStrict() { return this.strict; } + /** + * @returns {boolean} + */ isAccessibilityId() { return this.isFuzzy() && this.value[0] === '~'; } /** - * @return {string} + * @returns {string} */ toXPath() { if (this.isXPath()) return this.value; if (this.isCSS()) return cssToXPath(this.value); + throw new Error('Can\'t be converted to XPath'); } // DSL /** * @param {CodeceptJS.LocatorOrString} locator - * @return {Locator} + * @returns {Locator} */ or(locator) { const xpath = xpathLocator.combine([ @@ -142,7 +175,7 @@ class Locator { /** * @param {CodeceptJS.LocatorOrString} locator - * @return {Locator} + * @returns {Locator} */ find(locator) { const xpath = sprintf('%s//%s', this.toXPath(), convertToSubSelector(locator)); @@ -151,7 +184,7 @@ class Locator { /** * @param {CodeceptJS.LocatorOrString} locator - * @return {Locator} + * @returns {Locator} */ withChild(locator) { const xpath = sprintf('%s[./child::%s]', this.toXPath(), convertToSubSelector(locator)); @@ -160,7 +193,7 @@ class Locator { /** * @param {CodeceptJS.LocatorOrString} locator - * @return {Locator} + * @returns {Locator} */ withDescendant(locator) { const xpath = sprintf('%s[./descendant::%s]', this.toXPath(), convertToSubSelector(locator)); @@ -168,31 +201,35 @@ class Locator { } /** - * @param {number} position - * @return {Locator} + * @param {number} position + * @returns {Locator} */ at(position) { - if (position < 0) { - position++; // -1 points to the last element - // @ts-ignore - position = `last()-${Math.abs(position)}`; - } if (position === 0) { throw new Error('0 is not valid element position. XPath expects first element to have index 1'); } - const xpath = sprintf('(%s)[position()=%s]', this.toXPath(), position); + + let xpathPosition; + + if (position > 0) { + xpathPosition = position.toString(); + } else { + // -1 points to the last element + xpathPosition = `last()-${Math.abs(position + 1)}`; + } + const xpath = sprintf('(%s)[position()=%s]', this.toXPath(), xpathPosition); return new Locator({ xpath }); } /** - * @return {Locator} + * @returns {Locator} */ first() { return this.at(1); } /** - * @return {Locator} + * @returns {Locator} */ last() { return this.at(-1); @@ -200,7 +237,7 @@ class Locator { /** * @param {string} text - * @return {Locator} + * @returns {Locator} */ withText(text) { text = xpathLocator.literal(text); @@ -209,13 +246,13 @@ class Locator { } /** - * @param {Object.} attrs - * @return {Locator} + * @param {Object.} attributes + * @returns {Locator} */ - withAttr(attrs) { + withAttr(attributes) { const operands = []; - for (const attr of Object.keys(attrs)) { - operands.push(`@${attr} = ${xpathLocator.literal(attrs[attr])}`); + for (const attr of Object.keys(attributes)) { + operands.push(`@${attr} = ${xpathLocator.literal(attributes[attr])}`); } const xpath = sprintf('%s[%s]', this.toXPath(), operands.join(' and ')); return new Locator({ xpath }); @@ -223,7 +260,7 @@ class Locator { /** * @param {string} output - * @return {Locator} + * @returns {Locator} */ as(output) { this.output = output; @@ -232,7 +269,7 @@ class Locator { /** * @param {CodeceptJS.LocatorOrString} locator - * @return {Locator} + * @returns {Locator} */ inside(locator) { const xpath = sprintf('%s[ancestor::%s]', this.toXPath(), convertToSubSelector(locator)); @@ -241,7 +278,7 @@ class Locator { /** * @param {CodeceptJS.LocatorOrString} locator - * @return {Locator} + * @returns {Locator} */ after(locator) { const xpath = sprintf('%s[preceding-sibling::%s]', this.toXPath(), convertToSubSelector(locator)); @@ -250,7 +287,7 @@ class Locator { /** * @param {CodeceptJS.LocatorOrString} locator - * @return {Locator} + * @returns {Locator} */ before(locator) { const xpath = sprintf('%s[following-sibling::%s]', this.toXPath(), convertToSubSelector(locator)); @@ -267,11 +304,21 @@ Locator.build = (locator) => { return new Locator(locator, 'css'); }; +/** + * Filters to modify locators + */ Locator.filters = []; +/** + * Appends new `Locator` filter to an `Locator.filters` array, and returns the new length of the array. + */ Locator.addFilter = fn => Locator.filters.push(fn); Locator.clickable = { + /** + * @param {string} literal + * @returns {string} + */ narrow: literal => xpathLocator.combine([ `.//a[normalize-space(.)=${literal}]`, `.//button[normalize-space(.)=${literal}]`, @@ -279,6 +326,10 @@ Locator.clickable = { `.//input[./@type = 'submit' or ./@type = 'image' or ./@type = 'button'][normalize-space(@value)=${literal}]`, ]), + /** + * @param {string} literal + * @returns {string} + */ wide: literal => xpathLocator.combine([ `.//a[./@href][((contains(normalize-space(string(.)), ${literal})) or .//img[contains(./@alt, ${literal})])]`, `.//input[./@type = 'submit' or ./@type = 'image' or ./@type = 'button'][contains(./@value, ${literal})]`, @@ -292,14 +343,27 @@ Locator.clickable = { `.//*[@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id ]`, ]), + /** + * @param {string} literal + * @returns {string} + */ self: literal => `./self::*[contains(normalize-space(string(.)), ${literal}) or contains(normalize-space(@value), ${literal})]`, }; Locator.field = { + /** + * @param {string} literal + * @returns {string} + */ labelEquals: literal => xpathLocator.combine([ `.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')][((./@name = ${literal}) or ./@id = //label[@for][normalize-space(string(.)) = ${literal}]/@for or ./@placeholder = ${literal})]`, `.//label[normalize-space(string(.)) = ${literal}]//.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]`, ]), + + /** + * @param {string} literal + * @returns {string} + */ labelContains: literal => xpathLocator.combine([ `.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')][(((./@name = ${literal}) or ./@id = //label[@for][contains(normalize-space(string(.)), ${literal})]/@for) or ./@placeholder = ${literal})]`, `.//label[contains(normalize-space(string(.)), ${literal})]//.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]`, @@ -307,7 +371,17 @@ Locator.field = { `.//*[@title = ${literal}]`, `.//*[@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id ]`, ]), + + /** + * @param {string} literal + * @returns {string} + */ byName: literal => `.//*[self::input | self::textarea | self::select][@name = ${literal}]`, + + /** + * @param {string} literal + * @returns {string} + */ byText: literal => xpathLocator.combine([ `.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')][(((./@name = ${literal}) or ./@id = //label[@for][contains(normalize-space(string(.)), ${literal})]/@for) or ./@placeholder = ${literal})]`, `.//label[contains(normalize-space(string(.)), ${literal})]//.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]`, @@ -316,52 +390,111 @@ Locator.field = { }; Locator.checkable = { + /** + * @param {string} literal + * @returns {string} + */ byText: literal => xpathLocator.combine([ `.//input[@type = 'checkbox' or @type = 'radio'][(@id = //label[@for][contains(normalize-space(string(.)), ${literal})]/@for) or @placeholder = ${literal}]`, `.//label[contains(normalize-space(string(.)), ${literal})]//input[@type = 'radio' or @type = 'checkbox']`, ]), + /** + * @param {string} literal + * @returns {string} + */ byName: literal => `.//input[@type = 'checkbox' or @type = 'radio'][@name = ${literal}]`, }; Locator.select = { + /** + * @param {string} opt + * @returns {string} + */ byVisibleText: (opt) => { const normalized = `[normalize-space(.) = ${opt.trim()}]`; return `./option${normalized}|./optgroup/option${normalized}`; }, + /** + * @param {string} opt + * @returns {string} + */ byValue: (opt) => { const normalized = `[normalize-space(@value) = ${opt.trim()}]`; return `./option${normalized}|./optgroup/option${normalized}`; }, - }; module.exports = Locator; +/** + * @private + * Checks if `locator` is CSS locator + * @param {string} locator + * + * @returns {boolean} + */ function isCSS(locator) { return locator[0] === '#' || locator[0] === '.' || locator[0] === '['; } +/** + * @private + * Checks if `locator` is XPath locator + * @param {string} locator + * + * @returns {boolean} + */ function isXPath(locator) { const trimmed = locator.replace(/^\(+/, '').substr(0, 2); return trimmed === '//' || trimmed === './'; } +/** + * @private + * **Experimental!** Works for WebDriver helper only + * + * Checks if `locator` is shadow locator. + * + * Shadow locators are + * `{ shadow: ['my-app', 'recipe-hello', 'button'] }` + * + * @param {{shadow: string[]}} locator + * + * @returns {boolean} + */ function isShadow(locator) { const hasShadowProperty = (locator.shadow !== undefined) && (Object.keys(locator).length === 1); return hasShadowProperty; } -function isXPathStartingWithRoundBrackets(locator) { - return isXPath(locator) && locator[0] === '('; +/** + * @private + * Checks if xpath starts with `(` + * @param {string} xpath + * @returns {boolean} + */ +function isXPathStartingWithRoundBrackets(xpath) { + return isXPath(xpath) && xpath[0] === '('; } +/** + * @private + * Removes `./` and `.//` symbols from xpath's start + * @param {string} xpath + * @returns {string} + */ function removePrefix(xpath) { return xpath .replace(/^(\.|\/)+/, ''); } +/** + * @private + * @param {CodeceptJS.LocatorOrString} locator + * @returns {string} + */ function convertToSubSelector(locator) { const xpath = (new Locator(locator, 'css')).toXPath(); if (isXPathStartingWithRoundBrackets(xpath)) { diff --git a/lib/output.js b/lib/output.js index 42df19b1c..1c220f305 100644 --- a/lib/output.js +++ b/lib/output.js @@ -28,8 +28,8 @@ module.exports = { /** * Set or return current verbosity level - * @param {number} level - * @return {number} + * @param {number} [level] + * @returns {number} */ level(level) { if (level !== undefined) outputLevel = level; @@ -40,7 +40,7 @@ module.exports = { * Print information for a process * Used in multiple-run * @param {string} process - * @return {string} + * @returns {string} */ process(process) { if (process === null) return outputProcess = ''; @@ -63,7 +63,9 @@ module.exports = { * @param {string} msg */ log(msg) { - if (outputLevel >= 3) print(' '.repeat(this.stepShift), styles.log(truncate(` ${msg}`, this.spaceShift))); + if (outputLevel >= 3) { + print(' '.repeat(this.stepShift), styles.log(truncate(` ${msg}`, this.spaceShift))); + } }, /** @@ -83,11 +85,12 @@ module.exports = { }, /** - * @param {string} name + * Prints plugin message + * @param {string} pluginName * @param {string} msg */ - plugin(name, msg = '') { - this.debug(`<${name}> ${msg}`); + plugin(pluginName, msg = '') { + this.debug(`<${pluginName}> ${msg}`); }, /** diff --git a/lib/utils.js b/lib/utils.js index d7edc35be..de20060ee 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -105,10 +105,20 @@ module.exports.template = function (template, data) { }); }; +/** + * Make first char uppercase. + * @param {string} str + * @returns {string} + */ module.exports.ucfirst = function (str) { return str.charAt(0).toUpperCase() + str.substr(1); }; +/** + * Make first char lowercase. + * @param {string} str + * @returns {string} + */ module.exports.lcfirst = function (str) { return str.charAt(0).toLowerCase() + str.substr(1); }; @@ -148,6 +158,10 @@ module.exports.decodeUrl = function (url) { }; module.exports.xpathLocator = { + /** + * @param {string} string + * @returns {string} + */ literal: (string) => { if (string.indexOf("'") > -1) { string = string.split("'", -1).map(substr => `'${substr}'`).join(',"\'",'); @@ -155,6 +169,12 @@ module.exports.xpathLocator = { } return `'${string}'`; }, + + /** + * Combines passed locators into one disjunction one. + * @param {string[]} locators + * @returns {string} + */ combine: locators => locators.join(' | '), }; @@ -378,6 +398,11 @@ function normalizeKeyReplacer(match, prefix, key, suffix, offset, string) { return normalizedKey + position.charAt(0).toUpperCase() + position.substr(1).toLowerCase(); } +/** + * Transforms `key` into normalized to OS key. + * @param {string} key + * @returns {string} + */ module.exports.getNormalizedKeyAttributeValue = function (key) { // Use operation modifier key based on operating system key = key.replace(/(Ctrl|Control|Cmd|Command)[ _]?Or[ _]?(Ctrl|Control|Cmd|Command)/i, os.platform() === 'darwin' ? 'Meta' : 'Control'); diff --git a/package.json b/package.json index 75670eeae..01b3a4488 100644 --- a/package.json +++ b/package.json @@ -60,13 +60,13 @@ "allure-js-commons": "^1.3.2", "arrify": "^2.0.1", "axios": "^0.19.1", - "chalk": "^1.1.3", + "chalk": "^4.1.0", "commander": "^2.20.3", "css-to-xpath": "^0.1.0", "cucumber-expressions": "^6.6.2", "envinfo": "^7.5.1", "escape-string-regexp": "^1.0.3", - "figures": "^2.0.0", + "figures": "^3.2.0", "fn-args": "^4.0.0", "fs-extra": "^8.1.0", "gherkin": "^5.1.0",