feat(xml): add xpath mapper so queries can be run on the new source
MODIFY
packages/appium-plugins/package.json
packages/appium-plugins/package.json
"devDependencies": {
"@babel/core": "^7.12.10",
"@babel/plugin-proposal-class-properties": "^7.12.1",
- "appium": "^2.0.0-beta.8",
+ "appium": "^2.0.0-beta.9",
"chai": "4.x",
"chai-as-promised": "7.x",
"copyfiles": "^2.4.1",

MODIFY
packages/appium-plugins/packages/universal-xml/lib/plugin.js
packages/appium-plugins/packages/universal-xml/lib/plugin.js
/* eslint-disable no-case-declarations */
import BasePlugin from '@appium/base-plugin';
+import { errors } from 'appium-base-driver';
import { transformSourceXml } from './source';
+import { transformQuery } from './xpath';
import log from './logger';
@@ -10,13 +12,15 @@ export default class UniversalXMLPlugin extends BasePlugin {
commands = ['getPageSource', 'findElement', 'findElements', 'findElementFromElement',
'findElementsFromElement'];
- async handle (next, driver, cmdName/*, ...args*/) {
- if (cmdName === 'getPageSource') {
+ async getPageSource (next, driver, addIndexPath) {
const source = await driver.getPageSource();
- const transformMetadata = {appPackage: driver.opts.appPackage};
+ const metadata = {};
const {platformName} = driver.caps;
+ if (platformName.toLowerCase() === 'android') {
+ metadata.appPackage = driver.opts.appPackage;
+ }
const {xml, unknowns} = transformSourceXml(source,
- platformName.toLowerCase(), transformMetadata);
+ platformName.toLowerCase(), {metadata, addIndexPath});
if (unknowns.nodes.length) {
log.warn(`The XML mapper found ${unknowns.nodes.length} node(s) / ` +
`tag name(s) that it didn't know about. These should be ` +
@@ -31,8 +35,36 @@ export default class UniversalXMLPlugin extends BasePlugin {
}
return xml;
}
+
+ async findElement (...args) {
+ return await this._find(false, ...args);
+ }
+
+ async findElements (...args) {
+ return await this._find(true, ...args);
+ }
+
+ async _find (multiple, next, driver, strategy, selector) {
+ if (strategy.toLowerCase() !== 'xpath') {
return await next();
}
+ const xml = await this.getPageSource(null, driver, true);
+ const newSelector = transformQuery(selector, xml, multiple);
+
+ // if the selector was not able to be transformed, that means no elements were found that
+ // matched, so do the appropriate thing based on element vs elements
+ if (newSelector === null) {
+ if (multiple) {
+ return [];
+ }
+ throw new errors.NoSuchElementError();
+ }
+
+ // otherwise just run the transformed query!
+ const finder = multiple ? 'findElements' : 'findElement';
+ return await driver[finder](strategy, newSelector);
+ }
+
}
export { UniversalXMLPlugin };

MODIFY
packages/appium-plugins/packages/universal-xml/lib/source.js
packages/appium-plugins/packages/universal-xml/lib/source.js
@@ -16,13 +16,18 @@ const GEN_OPTS = {
};
export const ATTR_PREFIX = '@_';
+export const IDX_PATH_PREFIX = `${ATTR_PREFIX}indexPath`;
+export const IDX_PREFIX = `${ATTR_PREFIX}index`;
const isAttr = (k) => k.substring(0, 2) === ATTR_PREFIX;
const isNode = (k) => !isAttr(k);
-export function transformSourceXml (xmlStr, platform, metadata) {
+export function transformSourceXml (xmlStr, platform, {metadata, addIndexPath = false}) {
+ // first thing we want to do is modify the ios source root node, because it doesn't include the
+ // necessary index attribute, so we add it if it's not there
+ xmlStr = xmlStr.replace('<AppiumAUT>', '<AppiumAUT index="0">');
const xmlObj = parser.parse(xmlStr, PARSE_OPTS);
- const unknowns = transformNode(xmlObj, platform, metadata);
+ const unknowns = transformNode(xmlObj, platform, {metadata, addIndexPath, parentPath: ''});
const jParser = new j2xParser(GEN_OPTS);
const transformedXml = jParser.parse(xmlObj).trim();
return {xml: transformedXml, unknowns};
@@ -49,21 +54,35 @@ function getUniversalAttrName (attrName, platform) {
return getUniversalName(ATTR_MAP, attrName, platform);
}
-function transformNode (nodeObj, platform, metadata) {
+function transformNode (nodeObj, platform, {metadata, addIndexPath, parentPath}) {
const unknownNodes = [];
const unknownAttrs = [];
if (_.isPlainObject(nodeObj)) {
const keys = Object.keys(nodeObj);
const childNodeNames = keys.filter(isNode);
const attrs = keys.filter(isAttr);
+ let thisIndexPath = parentPath;
+
+ if (attrs.length && addIndexPath) {
+ if (!attrs.includes(IDX_PREFIX)) {
+ throw new Error(`Index path is required but node found with no 'index' attribute`);
+ }
+
+ thisIndexPath = `${parentPath}/${nodeObj[IDX_PREFIX]}`;
+ nodeObj[IDX_PATH_PREFIX] = thisIndexPath;
+ }
TRANSFORMS[platform]?.(nodeObj, metadata);
unknownAttrs.push(...transformAttrs(nodeObj, attrs, platform));
- unknownNodes.push(...transformChildNodes(nodeObj, childNodeNames, platform, metadata));
+ unknownNodes.push(...transformChildNodes(nodeObj, childNodeNames, platform, {
+ metadata, addIndexPath, parentPath: thisIndexPath
+ }));
} else if (_.isArray(nodeObj)) {
for (const childObj of nodeObj) {
- const {nodes, attrs} = transformNode(childObj, platform, metadata);
+ const {nodes, attrs} = transformNode(childObj, platform, {
+ metadata, addIndexPath, parentPath
+ });
unknownNodes.push(...nodes);
unknownAttrs.push(...attrs);
}
@@ -74,11 +93,11 @@ function transformNode (nodeObj, platform, metadata) {
};
}
-function transformChildNodes (nodeObj, childNodeNames, platform, metadata) {
+function transformChildNodes (nodeObj, childNodeNames, platform, {metadata, addIndexPath, parentPath}) {
const unknownNodes = [];
for (const nodeName of childNodeNames) {
// before modifying the name of this child node, recurse down and modify the subtree
- transformNode(nodeObj[nodeName], platform, metadata);
+ transformNode(nodeObj[nodeName], platform, {metadata, addIndexPath, parentPath});
// now translate the node name and replace the subtree with this node
const universalName = getUniversalNodeName(nodeName, platform);

ADD
packages/appium-plugins/packages/universal-xml/lib/xpath.js
+import { select as xpathQuery } from 'xpath';
+import { DOMParser } from 'xmldom';
+
+export function runQuery (query, xmlStr) {
+ const dom = new DOMParser().parseFromString(xmlStr);
+ const nodes = xpathQuery(query, dom);
+ return nodes;
+}
+
+export function transformQuery (query, xmlStr, multiple) {
+ const nodes = runQuery(query, xmlStr);
+
+ const newQueries = nodes.map((node) => {
+ const indexPath = getNodeAttrVal(node, 'indexPath');
+ // at this point indexPath will look like /0/0/1/1/0/1/0/2
+ let newQuery = indexPath
+ .substring(1) // remove leading / so we can split
+ .split('/') // split into idnexes
+ .map((indexStr) => { // map to xpath node indexes (1-based)
+ const xpathIndex = parseInt(indexStr, 10) + 1;
+ return `*[${xpathIndex}]`;
+ })
+ .join('/'); // reapply /
+
+ // now to make this a valid xpath from the root, prepend the / we removed earlier
+ return `/${newQuery}`;
+ });
+
+ let newSelector = null;
+ if (newQueries.length) {
+ if (multiple) {
+ newSelector = newQueries.join(' | ');
+ } else {
+ newSelector = newQueries[0];
+ }
+ }
+ return newSelector;
+}
+
+export function getNodeAttrVal (node, attr) {
+ const attrObjs = Object.values(node.attributes).filter((obj) => obj.name === attr);
+ if (!attrObjs.length) {
+ throw new Error(`Tried to retrieve a node attribute '${attr}' but the node didn't have it`);
+ }
+ return attrObjs[0].value;
+}

MODIFY
packages/appium-plugins/packages/universal-xml/package-lock.json
packages/appium-plugins/packages/universal-xml/package-lock.json
"version": "9.0.7",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz",
"integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0="
+ },
+ "xmldom": {
+ "version": "0.1.31",
+ "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz",
+ "integrity": "sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ=="
}
}
},
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="
},
"xmldom": {
- "version": "0.1.31",
- "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz",
- "integrity": "sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ=="
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.4.0.tgz",
+ "integrity": "sha512-2E93k08T30Ugs+34HBSTQLVtpi6mCddaY8uO+pMNk1pqSjV5vElzn4mmh6KLxN3hki8rNcHSYzILoh3TEWORvA=="
+ },
+ "xpath": {
+ "version": "0.0.32",
+ "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz",
+ "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="
},
"xtend": {
"version": "4.0.2",

MODIFY
packages/appium-plugins/packages/universal-xml/package.json
packages/appium-plugins/packages/universal-xml/package.json
"homepage": "https://github.com/appium/appium-plugins#readme",
"dependencies": {
"@appium/base-plugin": "^1.5.0",
- "appium-base-driver": "^8.0.0-beta.0",
- "fast-xml-parser": "^3.17.6"
+ "appium-base-driver": "^8.0.0-beta.4",
+ "fast-xml-parser": "^3.17.6",
+ "xmldom": "^0.4.0",
+ "xpath": "0.0.32"
},
"scripts": {
"test:unit": "mocha ./build/test/unit/*-specs.js",

MODIFY
packages/appium-plugins/packages/universal-xml/test/unit/plugin-specs.js
packages/appium-plugins/packages/universal-xml/test/unit/plugin-specs.js
@@ -3,25 +3,55 @@ import chaiAsPromised from 'chai-as-promised';
import UniversalXMLPlugin from '../../index';
import BaseDriver from 'appium-base-driver';
import { XML_IOS, XML_ANDROID, XML_IOS_TRANSFORMED, XML_ANDROID_TRANSFORMED } from '../fixtures';
+import { runQuery, getNodeAttrVal } from '../../lib/xpath';
chai.use(chaiAsPromised);
chai.should();
-describe('UniversalXMLPlugin#handle', function () {
+describe('UniversalXMLPlugin', function () {
const next = () => true;
- const driver = new BaseDriver();
- driver.getPageSource = () => XML_IOS;
- driver.caps = {platformName: 'iOS'};
- driver.opts = {appPackage: 'io.cloudgrey.the_app'};
const p = new UniversalXMLPlugin();
describe('getPageSource', function () {
+ const driver = new BaseDriver();
it('should transform page source for ios', async function () {
- await p.handle(next, driver, 'getPageSource').should.eventually.eql(XML_IOS_TRANSFORMED);
+ driver.getPageSource = () => XML_IOS;
+ driver.caps = {platformName: 'iOS'};
+ await p.getPageSource(next, driver).should.eventually.eql(XML_IOS_TRANSFORMED);
});
it('should transform page source for android', async function () {
driver.getPageSource = () => XML_ANDROID;
driver.caps = {platformName: 'Android'};
- await p.handle(next, driver, 'getPageSource').should.eventually.eql(XML_ANDROID_TRANSFORMED);
+ driver.opts = {appPackage: 'io.cloudgrey.the_app'};
+ await p.getPageSource(next, driver).should.eventually.eql(XML_ANDROID_TRANSFORMED);
+ });
+ });
+
+ describe('findElement(s)', function () {
+ const driver = new BaseDriver();
+ it('should turn an xpath query into another query run on the original ios source', async function () {
+ driver.getPageSource = () => XML_IOS;
+ driver.caps = {platformName: 'iOS'};
+ // mock out the findElement function to just return an xml node from the fixture
+ driver.findElement = (strategy, selector) => {
+ const nodes = runQuery(selector, XML_IOS);
+ return nodes[0];
+ };
+ const node = await p.findElement(next, driver, 'xpath', '//TextInput[@axId="username"]');
+ getNodeAttrVal(node, 'value').should.eql('alice');
+ node.nodeName.should.eql('XCUIElementTypeTextField');
+ });
+
+ it('should turn an xpath query into another query run on the original android source', async function () {
+ driver.getPageSource = () => XML_ANDROID;
+ driver.caps = {platformName: 'Android'};
+ driver.opts = {appPackage: 'io.cloudgrey.the_app'};
+ driver.findElement = (strategy, selector) => {
+ const nodes = runQuery(selector, XML_ANDROID);
+ return nodes[0];
+ };
+ const node = await p.findElement(next, driver, 'xpath', '//TextInput[@axId="username"]');
+ getNodeAttrVal(node, 'content-desc').should.eql('username');
+ node.nodeName.should.eql('android.widget.EditText');
});
});
});

feat(xml): add xpath mapper so queries can be run on the new source