nightwatch
Version:
Easy to use Node.js based end-to-end testing solution for web applications using the W3C WebDriver API.
442 lines (360 loc) • 14.3 kB
JavaScript
const Element = require('../element');
const Utils = require('../utils');
const ALLOWED_NAMESPACES = [
'alerts', 'cookies', 'document',
'assert', 'verify', 'expect'
];
function isAllowedNamespace(commandName) {
return ALLOWED_NAMESPACES.indexOf(commandName) > -1;
}
class Command {
static get TYPE_ELEMENT() {
return 'element';
}
static get TYPE_SECTION() {
return 'section';
}
static isPossibleElementSelector(item, commandName = '') {
if (!item) {
return false;
}
if (Array.isArray(item)) {
return false;
}
if (Utils.isObject(item)) {
/*eslint no-prototype-builtins: 'warn'*/
return item.hasOwnProperty('selector') && Utils.isString(item.selector);
}
const Api = require('../api');
const ScopedElementApi = require('../api/_loaders/element-api');
return Utils.isString(item) && (item.startsWith('@') || Api.isElementCommand(commandName) || ScopedElementApi.isScopedElementCommand(commandName));
}
static isUserDefinedElementCommand(commandName) {
const ApiLoader = require('../api');
return !ApiLoader.getElementsCommandsStrict().includes(commandName);
}
constructor(parent, commandName, isChaiAssertion, isES6Async = false) {
this.parent = parent;
this.commandName = commandName;
this.isChaiAssertion = isChaiAssertion;
this.isES6Async = isES6Async;
this.isUserDefined = Command.isUserDefinedElementCommand(commandName);
}
/**
* Creates a closure that enables calling commands and assertions on the page or section.
* For all element commands and assertions, it fetches element's selector and locate strategy
* For elements nested under sections, it sets 'recursion' as the locate strategy and passes as its first argument to the command an array of its ancestors + self
* If the command or assertion is not on an element, it calls it with the untouched passed arguments
*
* @param {function} commandFn The actual command function
* @returns {function}
*/
createWrapper(commandFn) {
const self = this;
return function (...args) {
if (args.length > 0 && this.__needsRecursion) {
// within commands
const inputElement = Element.createFromSelector(args[0]);
if (self.isUserDefined) {
inputElement.container = this.__element;
args[0] = inputElement;
} else {
args[0] = Element.createFromSelector({
locateStrategy: 'recursion',
selector: [this.__element, inputElement]
});
}
}
const client = this.client || self.parent && self.parent.client || {};
const result = self.executeCommand(commandFn, args, client);
if (self.isChaiAssertion) {
return result;
}
const {isES6AsyncTestcase} = client;
if ((result instanceof Promise) && (self.parent.constructor.name === 'Page' || isES6AsyncTestcase)) {
// when isES6AsyncTestcase is true, all page and section commands reach here (all commands
// return a Promise by default when isES6AsyncTestcase is true).
// when isES6AsyncTestcase is false, only those page commands which return promise reach here
// (normal API commands do not return Promise when isES6AsyncTestcase is false).
Object.assign(result, self.parent);
// Add parent prototype methods (like api, client, etc.) to result
const parentPrototype = Object.getPrototypeOf(self.parent);
Object.getOwnPropertyNames(parentPrototype).forEach((propertyName) => {
if (propertyName === 'constructor') {
return;
}
const propertyDescriptor = Object.getOwnPropertyDescriptor(parentPrototype, propertyName);
Object.defineProperty(result, propertyName, propertyDescriptor);
});
return result;
}
return self.parent;
};
}
validate(elementOrSection, strategy, type) {
let target = null;
let available = null;
let typeAvailable = 'elements';
let prefix;
let showStrategy = '';
let showAvailable;
switch (type) {
case Command.TYPE_ELEMENT:
target = available = this.parent.elements;
prefix = 'Element';
break;
case Command.TYPE_SECTION:
target = available = this.parent.section;
typeAvailable = 'sections';
prefix = 'Section';
break;
}
let isValid = false;
if (elementOrSection in target) {
isValid = true;
}
if (isValid && strategy) {
isValid = target[elementOrSection].locateStrategy && target[elementOrSection].locateStrategy === strategy;
}
if (!isValid) {
showAvailable = Object.keys(available);
if (strategy) {
showStrategy = `[locateStrategy='${strategy}']`;
showAvailable = showAvailable.map(item => `${item}[locateStrategy='${target[item].locateStrategy}']`);
}
throw new Error(`${prefix} "${elementOrSection}${showStrategy}" was not found in "${this.parent.name}". Available ${typeAvailable}: ${showAvailable.join(', ')}`);
}
}
/**
* Given an element name, returns that element object
*
* @param {string} elementName Name of element
* @param {string} [strategy]
* @returns {Element} The element object
*/
getElement(elementName, strategy = null) {
this.validate(elementName, strategy, Command.TYPE_ELEMENT);
return this.parent.elements[elementName];
}
/**
* Given a section name, returns that section object
*
* @param {string} sectionName Name of section
* @param {string} [strategy]
* @returns {Element} The section object
*/
getSection(sectionName, strategy = null) {
this.validate(sectionName, strategy, Command.TYPE_SECTION);
return this.parent.section[sectionName];
}
getSelectorFromArgs(args) {
let selectorArg = args[0];
const isSelector = Command.isPossibleElementSelector(selectorArg, this.commandName);
if (isSelector) {
// check if both strategy and selector are specified as args
const {LocateStrategy} = Utils;
const isStrategySpecified = LocateStrategy.isValid(selectorArg);
if (isStrategySpecified && Utils.isString(args[1])) {
selectorArg = {
selector: args[1],
locateStrategy: args[0]
};
}
return selectorArg;
}
return null;
}
/**
* Identifies element references (@-prefixed selectors) within an argument
* list and converts it into an element object with the appropriate
* selector or recursion chain of selectors.
*
* @param {Array} args The argument list to check for an element selector.
*/
parseElementSelector(args) {
const selector = this.getSelectorFromArgs(args);
if (!selector) {
return;
}
// currently only support first argument for @-elements
let inputElement = Element.createFromSelector(selector);
if (inputElement.hasElementSelector()) {
const nameSections = inputElement.selector.substring(1).split(':');
const name = nameSections[0];
const pseudoSelector = nameSections[1] || null;
// When true, indicates that the selector references a selector within a section rather than an elements definition.
// eg: .expect.section('@footer').to.be.visible
const isSectionSelector = this.isChaiAssertion && this.commandName === 'section';
const getter = isSectionSelector ? this.getSection : this.getElement;
const strategy = Utils.isObject(selector) && selector.locateStrategy || null;
const elementOrSection = getter.call(this, name, strategy);
elementOrSection.pseudoSelector = pseudoSelector;
Element.copyDefaults(inputElement, elementOrSection);
inputElement.locateStrategy = elementOrSection.locateStrategy;
inputElement.selector = elementOrSection.selector; // force replacement of @-selector
inputElement = inputElement.getRecursiveLookupElement() || inputElement;
args[0] = inputElement;
} else {
// if we're calling an element on a section using a css/xpath selector,
// then we need to retrieve the element using recursion
const Section = require('./section.js');
if (this.parent instanceof Section) {
inputElement.parent = this.parent;
inputElement = inputElement.getRecursiveLookupElement() || Element.createFromSelector({
locateStrategy: 'recursion',
selector: [this.parent, inputElement]
});
const ScopedElementApi = require('../api/_loaders/element-api');
if (ScopedElementApi.isScopedElementCommand(this.commandName)) {
// only locate the parent sections recursively and then find the main element using
// original method and arguments.
this.onlyLocateSectionsRecursively = true;
}
args[0] = inputElement;
}
}
}
/**
* @param {Function} commandFn
* @param {Array} args
* @param {Object} context
*/
executeCommand(commandFn, args, context) {
let parseArgs;
if (Utils.isObject(args[0]) && Array.isArray(args[0].args)) {
parseArgs = args[0].args;
} else {
parseArgs = args;
}
this.parseElementSelector(parseArgs);
return commandFn.apply(context, args);
}
}
class CommandLoader {
/**
* Entry point to add commands (elements commands, assertions, etc) to the page or section
*
* @param {Object} parent The parent page or section
* @param {function} commandLoader function that retrieves commands
* @returns {null}
*/
static addWrappedCommands(parent, commandLoader) {
const commands = {
get '__pageObjectItem__' () {
return parent;
}
};
const wrappedCommands = commandLoader(commands);
CommandLoader.applyCommandsToTarget(parent, parent, wrappedCommands);
if (parent.assert && parent.verify) {
const ApiLoader = require('../api');
parent.assert = ApiLoader.makeAssertProxy(parent.assert);
parent.verify = ApiLoader.makeAssertProxy(parent.verify);
}
}
static addWrappedCommandsAsync(parent, commandLoader) {
const commands = {};
const wrappedCommands = commandLoader(commands);
CommandLoader.applyCommandsToTarget(parent, parent, wrappedCommands);
return wrappedCommands;
}
/**
* Adds commands (elements commands, assertions, etc) to the page or section
*
* @param {Object} parent The parent page or section
* @param {Object} target What the command is added to (parent|section or assertion object on parent|section)
* @param {Object} commands
* @returns {null}
*/
static applyCommandsToTarget(parent, target, commands) {
Object.keys(commands).forEach(function(commandName) {
if (isAllowedNamespace(commandName)) {
target[commandName] = target[commandName] || {};
const isChaiAssertion = commandName === 'expect';
const namespace = commands[commandName];
Object.keys(namespace).forEach(function(nsCommandName) {
target[commandName][nsCommandName] = CommandLoader.addCommand({
target: target[commandName],
commandFn: namespace[nsCommandName],
commandName: nsCommandName,
parent,
isChaiAssertion
});
});
} else {
// don't load namespaces here, otherwise they'll be wrapped by a function.
if (Utils.isObject(commands[commandName])) {
return;
}
target[commandName] = CommandLoader.addCommand({
target,
commandFn: commands[commandName],
commandName,
parent,
isChaiAssertion: false,
isES6Async: Utils.isES6AsyncFn(commands[commandName])
});
}
});
}
/**
* @param parent
* @param originalApi
* @param targetApi
* @param {string} commandName
* @returns {function}
*/
static wrapElementCommand(parent, originalApi, targetApi, commandName) {
const originalFn = originalApi[commandName];
const command = new Command(parent, commandName, false);
return function(...args) {
const origArgs = args.slice();
command.parseElementSelector(args);
if (command.onlyLocateSectionsRecursively) {
// only happens in case of new scoped element api, when non @-referenced selector is passed
// to element api methods for sections. Doing this is not really necessary for `find` and `findAll`
// methods but only for testing-library methods (which only accepts string as first argument),
// but we do it for them anyways.
// transfer n-1 selectors to `element()` and the main selector (origArgs) to the actual method.
args[0].selector.pop();
const parentSectionElement = targetApi(args[0]);
return parentSectionElement[commandName](...origArgs);
}
return originalFn.apply(targetApi, args);
};
}
/**
* @param parent
* @param api
* @param {Array} commands
*/
static wrapProtocolCommands(parent, api, commands) {
commands.forEach(commandName => {
api[commandName] = CommandLoader.wrapElementCommand(parent, api, api, commandName);
});
}
static wrapScopedElementApi(parent, api, elementCommands) {
const wrappedElementFn = CommandLoader.wrapElementCommand(parent, api, api, 'element');
elementCommands.forEach(commandName => {
let names = commandName;
if (!Array.isArray(names)) {
names = [names];
}
names.forEach(commandName => {
wrappedElementFn[commandName] = CommandLoader.wrapElementCommand(parent, api.element, wrappedElementFn, commandName);
});
});
api.element = wrappedElementFn;
}
static addCommand({target, commandFn, commandName, parent, isChaiAssertion, isES6Async = false, overwrite = false}) {
if (target[commandName] && !overwrite) {
const err = new TypeError(`Error while loading the page object commands: the command "${commandName}" is already defined.`);
err.displayed = false;
err.showTrace = false;
throw err;
}
const command = new Command(parent, commandName, isChaiAssertion, isES6Async);
return command.createWrapper(commandFn);
}
}
module.exports = CommandLoader;