UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

414 lines (387 loc) 13.2 kB
import _ from 'lodash'; import {errors} from 'appium/driver'; import {util} from 'appium/support'; /** * Prepares the input value to be passed as an argument to WDA. * * @param {string|string[]|number} inp The actual text to type. * @example * ```js * // Acceptable values of `inp`: * ['some text'] * ['s', 'o', 'm', 'e', ' ', 't', 'e', 'x', 't'] * 'some text' * 1234 * ``` * @throws {Error} If the value is not acceptable for input * @returns {string[]} The preprocessed value */ function prepareInputValue(inp) { if (![_.isArray, _.isString, _.isFinite].some((f) => f(inp))) { throw new Error( `Only strings, numbers and arrays are supported as input arguments. ` + `Received: ${JSON.stringify(inp)}`, ); } // make it into a string, so then we assure // the array items are single characters if (_.isArray(inp)) { inp = inp.join(''); } else if (_.isFinite(inp)) { inp = `${inp}`; } // The `split` method must not be used on the string // to properly handle all Unicode code points return [...String(inp)].map((k) => { if (['\uE006', '\uE007'].includes(k)) { // RETURN or ENTER return '\n'; } if (['\uE003', '\ue017'].includes(k)) { // BACKSPACE or DELETE return '\b'; } return k; }); } const commands = { /** * @this {XCUITestDriver} */ async elementDisplayed(el) { el = util.unwrapElement(el); if (this.isWebContext()) { const atomsElement = this.getAtomsElement(el); return await this.executeAtom('is_displayed', [atomsElement]); } return await this.proxyCommand(`/element/${el}/displayed`, 'GET'); }, /** * @this {XCUITestDriver} */ async elementEnabled(el) { el = util.unwrapElement(el); if (this.isWebContext()) { const atomsElement = this.getAtomsElement(el); return await this.executeAtom('is_enabled', [atomsElement]); } return await this.proxyCommand(`/element/${el}/enabled`, 'GET'); }, /** * @this {XCUITestDriver} */ async elementSelected(el) { el = util.unwrapElement(el); if (this.isWebContext()) { const atomsElement = this.getAtomsElement(el); return await this.executeAtom('is_selected', [atomsElement]); } return await this.proxyCommand(`/element/${el}/selected`, 'GET'); }, /** * @this {XCUITestDriver} */ async getName(el) { el = util.unwrapElement(el); if (this.isWebContext()) { const atomsElement = this.getAtomsElement(el); const script = 'return arguments[0].tagName.toLowerCase()'; return await this.executeAtom('execute_script', [script, [atomsElement]]); } return await this.proxyCommand(`/element/${el}/name`, 'GET'); }, /** * @this {XCUITestDriver} */ async getNativeAttribute(attribute, el) { if (attribute === 'contentSize') { // don't proxy requests for the content size of a scrollable element return await this.getContentSize(el); } el = util.unwrapElement(el); // otherwise let WDA handle attribute requests let value = /** @type {string|number|null|undefined|boolean} */ ( await this.proxyCommand(`/element/${el}/attribute/${attribute}`, 'GET') ); // Transform the result for the case when WDA returns an integer representation for a boolean value if ([0, 1].includes(/** @type {number} */ (value))) { value = !!value; } // The returned value must be of type string according to https://www.w3.org/TR/webdriver/#get-element-attribute return _.isNull(value) || _.isString(value) ? value : JSON.stringify(value); }, /** * @this {XCUITestDriver} */ async getAttribute(attribute, el) { el = util.unwrapElement(el); if (!this.isWebContext()) { return await this.getNativeAttribute(attribute, el); } const atomsElement = this.getAtomsElement(el); return await this.executeAtom('get_attribute_value', [atomsElement, attribute]); }, /** * @this {XCUITestDriver} */ async getProperty(property, el) { el = util.unwrapElement(el); if (!this.isWebContext()) { return await this.getNativeAttribute(property, el); } const atomsElement = this.getAtomsElement(el); return await this.executeAtom('get_attribute_value', [atomsElement, property]); }, /** * @this {XCUITestDriver} */ async getText(el) { el = util.unwrapElement(el); if (!this.isWebContext()) { return await this.proxyCommand(`/element/${el}/text`, 'GET'); } let atomsElement = this.getAtomsElement(el); return await this.executeAtom('get_text', [atomsElement]); }, /** * @this {XCUITestDriver} * @returns {Promise<import('@appium/types').Rect>} */ async getElementRect(el) { if (this.isWebContext()) { // Mobile safari doesn't support rect const {x, y} = await this.getLocation(el); const {width, height} = await this.getSize(el); return {x, y, width, height}; } el = util.unwrapElement(el); return await this.getNativeRect(el); }, /** * Get the position of an element on screen * * @param {string|Element} elementId - the element ID * @returns {Promise<Position>} The position of the element * @deprecated Use {@linkcode XCUITestDriver.getElementRect} instead * @this {XCUITestDriver} */ async getLocation(elementId) { const el = util.unwrapElement(elementId); if (this.isWebContext()) { const atomsElement = this.getAtomsElement(el); let loc = await this.executeAtom('get_top_left_coordinates', [atomsElement]); if (this.opts.absoluteWebLocations) { const script = 'return [' + 'Math.max(window.pageXOffset,document.documentElement.scrollLeft,document.body.scrollLeft),' + 'Math.max(window.pageYOffset,document.documentElement.scrollTop,document.body.scrollTop)];'; const [xOffset, yOffset] = /** @type {[number, number]} */ (await this.execute(script)); loc.x += xOffset; loc.y += yOffset; } return loc; } const rect = await this.getElementRect(el); return {x: rect.x, y: rect.y}; }, /** * Alias for {@linkcode XCUITestDriver.getLocation} * @param {string|Element} elementId - the element ID * @returns {Promise<Position>} The position of the element * @deprecated Use {@linkcode XCUITestDriver.getElementRect} instead * @this {XCUITestDriver} */ async getLocationInView(elementId) { return await this.getLocation(elementId); }, /** * Get the size of an element * @param {string|Element} el - the element ID * @returns {Promise<Size>} The position of the element * @this {XCUITestDriver} */ async getSize(el) { el = util.unwrapElement(el); if (this.isWebContext()) { return await this.executeAtom('get_size', [this.getAtomsElement(el)]); } const rect = await this.getElementRect(el); return {width: rect.width, height: rect.height}; }, /** * Alias for {@linkcode setValue} * * @param {string} value - the value to set * @param {string} el - the element to set the value of * @deprecated * @this {XCUITestDriver} */ async setValueImmediate(value, el) { // WDA does not provide no way to set the value directly this.log.info( 'There is currently no way to bypass typing using XCUITest. Setting value through keyboard', ); await this.setValue(value, el); }, /** * @this {XCUITestDriver} */ async setValue(value, el) { el = util.unwrapElement(el); if (!this.isWebContext()) { await this.proxyCommand(`/element/${el}/value`, 'POST', { value: prepareInputValue(value), }); return; } const atomsElement = this.getAtomsElement(el); await this.executeAtom('click', [atomsElement]); if (this.opts.sendKeyStrategy !== 'oneByOne') { await this.setValueWithWebAtom(atomsElement, value); return; } for (const char of prepareInputValue(value)) { await this.setValueWithWebAtom(atomsElement, char); } }, /** * Set value with Atom for Web. This method calls `type` atom only. * Expected to be called as part of {@linkcode setValue}. * @this {XCUITestDriver} * @param {import('./types').AtomsElement<string>} atomsElement A target element to type the given value. * @param {string|string[]} value The actual text to type. */ async setValueWithWebAtom(atomsElement, value) { await this.executeAtom('type', [atomsElement, value]); if (this.opts.skipTriggerInputEventAfterSendkeys) { return; } function triggerInputEvent(/** @type {EventTarget & {_valueTracker?: any}} */input) { let lastValue = ''; let event = new Event('input', { bubbles: true }); let tracker = input._valueTracker; if (tracker) { tracker.setValue(lastValue); } input.dispatchEvent(event); } const scriptAsString = `return (${triggerInputEvent}).apply(null, arguments)`; await this.executeAtom('execute_script', [scriptAsString, [atomsElement]]); }, /** * Send keys to the app * @param {string[]} value - Array of keys to send * @this {XCUITestDriver} * @deprecated Use {@linkcode XCUITestDriver.setValue} instead */ async keys(value) { await this.proxyCommand('/wda/keys', 'POST', { value: prepareInputValue(value), }); }, /** * @this {XCUITestDriver} */ async clear(el) { el = util.unwrapElement(el); if (this.isWebContext()) { const atomsElement = this.getAtomsElement(el); await this.executeAtom('clear', [atomsElement]); return; } await this.proxyCommand(`/element/${el}/clear`, 'POST'); }, /** * @this {XCUITestDriver} */ async getContentSize(el) { if (this.isWebContext()) { throw new errors.NotYetImplementedError( 'Support for getContentSize for web context is not yet implemented. Please contact an Appium dev', ); } const type = await this.getAttribute('type', el); if (type !== 'XCUIElementTypeTable' && type !== 'XCUIElementTypeCollectionView') { throw new Error( `Can't get content size for type '${type}', only for ` + `tables and collection views`, ); } let locator = '*'; if (type === 'XCUIElementTypeTable') { // only find table cells, not just any children locator = 'XCUIElementTypeCell'; } let contentHeight = 0; const children = await this.findElOrEls(`class chain`, locator, true, el); if (children.length === 1) { // if we know there's only one element, we can optimize to make just one // call to WDA const rect = await this.getElementRect(_.head(children)); contentHeight = rect.height; } else if (children.length) { // otherwise if we have multiple elements, logic differs based on element // type switch (type) { case 'XCUIElementTypeTable': { const firstRect = await this.getElementRect(_.head(children)); const lastRect = await this.getElementRect(_.last(children)); contentHeight = lastRect.y + lastRect.height - firstRect.y; break; } case 'XCUIElementTypeCollectionView': { let elsInRow = 1; // we know there must be at least one element in the row let firstRect = await this.getElementRect(_.head(children)); let initialRects = [firstRect]; for (let i = 1; i < children.length; i++) { const rect = await this.getElementRect(children[i]); initialRects.push(rect); if (rect.y !== firstRect.y) { elsInRow = i; break; } } const spaceBetweenEls = initialRects[elsInRow].y - initialRects[elsInRow - 1].y - initialRects[elsInRow - 1].height; const numRows = Math.ceil(children.length / elsInRow); // assume all cells are the same height contentHeight = numRows * firstRect.height + spaceBetweenEls * (numRows - 1); break; } default: throw new Error( `Programming error: type '${type}' was not ` + `valid but should have already been rejected`, ); } } const size = await this.getSize(el); const origin = await this.getLocationInView(el); // attributes have to be strings, so stringify this up return JSON.stringify({ width: size.width, height: size.height, top: origin.y, left: origin.x, scrollableOffset: contentHeight, }); }, }; const extensions = { /** * @this {XCUITestDriver} * @returns {Promise<Rect>} */ async getNativeRect(el) { return /** @type {Rect} */ (await this.proxyCommand(`/element/${el}/rect`, 'GET')); }, }; export default {...extensions, ...commands}; /** * @typedef {import('../driver').XCUITestDriver} XCUITestDriver * @typedef {import('@appium/types').Element} Element * @typedef {import('@appium/types').Position} Position * @typedef {import('@appium/types').Size} Size * @typedef {import('@appium/types').Rect} Rect */