Skip to content
This repository has been archived by the owner on Oct 25, 2023. It is now read-only.

Commit

Permalink
add custom locator strategy (#268)
Browse files Browse the repository at this point in the history
* add custom locator strategy

* address review comments

* allow customFindModules cap to register multiple modules as an object

* add unit tests for custom element finding plugin
  • Loading branch information
jlipps authored Oct 12, 2018
1 parent ad1f884 commit 02b3b87
Show file tree
Hide file tree
Showing 4 changed files with 229 additions and 3 deletions.
119 changes: 117 additions & 2 deletions lib/basedriver/commands/find.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import log from '../logger';
import { logger, imageUtil } from 'appium-support';
import _ from 'lodash';
import { errors } from '../../..';
import { MATCH_TEMPLATE_MODE } from './images';
import { W3C_ELEMENT_KEY, MJSONWP_ELEMENT_KEY } from '../../protocol/protocol';
import { ImageElement } from '../image-element';
import { imageUtil } from 'appium-support';


const commands = {}, helpers = {}, extensions = {};

const IMAGE_STRATEGY = "-image";
const CUSTOM_STRATEGY = "-custom";

// Override the following function for your own driver, and the rest is taken
// care of!
Expand Down Expand Up @@ -41,6 +43,8 @@ helpers.findElOrElsWithProcessing = async function (strategy, selector, mult, co
commands.findElement = async function (strategy, selector) {
if (strategy === IMAGE_STRATEGY) {
return await this.findByImage(selector, {multiple: false});
} else if (strategy === CUSTOM_STRATEGY) {
return await this.findByCustom(selector, false);
}

return await this.findElOrElsWithProcessing(strategy, selector, false);
Expand All @@ -49,6 +53,8 @@ commands.findElement = async function (strategy, selector) {
commands.findElements = async function (strategy, selector) {
if (strategy === IMAGE_STRATEGY) {
return await this.findByImage(selector, {multiple: true});
} else if (strategy === CUSTOM_STRATEGY) {
return await this.findByCustom(selector, true);
}

return await this.findElOrElsWithProcessing(strategy, selector, true);
Expand All @@ -62,6 +68,115 @@ commands.findElementsFromElement = async function (strategy, selector, elementId
return await this.findElOrElsWithProcessing(strategy, selector, true, elementId);
};

/**
* Find an element using a custom plugin specified by the customFindModules cap.
*
* @param {string} selector - the selector which the plugin will use to find
* elements
* @param {boolean} multiple - whether we want one element or multiple
*
* @returns {WebElement} - WebDriver element or list of elements
*/
commands.findByCustom = async function (selector, multiple) {
const plugins = this.opts.customFindModules;

// first ensure the user has registered one or more find plugins
if (!plugins) {
// TODO this info should go in docs instead; update when docs for this
// feature exist
throw new Error("Finding an element using a plugin is currently an " +
"incubating feature. To use it you must manually install one or more " +
"plugin modules in a way that they can be required by Appium, for " +
"example installing them from the Appium directory, installing them " +
"globally, or installing them elsewhere and passing an absolute path as " +
"the capability. Then construct an object where the key is the shortcut " +
"name for this plugin and the value is the module name or absolute path, " +
"for example: {\"p1\": \"my-find-plugin\"}, and pass this in as the " +
"'customFindModules' capability.");
}

// then do some basic checking of the type of the capability
if (!_.isPlainObject(plugins)) {
throw new Error("Invalid format for the 'customFindModules' capability. " +
"It should be an object with keys corresponding to the short names and " +
"values corresponding to the full names of the element finding plugins");
}

// get the name of the particular plugin used for this invocation of find,
// and separate it from the selector we will pass to the plugin
let [plugin, realSelector] = selector.split(":");

// if the user didn't specify a plugin for this find invocation, and we had
// multiple plugins registered, that's a problem
if (_.size(plugins) > 1 && !realSelector) {
throw new Error(`Multiple element finding plugins were registered ` +
`(${_.keys(plugins)}), but your selector did not indicate which plugin ` +
`to use. Ensure you put the short name of the plugin followed by ':' as ` +
`the initial part of the selector string.`);
}

// but if they did not specify a plugin and we only have one plugin, just use
// that one
if (_.size(plugins) === 1 && !realSelector) {
realSelector = plugin;
plugin = _.keys(plugins)[0];
}

if (!plugins[plugin]) {
throw new Error(`Selector specified use of element finding plugin ` +
`'${plugin}' but it was not registered in the 'customFindModules' ` +
`capability.`);
}

let finder;
try {
log.debug(`Find plugin '${plugin}' requested; will attempt to use it ` +
`from '${plugins[plugin]}'`);
finder = require(plugins[plugin]);
} catch (err) {
throw new Error(`Could not load your custom find module '${plugin}'. Did ` +
`you put it somewhere Appium can 'require' it? Original error: ${err}`);
}

if (!finder || !_.isFunction(finder.find)) {
throw new Error("Your custom find module did not appear to be constructed " +
"correctly. It needs to export an object with a `find` method.");
}

const customFinderLog = logger.getLogger(plugin);

let elements;
const condition = async () => {
// get a list of matched elements from the custom finder, which can
// potentially use the entire suite of methods the current driver provides.
// the finder should always return a list of elements, but may use the
// knowledge of whether we are looking for one or many to perform internal
// optimizations
elements = await finder.find(this, customFinderLog, realSelector, multiple);

// if we're looking for multiple elements, or if we're looking for only
// one and found it, we're done
if (!_.isEmpty(elements) || multiple) {
return true;
}

// otherwise we should retry, so return false to trigger the retry loop
return false;
};

try {
// make sure we respect implicit wait
await this.implicitWaitForCondition(condition);
} catch (err) {
if (err.message.match(/Condition unmet/)) {
throw new errors.NoSuchElementError();
}
throw err;
}

return multiple ? elements : elements[0];
};

/**
* @typedef {Object} FindByImageOptions
* @property {boolean} [shouldCheckStaleness=false] - whether this call to find an
Expand Down Expand Up @@ -256,5 +371,5 @@ helpers.getScreenshotForImageFind = async function (screenWidth, screenHeight) {


Object.assign(extensions, commands, helpers);
export { commands, helpers, IMAGE_STRATEGY };
export { commands, helpers, IMAGE_STRATEGY, CUSTOM_STRATEGY };
export default extensions;
79 changes: 78 additions & 1 deletion test/basedriver/commands/find-specs.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import chai from 'chai';
import path from 'path';
import chaiAsPromised from 'chai-as-promised';
import sinon from 'sinon';
import { BaseDriver, ImageElement } from '../../..';
import { IMAGE_STRATEGY } from '../../../lib/basedriver/commands/find';
import { IMAGE_STRATEGY, CUSTOM_STRATEGY } from '../../../lib/basedriver/commands/find';
import { imageUtil } from 'appium-support';


Expand All @@ -14,6 +15,11 @@ class TestDriver extends BaseDriver {
async getScreenshot () {}
}

const CUSTOM_FIND_MODULE = path.resolve(__dirname, "..", "..", "..", "..",
"test", "basedriver", "fixtures", "custom-element-finder");
const BAD_CUSTOM_FIND_MODULE = path.resolve(__dirname, "..", "..", "..", "..",
"test", "basedriver", "fixtures", "custom-element-finder-bad");

const TINY_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQwIDc5LjE2MDQ1MSwgMjAxNy8wNS8wNi0wMTowODoyMSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTggKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6N0NDMDM4MDM4N0U2MTFFOEEzMzhGMTRFNUUwNzIwNUIiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6N0NDMDM4MDQ4N0U2MTFFOEEzMzhGMTRFNUUwNzIwNUIiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3Q0MwMzgwMTg3RTYxMUU4QTMzOEYxNEU1RTA3MjA1QiIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3Q0MwMzgwMjg3RTYxMUU4QTMzOEYxNEU1RTA3MjA1QiIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PpdvJjQAAAAlSURBVHjaJInBEQAACIKw/Xe2Ul5wYBtwmJqkk4+zfvUQVoABAEg0EfrZwc0hAAAAAElFTkSuQmCC";
const TINY_PNG_DIMS = [4, 4];

Expand Down Expand Up @@ -191,3 +197,74 @@ describe('finding elements by image', function () {
});
});
});

describe('custom element finding plugins', function () {
// happys
it('should find a single element using a custom finder', async function () {
const d = new BaseDriver();
d.opts.customFindModules = {f: CUSTOM_FIND_MODULE};
await d.findElement(CUSTOM_STRATEGY, "f:foo").should.eventually.eql("bar");
});
it('should not require selector prefix if only one find plugin is registered', async function () {
const d = new BaseDriver();
d.opts.customFindModules = {f: CUSTOM_FIND_MODULE};
await d.findElement(CUSTOM_STRATEGY, "foo").should.eventually.eql("bar");
});
it('should find multiple elements using a custom finder', async function () {
const d = new BaseDriver();
d.opts.customFindModules = {f: CUSTOM_FIND_MODULE};
await d.findElements(CUSTOM_STRATEGY, "f:foos").should.eventually.eql(["baz1", "baz2"]);
});
it('should give a hint to the plugin about whether multiple are requested', async function () {
const d = new BaseDriver();
d.opts.customFindModules = {f: CUSTOM_FIND_MODULE};
await d.findElement(CUSTOM_STRATEGY, "f:foos").should.eventually.eql("bar1");
});
it('should be able to use multiple find modules', async function () {
const d = new BaseDriver();
d.opts.customFindModules = {f: CUSTOM_FIND_MODULE, g: CUSTOM_FIND_MODULE};
await d.findElement(CUSTOM_STRATEGY, "f:foo").should.eventually.eql("bar");
await d.findElement(CUSTOM_STRATEGY, "g:foo").should.eventually.eql("bar");
});

// errors
it('should throw an error if customFindModules is not set', async function () {
const d = new BaseDriver();
await d.findElement(CUSTOM_STRATEGY, "f:foo").should.eventually.be.rejectedWith(/customFindModules/);
});
it('should throw an error if customFindModules is the wrong shape', async function () {
const d = new BaseDriver();
d.opts.customFindModules = CUSTOM_FIND_MODULE;
await d.findElement(CUSTOM_STRATEGY, "f:foo").should.eventually.be.rejectedWith(/customFindModules/);
});
it('should throw an error if customFindModules is size > 1 and no selector prefix is used', async function () {
const d = new BaseDriver();
d.opts.customFindModules = {f: CUSTOM_FIND_MODULE, g: CUSTOM_FIND_MODULE};
await d.findElement(CUSTOM_STRATEGY, "foo").should.eventually.be.rejectedWith(/multiple element finding/i);
});
it('should throw an error in attempt to use unregistered plugin', async function () {
const d = new BaseDriver();
d.opts.customFindModules = {f: CUSTOM_FIND_MODULE, g: CUSTOM_FIND_MODULE};
await d.findElement(CUSTOM_STRATEGY, "z:foo").should.eventually.be.rejectedWith(/was not registered/);
});
it('should throw an error if plugin cannot be loaded', async function () {
const d = new BaseDriver();
d.opts.customFindModules = {f: "./foo.js"};
await d.findElement(CUSTOM_STRATEGY, "f:foo").should.eventually.be.rejectedWith(/could not load/i);
});
it('should throw an error if plugin is not the right shape', async function () {
const d = new BaseDriver();
d.opts.customFindModules = {f: BAD_CUSTOM_FIND_MODULE};
await d.findElement(CUSTOM_STRATEGY, "f:foo").should.eventually.be.rejectedWith(/constructed correctly/i);
});
it('should pass on an error thrown by the finder itself', async function () {
const d = new BaseDriver();
d.opts.customFindModules = {f: CUSTOM_FIND_MODULE};
await d.findElement(CUSTOM_STRATEGY, "f:error").should.eventually.be.rejectedWith(/plugin error/i);
});
it('should throw no such element error if element not found', async function () {
const d = new BaseDriver();
d.opts.customFindModules = {f: CUSTOM_FIND_MODULE};
await d.findElement(CUSTOM_STRATEGY, "f:nope").should.eventually.be.rejectedWith(/could not be located/);
});
});
5 changes: 5 additions & 0 deletions test/basedriver/fixtures/custom-element-finder-bad.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
notFind: function () { // eslint-disable-line object-shorthand
return [];
}
};
29 changes: 29 additions & 0 deletions test/basedriver/fixtures/custom-element-finder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
module.exports = {
find: function (driver, logger, selector, multiple) { // eslint-disable-line object-shorthand
if (!driver || !driver.opts) {
throw new Error("Expected driver object");
}

if (!logger || !logger.info) {
throw new Error("Expected logger object");
}

if (selector === "foo") {
return ["bar"];
}

if (selector === "foos") {
if (multiple) {
return ["baz1", "baz2"];
}

return ["bar1", "bar2"];
}

if (selector === "error") {
throw new Error("This is a plugin error");
}

return [];
}
};

0 comments on commit 02b3b87

Please sign in to comment.