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

add custom locator strategy #268

Merged
merged 4 commits into from
Oct 12, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

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 [];
}
};