weikaopu-wdio-ui5-service
Version:
WebdriverIO plugin for testing UI5 browser-based apps
429 lines (395 loc) • 21.3 kB
JavaScript
async function clientSide_injectUI5(config, waitForUI5Timeout, browserInstance) {
return await browserInstance.executeAsync((waitForUI5Timeout, done) => {
if (window.bridge) {
// setup sap testing already done
done(true)
}
if (!window.sap || !window.sap.ui) {
// setup sap testing already cant be done due to sap namespace not present on the page
console.error("[browser wdi5] ERR: no ui5 present on page")
// only condition where to cancel the setup process
done(false)
}
// attach the function to be able to use the extracted method later
if (!window.bridge) {
// create empty
window.wdi5 = {
createMatcher: null,
isInitialized: false,
Log: null,
waitForUI5Options: {
timeout: waitForUI5Timeout,
interval: 400
},
objectMap: {
// GUID: {}
},
bWaitStarted: false,
asyncControlRetrievalQueue: []
}
/**
*
* @param {sap.ui.base.Object} object
* @returns uuid
*/
window.wdi5.saveObject = (object) => {
// This is a manual replacement for crypto.randomUUID()
// until it is only available in secure contexts.
// See https://github.com/WICG/uuid/issues/23
const uuid = ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)
)
window.wdi5.objectMap[uuid] = object
return uuid
}
// load UI5 logger
sap.ui.require(["sap/base/Log"], (Log) => {
// Logger is loaded -> can be use internally
// attach logger to wdi5 to be able to use it globally
window.wdi5.Log = Log
window.wdi5.Log.info("[browser wdi5] injected!")
})
sap.ui.require(["sap/ui/test/autowaiter/_autoWaiterAsync"], (_autoWaiterAsync) => {
window.wdi5.waitForUI5 = function (oOptions, callback, errorCallback) {
oOptions = oOptions || {}
_autoWaiterAsync.extendConfig(oOptions)
const startWaiting = function () {
window.wdi5.bWaitStarted = true
_autoWaiterAsync.waitAsync(function (sError) {
const nextWaitAsync = window.wdi5.asyncControlRetrievalQueue.shift()
if (nextWaitAsync) {
setTimeout(nextWaitAsync) //use setTimeout to postpone execution to the next event cycle, so that bWaitStarted in the UI5 _autoWaiterAsync is also set to false first
} else {
window.wdi5.bWaitStarted = false
}
if (sError) {
errorCallback(new Error(sError))
} else {
callback()
}
})
}
if (!window.wdi5.bWaitStarted) {
startWaiting()
} else {
window.wdi5.asyncControlRetrievalQueue.push(startWaiting)
}
}
window.wdi5.Log.info("[browser wdi5] window._autoWaiterAsync used in waitForUI5 function")
})
// attach new bridge
sap.ui.require(["sap/ui/test/RecordReplay"], (RecordReplay) => {
window.bridge = RecordReplay
window.fe_bridge = {} // empty init for fiori elements test api
window.wdi5.Log.info("[browser wdi5] APIs injected!")
window.wdi5.isInitialized = true
// here setup is successful
// known side effect this call triggers the back to node scope, the other sap.ui.require continue to run in background in browser scope
done(true)
})
// make exec function available on all ui5 controls, so more complex evaluations can be done on browser side for better performance
sap.ui.require(["sap/ui/core/Control"], (Control) => {
Control.prototype.exec = function (funcToEval, ...args) {
try {
return new Function("return " + funcToEval).apply(this).apply(this, args)
} catch (error) {
return { status: 1, message: error.toString() }
}
}
})
// make sure the resources are required
// TODO: "sap/ui/test/matchers/Sibling",
sap.ui.require(
[
"sap/ui/test/matchers/BindingPath",
"sap/ui/test/matchers/I18NText",
"sap/ui/test/matchers/Properties",
"sap/ui/test/matchers/Ancestor",
"sap/ui/test/matchers/LabelFor",
"sap/ui/test/matchers/Descendant",
"sap/ui/test/matchers/Interactable"
],
(BindingPath, I18NText, Properties, Ancestor, LabelFor, Descendant, Interactable) => {
/**
* used to dynamically create new control matchers when searching for elements
*/
window.wdi5.createMatcher = (oSelector) => {
// since 1.72.0 the declarative matchers are available. Before that
// you had to instantiate the matchers manually
const oldAPIVersion = "1.72.0"
// check whether we're looking for a control via regex
// hint: no IE support here :)
if (oSelector.id && oSelector.id.startsWith("/", 0)) {
const [sTarget, sRegEx, sFlags] = oSelector.id.match(/\/(.*)\/(.*)/)
oSelector.id = new RegExp(sRegEx, sFlags)
}
// match a regular regex as (partial) matcher
// properties: {
// text: /.*ersi.*/gm
// }
// but not a declarative style regex matcher
// properties: {
// text: {
// regex: {
// source: '.*ersi.*',
// flags: 'gm'
// }
// }
// }
if (
typeof oSelector.properties?.text === "string" &&
oSelector.properties?.text.startsWith("/", 0)
) {
const [_, sRegEx, sFlags] = oSelector.properties.text.match(/\/(.*)\/(.*)/)
oSelector.properties.text = new RegExp(sRegEx, sFlags)
}
if (oSelector.bindingPath) {
// TODO: for the binding Path there is no object creation
// fix (?) for 'leading slash issue' in propertyPath w/ a named model
// openui5 issue in github is open
const hasNamedModel =
oSelector.bindingPath.modelName && oSelector.bindingPath.modelName.length > 0
const isRootProperty =
oSelector.bindingPath.propertyPath &&
oSelector.bindingPath.propertyPath.charAt(0) === "/"
if (
hasNamedModel &&
isRootProperty &&
window.compareVersions.compare("1.81.0", sap.ui.version, ">")
) {
// attach the double leading /
// for UI5 < 1.81
oSelector.bindingPath.propertyPath = `/${oSelector.bindingPath.propertyPath}`
}
}
if (window.compareVersions.compare(oldAPIVersion, sap.ui.version, ">")) {
oSelector.matchers = []
// for version < 1.72 declarative matchers are not available
if (oSelector.bindingPath) {
oSelector.matchers.push(new BindingPath(oSelector.bindingPath))
delete oSelector.bindingPath
}
if (oSelector.properties) {
oSelector.matchers.push(new Properties(oSelector.properties))
delete oSelector.properties
}
if (oSelector.i18NText) {
oSelector.matchers.push(new I18NText(oSelector.i18NText))
delete oSelector.i18NText
}
if (oSelector.labelFor) {
oSelector.matchers.push(new LabelFor(oSelector.labelFor))
delete oSelector.labelFor
}
if (oSelector.ancestor) {
oSelector.matchers.push(new Ancestor(oSelector.ancestor))
delete oSelector.ancestor
}
}
/*
oSelector.matchers = []
// since for these matcher a constructor call is neccessary
if (oSelector.sibling && oSelector.sibling.options) {
// don't construct matcher if not needed
const options = oSelector.sibling.options
delete oSelector.sibling.options
oSelector.matchers.push(new Sibling(oSelector.sibling, options))
delete oSelector.sibling
}
if (oSelector.descendant && (typeof oSelector.descendant.bDirect !== 'undefined')) {
// don't construct matcher if not needed
const bDirect = oSelector.descendant.bDirect
delete oSelector.descendant.bDirect
oSelector.matchers.push(new Descendant(oSelector.descendant, !!bDirect))
delete oSelector.descendant
}
if (oSelector.ancestor && (typeof oSelector.ancestor.bDirect !== 'undefined')) {
// don't construct matcher if not needed
const bDirect = oSelector.ancestor.bDirect
delete oSelector.ancestor.bDirect
oSelector.matchers.push(new Ancestor(oSelector.ancestor, !!bDirect))
delete oSelector.ancestor
}
*/
return oSelector
}
/**
* extract the multi use function to get a UI5 Control from a JSON Webobejct
*/
window.wdi5.getUI5CtlForWebObj = (ui5Control) => {
//> REVISIT: refactor to https://ui5.sap.com/#/api/sap.ui.core.Element%23methods/sap.ui.core.Element.closestTo for UI5 >= 1.106
return jQuery(ui5Control).control(0)
}
/**
* gets a UI5 controls' methods to proxy from browser- to Node.js-runtime
*
* @param {sap.<lib>.<Control>} control UI5 control
* @returns {String[]} UI5 control's method names
*/
window.wdi5.retrieveControlMethods = (control) => {
// create keys of all parent prototypes
let properties = new Set()
let currentObj = control
do {
Object.getOwnPropertyNames(currentObj).map((item) => properties.add(item))
} while ((currentObj = Object.getPrototypeOf(currentObj)))
// filter for:
// @ts-expect-error - TS doesn't know that the keys are strings
let controlMethodsToProxy = [...properties.keys()].filter((item) => {
if (typeof control[item] === "function") {
// function
// filter private methods
if (item.startsWith("_")) {
return false
}
if (item.indexOf("Render") !== -1) {
return false
}
// filter not working methods
// and those with a specific api from wdi5/wdio-ui5-service
// prevent overwriting wdi5-control's own init method
const aFilterFunctions = ["$", "getAggregation", "constructor", "fireEvent", "init"]
if (aFilterFunctions.includes(item)) {
return false
}
// if not already discarded -> should be in the result
return true
}
return false
})
return controlMethodsToProxy
}
/**
* flatten all functions and properties on the Prototype directly into the returned object
* @param {object} obj
* @returns {object} all functions and properties of the inheritance chain in a flat structure
*/
window.wdi5.collapseObject = (obj) => {
let protoChain = []
let proto = obj
while (proto !== null) {
protoChain.unshift(proto)
proto = Object.getPrototypeOf(proto)
}
let collapsedObj = {}
protoChain.forEach((prop) => Object.assign(collapsedObj, prop))
return collapsedObj
}
/**
* used as a replacer function in JSON.stringify
* removes circular references in an object
* all credit to https://bobbyhadz.com/blog/javascript-typeerror-converting-circular-structure-to-json
*/
window.wdi5.getCircularReplacer = () => {
const seen = new WeakSet()
return (key, value) => {
if (typeof value === "object" && value !== null) {
if (seen.has(value)) {
return
}
seen.add(value)
}
return value
}
}
/**
* removes all empty collection members from an object,
* e.g. empty, null, or undefined array elements
*
* @param {object} obj
* @returns {object} obj without empty collection members
*/
window.wdi5.removeEmptyElements = (obj, i = 0) => {
for (let key in obj) {
if (obj[key] === null || key.startsWith("_")) {
delete obj[key]
} else if (Array.isArray(obj[key])) {
obj[key] = obj[key].filter(
(element) =>
element !== null &&
element !== undefined &&
element !== "" &&
Object.keys(element).length > 0
)
if (obj[key].length > 0) {
i++
window.wdi5.removeEmptyElements(obj[key], i)
}
} else if (typeof obj[key] === "object") {
i++
window.wdi5.removeEmptyElements(obj[key], i)
}
}
return obj
}
/**
* if parameter is JS primitive type
* returns {boolean}
* @param {*} test
*/
window.wdi5.isPrimitive = (test) => {
return test !== Object(test)
}
/**
* creates a array of objects containing their id as a property
* @param {[sap.ui.core.Control]} aControls
* @throws {Error} error if the aggregation was not found that has to be catched
* @return {Array} Object
*/
window.wdi5.createControlIdMap = (aControls, controlType = "") => {
// the array of UI5 controls need to be mapped (remove circular reference)
if (!aControls) {
throw new Error("Aggregation was not found!")
}
return aControls.map((element) => {
// just use the absolute ID of the control
if (
(controlType === "sap.m.ComboBox" || controlType === "sap.m.MultiComboBox") &&
element.data("InputWithSuggestionsListItem")
) {
return {
id: element.data("InputWithSuggestionsListItem").getId()
}
} else if (controlType === "sap.m.PlanningCalendar") {
return {
id: `${element.getId()}-CLI`
}
} else {
return {
id: element.getId()
}
}
})
}
/**
* creates an object containing their id as a property
* @param {sap.ui.core.Control} aControl
* @return {Object} Object
*/
window.wdi5.createControlId = (aControl) => {
// the array of UI5 controls need to be mapped (remove circular reference)
if (!Array.isArray(aControl)) {
// if in aControls is a single control -> create an array first
// this is causes by sap.ui.base.ManagedObject -> get Aggregation defines its return value as:
// sap.ui.base.ManagedObject or sap.ui.base.ManagedObject[] or null
// aControls = [aControls]
let item = {
id: aControl.getId()
}
return item
} else {
console.error("error creating new element by id of control: " + aControl)
}
}
window.wdi5.errorHandling = (done, error) => {
window.wdi5.Log.error("[browser wdi5] ERR: ", error)
done({ status: 1, message: error.toString() })
}
}
)
}
}, waitForUI5Timeout)
}
module.exports = {
clientSide_injectUI5
}