wdio-ui5-service
Version:
WebdriverIO plugin for testing UI5 browser-based apps
636 lines • 25.7 kB
JavaScript
import * as util from "util";
export const ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
// TODO: import { ELEMENT_KEY } from "webdriverio/build/constants.js"
// patch in webdriverio repo?
import { clientSide_getControl } from "../../client-side-js/getControl.cjs";
import { clientSide_interactWithControl } from "../../client-side-js/interactWithControl.cjs";
import { clientSide_executeControlMethod } from "../../client-side-js/executeControlMethod.cjs";
import { clientSide_getAggregation } from "../../client-side-js/_getAggregation.cjs";
import { clientSide_fireEvent } from "../../client-side-js/fireEvent.cjs";
import { Logger as _Logger } from "./Logger.js";
import { wdioApi } from "./wdioApi.js";
import { WDI5Object } from "./wdi5-object.js";
const Logger = _Logger.getInstance();
/**
* This is a bridge object to use from selector to UI5 control,
* can be seen as a generic representation of a UI5 control
*/
export class WDI5Control {
_controlSelector = null;
// return value of Webdriver interface: JSON web token
_webElement = null; // TODO: type "org.openqa.selenium.WebElement"
// wdio element retrieved separately via $()
_webdriverRepresentation = null;
_metadata = {};
// TODO: move to _metadata
_wdio_ui5_key = null;
_generatedUI5Methods;
_initialisation = false;
_forceSelect = false;
_logging;
_wdioBridge = {};
_generatedWdioMethods;
_domId;
_browserInstance;
constructor(oOptions) {
const { browserInstance, controlSelector, wdio_ui5_key, forceSelect, generatedUI5Methods, webdriverRepresentation, webElement, domId } = oOptions;
this._controlSelector = controlSelector;
this._wdio_ui5_key = wdio_ui5_key;
this._forceSelect = forceSelect;
this._generatedUI5Methods = generatedUI5Methods;
this._browserInstance = browserInstance;
this._webElement = webElement;
this._webdriverRepresentation = webdriverRepresentation;
this._domId = domId;
if (this._generatedUI5Methods && this._generatedUI5Methods.length > 0) {
this._attachControlBridge(this._generatedUI5Methods);
}
if (this._generatedWdioMethods && this._generatedWdioMethods.length > 0) {
this._attachWdioControlBridge(this._generatedWdioMethods);
}
this.setControlInfo();
return this;
}
async init(controlSelector = this._controlSelector, forceSelect = this._forceSelect) {
this._controlSelector = controlSelector;
this._wdio_ui5_key = controlSelector.wdio_ui5_key;
this._forceSelect = forceSelect;
this._logging = this._controlSelector?.logging ?? true;
const controlResult = await this._getControl();
if (controlResult.status === 1) {
// result is string and has error text -> its an error
if (this._logging) {
Logger.error(`error retrieving control: ${this._wdio_ui5_key}`);
}
return this;
}
else {
this._webElement = controlResult.domElement;
// dynamic function bridge
this._generatedUI5Methods = controlResult.aProtoFunctions;
this._attachControlBridge(this._generatedUI5Methods);
this._attachWdioControlBridge(this._generatedWdioMethods);
this.setControlInfo();
}
return this;
}
/**
* after retrieving the ui5 control and connection this can be false eg. in cases when no DOM element was found by RecordReplay API
* @return {Boolean} whether this control was successfully initialized
*/
isInitialized() {
return this._initialisation;
}
getControlInfo() {
return this._metadata;
}
setControlInfo(metadata = {
key: this._wdio_ui5_key,
$: this._generatedWdioMethods,
methods: this._generatedUI5Methods,
id: this._domId
}) {
this._metadata.$ = metadata.$ ? metadata.$ : this._metadata.$;
this._metadata.id = metadata.id ? metadata.id : this._metadata.id;
this._metadata.methods = metadata.methods ? metadata.methods : this._metadata.methods;
this._metadata.className = metadata.className ? metadata.className : this._metadata.className;
this._metadata.key = metadata.key ? metadata.key : this._metadata.key;
return this._metadata;
}
/**
* tries to retrieve the webdriver representation of the current wdi5 control
* @return {WebdriverIO.Element} the webdriver Element
*/
async getWebElement() {
try {
return await this._getWebElement();
}
catch (error) {
if (this._logging) {
Logger.error(`cannot call "getWebElement()", because ${error.message}`);
}
}
}
/**
* add convenience to the getWebElement Function
* @returns {WebdriverIO.Element} the webdriver Element
*/
$() {
return this._wdioBridge; // this.getWebElement()
}
/**
* bridge to UI5 control api "getAggregation"
* @param name name of the aggregation
* @return array of UI5 controls representing the aggregation
*/
async getAggregation(name) {
try {
return await this._getAggregation(name);
}
catch (error) {
if (this._logging) {
Logger.error(`cannot get aggregation "${name}", because ${error.message}`);
}
}
}
/**
* enters a text into a UI5 control
* @param text
*/
async enterText(text) {
let selector;
let logging;
if (util.types.isProxy(this._controlSelector)) {
const _controlSelector = await Promise.resolve(this._controlSelector);
selector = await Promise.resolve(_controlSelector.selector);
logging = await Promise.resolve(this._logging);
}
else {
selector = this._controlSelector.selector;
logging = this._logging;
}
const oOptions = {
enterText: text,
selector,
clearTextFirst: true,
interactionType: "ENTER_TEXT"
};
try {
await this._interactWithControl(oOptions);
}
catch (error) {
if (logging) {
Logger.error(`cannot call enterText(), because ${error.message}`);
}
}
return this;
}
/**
* click on a UI5 control
* this works both on a standalone control as well as with the fluent async api
*/
async press() {
// support fluent async api
let className;
let controlSelector;
let logging;
if (util.types.isProxy(this._domId)) {
const _controlInfo = await Promise.resolve(this._metadata);
className = _controlInfo.className;
controlSelector = await Promise.resolve(this._controlSelector);
logging = await Promise.resolve(this._logging);
}
else {
className = this.getControlInfo().className;
controlSelector = this._controlSelector;
logging = this._logging;
}
// when the interaction locator is existing we want to use the RecordReplay press (interactWithControl)
if (controlSelector.selector.interaction) {
if (logging) {
Logger.info(`using OPA5 Press action to interact with this ${className}...`);
}
const oOptions = {
selector: controlSelector.selector,
interactionType: "PRESS"
};
try {
await this._interactWithControl(oOptions);
}
catch (error) {
if (logging) {
Logger.error(`cannot issue OPA5-press() on control, because ${error.message}`);
}
}
}
else {
// interact via wdio
try {
await (await this._getWebElement()).click();
}
catch (error) {
if (logging) {
Logger.error(`cannot call press(), because ${error.message}`);
}
}
}
return this;
}
/**
* fire a named event on a UI5 control
* @param {String} eventName
* @param {any} oOptions
* @param {WebdriverIO.Element} webElement
*/
async fireEvent(eventName, oOptions, webElement = this._webElement) {
// Check the options have a eval property
if (oOptions?.eval) {
oOptions = "(" + oOptions.eval.toString() + ")";
}
const result = (await clientSide_fireEvent(webElement, eventName, oOptions, this._browserInstance));
if (this._logging) {
this._writeObjectResultLog(result, "fireEvent()");
}
return result.result;
}
// --- deprecated ---
/**
* @deprecated -> use isInitialized()
* @return {Boolean}
*/
getInitStatus() {
return this._initialisation;
}
// --- private methods ---
/**
* Interact with specific control.
* @param {object} oOptions
* @param {sap.ui.test.RecordReplay.ControlSelector} oOptions.selector - UI5 type
* @param {sap.ui.test.RecordReplay.InteractionType} oOptions.interactionType - UI5 type
* @param {string} oOptions.enterText
* @param {boolean} oOptions.clearTextFirst
*/
async _interactWithControl(oOptions) {
// const domId = util.types.isProxy(this._domId) ? await Promise.resolve(this._domId) : this._domId
let domId;
let logging;
if (util.types.isProxy(this._domId)) {
domId = await Promise.resolve(this._domId);
logging = await Promise.resolve(this._logging);
}
else {
domId = this._domId;
logging = this._logging;
}
const browserInstance = util.types.isProxy(this._browserInstance)
? await Promise.resolve(this._browserInstance)
: this._browserInstance;
if (domId) {
const result = (await clientSide_interactWithControl(oOptions, browserInstance));
if (logging) {
this._writeObjectResultLog(result, "interactWithControl()");
}
// return result.result
return this;
}
else {
throw Error("control could not be found");
}
}
/**
* returns the wdio web element.
* @throws will throw an error when no DOM Element was found
* @return {WebdriverIO.Element} the webdriver Element
*/
async _getWebElement() {
if (util.types.isProxy(this._domId)) {
const id = await Promise.resolve(this._domId);
if (id) {
const webElement = await $(`//*[@id="${id}"]`);
return webElement;
}
else {
throw Error("control could not be found");
}
}
if (!this._webdriverRepresentation) {
// to enable transition from wdi5 to wdio api in allControls
await this._renewWebElement();
}
return this._webdriverRepresentation;
}
/**
* @param id
* @returns
*/
async _renewWebElement(id = this._domId) {
if (this._domId) {
this._webdriverRepresentation = await this._browserInstance.$(`//*[@id="${id}"]`);
return this._webdriverRepresentation;
}
else {
throw Error("control could not be found");
}
}
/**
* retrieve UI5 control representation of a UI5 control's aggregation
*
* @param aControls strings of IDs of aggregation items
* @returns instances of wdi5 class per control in the aggregation
*/
async _retrieveElements(aControls) {
const aResultOfPromises = [];
// check the validity of param
if (aControls) {
// loop through items
aControls.forEach((item) => {
// item id -> create selector
const selector = {
wdio_ui5_key: item.id, // plugin-internal, not part of RecordReplay.ControlSelector
forceSelect: this._forceSelect,
selector: {
id: item.id
}
};
// get wdi5 control
aResultOfPromises.push(this._browserInstance.asControl(selector));
});
return await Promise.all(aResultOfPromises);
}
else {
if (this._logging) {
Logger.warn(`${this._wdio_ui5_key} has no aControls`);
}
}
}
/**
* retrieve UI5 control representation of a UI5 control's aggregation
*
* @param eControl ID
* @returns instances of wdi5 class per control in the aggregation
*/
async _retrieveElement(eControl) {
let eResult = {};
// check the validity of param
if (eControl) {
// item id -> create selector
const selector = {
wdio_ui5_key: eControl.id, // plugin-internal, not part of RecordReplay.ControlSelector
forceSelect: this._forceSelect,
selector: {
id: eControl.id
}
};
// get wdi5 control
eResult = await this._browserInstance.asControl(selector);
}
else {
if (this._logging) {
Logger.warn(`${this._wdio_ui5_key} has no aControls`);
}
}
return eResult;
}
/**
* attaches to the instance of this class the functions given in the parameter sReplFunctionNames
*
* @param sReplFunctionNames
*/
_attachControlBridge(sReplFunctionNames) {
// check the validity of param
if (sReplFunctionNames) {
sReplFunctionNames.forEach(async (sMethodName) => {
this[sMethodName] = this._executeControlMethod.bind(this, sMethodName, this._webElement);
});
}
else {
if (this._logging) {
Logger.warn(`${this._wdio_ui5_key} has no sReplFunctionNames`);
}
}
}
_attachWdioControlBridge(sReplFunctionNames) {
// check the validity of param
if (sReplFunctionNames) {
sReplFunctionNames.forEach(async (sMethodName) => {
this._wdioBridge[sMethodName] = async () => {
return await (await this.getWebElement())[sMethodName]();
};
});
}
else {
if (this._logging) {
Logger.warn(`${this._wdio_ui5_key} has no sReplFunctionNames`);
}
}
}
/**
* runtime - proxied browser-time UI5 controls' method at Node.js-runtime
*
* @param methodName UI5 control method
* @param webElement representation of selected UI5 control in wdio
* @param args proxied arguments to UI5 control method at runtime
*/
async _executeControlMethod(methodName, webElement = this._webElement, ...args) {
if (this._forceSelect) {
try {
this._webElement = await this._renewWebElementReference();
}
catch (error) {
if (this._logging) {
Logger.error(`cannot execute ${methodName}(), because ${error.message}`);
}
}
}
// special case for custom data attached to a UI5 control:
// pass the arguments to the event handler (like UI5 handles and expects them) also
// also here in Node.js runtime
if (methodName === "fireEvent") {
if (args[1] && typeof args[1]["eval"] === "function") {
return await this.fireEvent(args[0], args[1], webElement);
}
}
// returns the array of [0: "status", 1: result]
//special case for exec, passed function needs to be converted to string to be passed to the browser
if (methodName === "exec") {
if (args[0] && typeof args[0] === "function") {
args[0] = args[0].toString();
}
else if (this._logging) {
Logger.error(`cannot execute ${methodName}(), because an argument of type function should be present`);
}
}
// regular browser-time execution of UI5 control method
const result = (await clientSide_executeControlMethod(webElement, methodName, this._browserInstance, args,
// to safeguard "stale" elements in the devtools protocol we pass the whole wdi5 object
this));
// create logging
this._writeObjectResultLog(result, methodName);
switch (result.returnType) {
case "newElement":
// retrieve and return another instance of a wdi5 control
return await this._retrieveElement(result.result);
case "element":
// return $self after a called method of the wdi5 instance to allow method chaining
return this;
case "result":
return result.nonCircularResultObject ? result.nonCircularResultObject : result.result;
case "object":
// enhance with uuid
return new WDI5Object(result.uuid, result.aProtoFunctions, result.object);
case "empty":
if (this._logging) {
Logger.warn("No data found in property or aggregation");
}
return result.result;
case "aggregation": // also applies for getAggregation convenience methods such as $ui5control.getItems()
// check weather to retrieve all elements in the aggreation as ui5 controls
if ((args.length > 0 && typeof args[0] === "boolean" && args[0] === false) || args.length === 0) {
// get all if param is false or undefined
return await this._retrieveElements(result.result);
}
else if (String(args[0]) && typeof args[0] === "number") {
// here we're retrieving the UI5 control at index args[0] from the aggregation
if (args[0] <= result.result.length) {
// retrieve only one
// need some code of separate feature branch here
const wdioElement = result.result[args[0]];
return await this._retrieveElement(wdioElement);
}
else {
console.error(`tried to get an control at index: ${args[0]} of an aggregation outside of aggregation length: ${result.result.length}`);
}
}
else {
// return wdio elements
return result.result;
}
case "unknown":
Logger.warn(`${methodName} returned unknown status`);
return null;
case "none":
return null;
default:
return null;
}
}
/**
* retrieve an aggregation's members as UI5 controls
*
* @param aggregationName
* @param webElement
* @throws will throw an error when no webElement was found
* @return {any}
*/
async _getAggregation(aggregationName, webElement = this._webElement) {
const _forceSelect = util.types.isProxy(this._forceSelect)
? await Promise.resolve(this._forceSelect)
: this._forceSelect;
const _logging = util.types.isProxy(this._logging)
? await Promise.resolve(this._logging)
: this._logging;
if (_forceSelect) {
await this._renewWebElementReference();
}
if (util.types.isProxy(webElement)) {
webElement = await Promise.resolve(webElement);
}
if (!webElement) {
throw Error("control could not be found");
}
const result = (await clientSide_getAggregation(webElement, aggregationName, this._browserInstance));
if (_logging) {
this._writeObjectResultLog(result, "_getAggregation()");
}
let wdiItems = [];
if (result.status === 0) {
wdiItems = await this._retrieveElements(result.result);
}
// else return empty array
return wdiItems;
}
/**
* used to update the wdio control reference
* this can be used to manually trigger an control reference update after a ui5 control rerendering
* this method is also used wdi5-internally to implement the extended forceSelect option
* @param {Boolean} isRefresh whether to treat the incoming call as a refresh attempt on a stale web element
*/
async _renewWebElementReference(isRefresh = false) {
if (this._domId) {
//> REVISIT: browser.allControls uses this._domId for selection
const newWebElement = (await this._getControl(isRefresh ? this._controlSelector : { selector: { id: this._domId } })).domElement; // added to have a more stable retrieval experience
if (!this.isInitialized()) {
this._webElement = undefined;
}
else {
this._webElement = newWebElement;
}
return newWebElement;
}
else if (this._wdio_ui5_key && !this._forceSelect) {
const fromCache = await this._getControl(this._controlSelector);
this._webElement = fromCache.domElement;
return fromCache.domElement;
}
}
/**
* expose internal API to refresh a stale web element reference
* @param {Boolean} asRefresh whether to treat the incoming call as a refresh attempt on a stale web element
*/
async renewWebElementReference(asRefresh = true) {
return await this._renewWebElementReference(asRefresh);
}
/**
* retrieve a DOM element via UI5 locator
* @param {sap.ui.test.RecordReplay.ControlSelector} controlSelector
* @return {[WebdriverIO.Element | String, [aProtoFunctions]]} UI5 control or error message, array of function names of this control
*/
async _getControl(controlSelector = this._controlSelector) {
// check whether we have a "by id regex" locator request
if (controlSelector.selector.id && typeof controlSelector.selector.id === "object") {
// make it a string for serializing into browser-scope and
// further processing there
controlSelector.selector.id = controlSelector.selector.id.toString();
}
// check whether we have a (partial) text matcher
// that should match:
// properties: {
// text: new RegExp(/.*ersi.*/gm)
// }
// ...but not:
// properties: {
// text: {
// regex: {
// source: '.*ersi.*',
// flags: 'gm'
// }
// }
// }
if (typeof controlSelector.selector.properties?.text === "object" &&
controlSelector.selector.properties?.text instanceof RegExp) {
// make it a string for serializing into browser-scope and
// further processing there
controlSelector.selector.properties.text = controlSelector.selector.properties.text.toString();
}
const _result = (await clientSide_getControl(controlSelector, this._browserInstance));
// When the WebDriver protocol is not used, the domElement is not set accordingly (via devtool protocol)
// Therefore we get element reference by calling browser execute function manually
if (_result.status === 0 && !_result.domElement[ELEMENT_KEY]) {
const elementReference = (await this._browserInstance.execute((id) => {
const webElement = document.evaluate(`//*[@id='${id}']`, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
return webElement;
}, _result.id));
_result.domElement = elementReference;
}
const { status, domElement, id, aProtoFunctions, className } = _result;
if (status === 0 && id) {
// only if the result is valid
this._generatedWdioMethods = wdioApi;
// add metadata
this._metadata.className = className;
this._domId = id;
// set the successful init param
this._initialisation = true;
}
else {
this._initialisation = false;
this._domId = undefined;
}
if (this._logging) {
this._writeObjectResultLog(_result, "_getControl()");
}
return { status: status, domElement: domElement, aProtoFunctions: aProtoFunctions };
}
_writeObjectResultLog(response, functionName) {
if (response.status > 0) {
Logger.error(`call of ${functionName} failed because of: ${response.message}`);
}
else if (response.status === 0) {
Logger.success(`call of function ${functionName} returned: ${JSON.stringify(response.id ? response.id : response.result)}`);
}
else {
Logger.warn(`Unknown status: ${functionName} returned: ${JSON.stringify(response.message)}`);
}
}
}
//# sourceMappingURL=wdi5-control.js.map