wdio-ui5-service
Version:
WebdriverIO plugin for testing UI5 browser-based apps
604 lines • 28.5 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.setup = setup;
exports.start = start;
exports.initMultiRemoteBrowser = initMultiRemoteBrowser;
exports.injectUI5 = injectUI5;
exports.checkForUI5Page = checkForUI5Page;
exports.authenticate = authenticate;
exports._addWdi5Commands = _addWdi5Commands;
const node_path_1 = require("node:path");
const promises_1 = require("node:fs/promises");
const node_os_1 = require("node:os");
const compare_versions_1 = require("compare-versions");
const wdi5_control_js_1 = require("./wdi5-control.js");
const wdi5_fe_js_1 = require("./wdi5-fe.js");
const injectUI5_js_1 = require("../client-side-js/injectUI5.js");
const injectXHRPatch_js_1 = require("../client-side-js/injectXHRPatch.js");
const getSelectorForElement_js_1 = require("../client-side-js/getSelectorForElement.js");
const _checkForUI5Ready_js_1 = require("../client-side-js/_checkForUI5Ready.js");
const getObject_js_1 = require("../client-side-js/getObject.js");
const getUI5Version_js_1 = require("../client-side-js/getUI5Version.js");
const _navTo_js_1 = require("../client-side-js/_navTo.js");
const allControls_js_1 = require("../client-side-js/allControls.js");
const setUI5FeaturesAvailable_js_1 = require("../client-side-js/setUI5FeaturesAvailable.js");
const Logger_js_1 = require("./Logger.js");
const wdi5_object_js_1 = require("./wdi5-object.js");
const BTPAuthenticator_js_1 = __importDefault(require("./authentication/BTPAuthenticator.js"));
const BasicAuthenticator_js_1 = __importDefault(require("./authentication/BasicAuthenticator.js"));
const CustomAuthenticator_js_1 = __importDefault(require("./authentication/CustomAuthenticator.js"));
const Office365Authenticator_js_1 = __importDefault(require("./authentication/Office365Authenticator.js"));
const Logger = Logger_js_1.Logger.getInstance();
/** store the status of initialization */
let _isInitialized = false;
/** stores the status of the setup process */
let _setupComplete = false;
/** currently running sap.ui.version */
let _sapUI5Version;
/** relay runtime config options from Service */
let _config;
async function setup(config, browserInstance) {
_config = config;
if (_setupComplete) {
// already setup done
return;
}
// jump-start the desired log level
Logger.setLogLevel(config?.wdi5?.logLevel || "error");
if (browser.isMultiremote) {
for (const name of browser.instances) {
initBrowser(browser[name]);
}
}
else {
initBrowser(browserInstance);
}
_setupComplete = true;
}
async function start(config, browserInstance) {
if (config?.wdi5?.url) {
// still support the old logic that we don't have breaking changes
Logger.warn(`'url' property in config file deprecated: please use 'baseUrl' only!`);
Logger.info(`open url: ${config.wdi5.url}`);
await browserInstance.url(config.wdi5.url);
}
else if (config?.baseUrl) {
Logger.info(`open url: ${config.baseUrl}`);
await browserInstance.url(config.baseUrl);
}
else {
const message = `wdi5 config is missing 'baseUrl' property! See https://ui5-community.github.io/wdi5/#/configuration?id=wdi5`;
Logger.error(message);
throw new Error(message);
}
}
function initMultiRemoteBrowser() {
;
["asControl", "goTo", "screenshot", "waitForUI5", "getUI5Version", "getSelectorForElement", "allControls"].forEach((command) => {
browser.addCommand(command, async (...args) => {
const multiRemoteInstance = browser;
const result = [];
multiRemoteInstance.instances.forEach((name) => {
result.push(multiRemoteInstance[name][command].apply(this, args));
});
return Promise.all(result);
});
});
}
function initBrowser(browserInstance) {
// init control cache
if (!browserInstance._controls) {
Logger.info("creating internal control map");
browserInstance._controls = Object.create(null);
}
_addWdi5Commands(browserInstance);
if (!browserInstance.fe) {
browserInstance.fe = wdi5_fe_js_1.WDI5FE;
}
_setupComplete = true;
}
async function setUi5FeaturesAvailable(ui5Version, browserInstance) {
const ui5FeaturesAvailable = {
version: ui5Version,
useFetchWaiter: (0, compare_versions_1.compare)(ui5Version, "1.114.0", ">"),
useGetComponentById: (0, compare_versions_1.compare)(ui5Version, "1.120.0", ">"),
useUI5ElementClosestTo: (0, compare_versions_1.compare)(ui5Version, "1.108.0", ">"),
useOldHashChanger: (0, compare_versions_1.compare)("1.75.0", ui5Version, ">"),
useOldDoubleLeadingSlash: (0, compare_versions_1.compare)("1.81.0", ui5Version, ">"),
useOldMatcherAPI: (0, compare_versions_1.compare)("1.72.0", ui5Version, ">")
};
return await (0, setUI5FeaturesAvailable_js_1.clientSide_setUI5FeaturesAvailable)(ui5FeaturesAvailable, browserInstance);
}
function checkUI5Version(ui5Version) {
if ((0, compare_versions_1.compare)(ui5Version, "1.60.0", "<")) {
// the record replay api is only available since 1.60
const message = "The UI5 version of your application is too low. Minimum required is 1.60!";
Logger.error(message);
throw new Error(message);
}
}
/**
* function library to setup the webdriver to UI5 bridge, it runs all the initial setup
* make sap/ui/test/RecordReplay accessible via wdio
* attach the sap/ui/test/RecordReplay object to the application context window object as 'bridge'
*/
async function injectUI5(config, browserInstance) {
if (!config?.wdi5) {
//Fetching config from global variable
config.wdi5 = global.__wdi5Config.wdi5;
}
const waitForUI5Timeout = config?.wdi5?.waitForUI5Timeout || 15000;
let result = true;
// unify timeouts across Node.js- and browser-scope
// align browser script timeout with wdi5 setting (+ leverage)
// this mostly affects browser.execute()
const timeout = waitForUI5Timeout + 1000;
await browserInstance.setTimeout({ script: timeout });
Logger.debug(`browser script timeout set to ${timeout}`);
if (typeof browserInstance.getTimeouts === "function") {
Logger.debug(`browser timeouts are ${JSON.stringify(await browserInstance.getTimeouts(), null, 2)}`);
}
const version = await browserInstance.getUI5Version();
checkUI5Version(version);
await setUi5FeaturesAvailable(version, browserInstance);
// BIDI does not allow to pass functions inside of the browser scope
await (0, injectXHRPatch_js_1.clientSide_injectXHRPatch)(config.wdi5, browserInstance);
result = result && (await (0, injectUI5_js_1.clientSide_injectUI5)(waitForUI5Timeout, browserInstance));
// we are not using _controls as an array, we are using it as an object. That's why the length property
// is not updated right away: https://stackoverflow.com/a/4424026
if (Object.keys(browserInstance._controls).length > 0) {
Logger.info("invalidating control map!");
browserInstance._controls = Object.create(null);
}
if (result) {
// set when call returns
_isInitialized = true;
Logger.success("successfully initialized wdio-ui5 bridge");
}
else {
Logger.error("bridge was not initialized correctly");
}
return result;
}
async function checkForUI5Page(browserInstance) {
// wait till the loading finished and the state is "completed"
await browserInstance.waitUntil(async () => {
const checkState = await browserInstance.execute(() => {
return { state: document.readyState, sapReady: !!window.sap };
});
return checkState.state === "complete" && checkState.sapReady;
});
// sap in global window namespace denotes (most likely :) ) that ui5 is present
return await browserInstance.execute(() => {
return !!window.sap;
});
}
async function authenticate(options, browserInstanceName) {
switch (options.provider) {
case "BTP": {
const btp = new BTPAuthenticator_js_1.default(options, browserInstanceName);
if (options.disableBiometricAuthentication) {
await btp.disableBiometricAuthentication();
}
await btp.login();
break;
}
case "BasicAuth":
await new BasicAuthenticator_js_1.default(options, browserInstanceName, _config.baseUrl).login();
break;
case "Office365":
await new Office365Authenticator_js_1.default(options, browserInstanceName).login();
break;
case "custom":
await new CustomAuthenticator_js_1.default(options, browserInstanceName).login();
break;
default:
break;
}
}
//******************************************************************************************
// private
/**
* creates a string valid as object key from a selector
* @param selector
* @returns wdio_ui5_key
*/
function _createWdioUI5KeyFromSelector(selector) {
const wdi5_ui5_key = [];
wdi5_ui5_key.push(selector.id);
wdi5_ui5_key.push(selector.viewName);
wdi5_ui5_key.push(selector.controlType);
wdi5_ui5_key.push(JSON.stringify(selector.bindingPath));
wdi5_ui5_key.push(JSON.stringify(selector.i18NText));
wdi5_ui5_key.push(JSON.stringify(selector.descendant));
wdi5_ui5_key.push(JSON.stringify(selector.labelFor));
wdi5_ui5_key.push(JSON.stringify(selector.properties));
wdi5_ui5_key.push(JSON.stringify(selector.ancestor));
return wdi5_ui5_key.filter((data) => !!data).join("|");
}
/**
* does a basic validation of a wdi5ControlSelector
* @param wdi5Selector: wdi5Selector
* @returns {boolean} if the given selector is a valid selector
*/
function _verifySelector(wdi5Selector) {
/* eslint-disable no-prototype-builtins */
if (wdi5Selector.hasOwnProperty("selector")) {
if (wdi5Selector.selector.hasOwnProperty("id") ||
wdi5Selector.selector.hasOwnProperty("viewName") ||
wdi5Selector.selector.hasOwnProperty("bindingPath") ||
wdi5Selector.selector.hasOwnProperty("controlType") ||
wdi5Selector.selector.hasOwnProperty("i18NText") ||
wdi5Selector.selector.hasOwnProperty("labelFor") ||
wdi5Selector.selector.hasOwnProperty("descendant") ||
wdi5Selector.selector.hasOwnProperty("ancestor") ||
wdi5Selector.selector.hasOwnProperty("properties") ||
wdi5Selector.selector.hasOwnProperty("sibling") ||
wdi5Selector.selector.hasOwnProperty("interactable")
/* eslint-enable no-prototype-builtins */
) {
return true;
}
Logger.error("Specified selector is not valid. Please use at least one of: 'id, viewName, bindingPath, controlType, i18NText, labelFor, ancestor, properties, descendant, sibling, interactable' -> abort");
return false;
}
Logger.error("Specified selector is not valid -> property 'selector' is missing");
return false;
}
async function _addWdi5Commands(browserInstance) {
browserInstance.addCommand("_asControl", async (wdi5Selector) => {
if (!_verifySelector(wdi5Selector)) {
return "ERROR: Specified selector is not valid -> abort";
}
const internalKey = wdi5Selector.wdio_ui5_key || _createWdioUI5KeyFromSelector(wdi5Selector.selector);
// either retrieve and cache a UI5 control
// or return a cached version
if (!browserInstance._controls?.[internalKey] || wdi5Selector.forceSelect /* always retrieve control */) {
Logger.info(`creating internal control with id ${internalKey}`);
wdi5Selector.wdio_ui5_key = internalKey;
performance.mark("retrieveSingleControlStart"); //> TODO: bind to debug log level
const wdi5Control = await new wdi5_control_js_1.WDI5Control({ browserInstance }).init(wdi5Selector, wdi5Selector.forceSelect);
performance.mark("retrieveSingleControlEnd"); //> TODO: bind to debug log level
const retrieveSingleControlMeasure = performance.measure("retrieveSingleControl", "retrieveSingleControlStart", "retrieveSingleControlEnd");
Logger.info(`_asControl() needed ${retrieveSingleControlMeasure.duration} for ${internalKey}`);
browserInstance._controls[internalKey] = wdi5Control;
}
else {
Logger.info(`reusing internal control with id ${internalKey}`);
}
return browserInstance._controls[internalKey];
});
browserInstance.addCommand("asObject", async (_uuid) => {
const _result = await (0, getObject_js_1.clientSide_getObject)(_uuid, browserInstance);
const { uuid, status, aProtoFunctions, className, object } = _result;
if (status === 0) {
// create new wdi5-Object
const wdiObject = new wdi5_object_js_1.WDI5Object(uuid, aProtoFunctions, object);
return wdiObject;
}
_writeObjectResultLog(_result, "asObject()");
return { status: status, aProtoFunctions: aProtoFunctions, className: className, uuid: uuid };
});
// no fluent API -> no private method
browserInstance.addCommand("allControls", async (wdi5Selector) => {
if (!_verifySelector(wdi5Selector)) {
return "ERROR: Specified selector is not valid -> abort";
}
const internalKey = wdi5Selector.wdio_ui5_key || _createWdioUI5KeyFromSelector(wdi5Selector.selector);
// REVISIT all elements receive the same! internal key
if (!browserInstance._controls?.[internalKey] || wdi5Selector.forceSelect /* always retrieve control */) {
wdi5Selector.wdio_ui5_key = internalKey;
Logger.info(`creating internal controls with id ${internalKey}`);
browserInstance._controls[internalKey] = await _allControls(wdi5Selector, browserInstance);
return browserInstance._controls[internalKey];
}
else {
Logger.info(`reusing internal control with id ${internalKey}`);
}
return browserInstance._controls[internalKey];
});
/**
* Find the best control selector for a DOM element. A selector uniquely represents a single element.
* The 'best' selector is the one with which it is most likely to uniquely identify a control with the least possible inspection of the control tree.
* @param {object} oOptions
* @param {object} oOptions.domElement - DOM Element to search for
* @param {object} oOptions.settings - ui5 settings object
* @param {boolean} oOptions.settings.preferViewId
*/
browserInstance.addCommand("getSelectorForElement", async (oOptions) => {
const result = await (0, getSelectorForElement_js_1.clientSide_getSelectorForElement)(oOptions, browserInstance);
if (result.status === 1) {
Logger.error("ERROR: getSelectorForElement() failed because of: " + result.message);
return result.message;
}
else if (result.status === 0) {
Logger.log(`SUCCESS: getSelectorForElement() returned: ${JSON.stringify(result.result)}`);
return result.result;
}
});
browserInstance.addCommand("getUI5Version", async () => {
if (!_sapUI5Version) {
const resultVersion = await (0, getUI5Version_js_1.clientSide_getUI5Version)(browserInstance);
_sapUI5Version = resultVersion;
}
return _sapUI5Version;
});
/**
* uses the UI5 native waitForUI5 function to wait for all promises to be settled
*/
browserInstance.addCommand("waitForUI5", async () => {
return await _waitForUI5(browserInstance);
});
/**
* wait for ui5 and take a screenshot
*/
browserInstance.addCommand("screenshot", async (fileAppendix) => {
await _waitForUI5(browserInstance);
await _writeScreenshot(browserInstance, fileAppendix);
});
browserInstance.addCommand("goTo", async (oOptions) => {
// allow for method sig to be both
// wdi5()...goTo("#/accounts/create")
// wdi5()...goTo({sHash:"#/accounts/create"})
let sHash;
if (typeof oOptions === "string") {
sHash = oOptions;
}
else {
sHash = oOptions.sHash;
}
const oRoute = oOptions.oRoute;
if (sHash && sHash.length > 0) {
// we need to still support the old url property
// TODO: use type wdi5Config not any
if (browserInstance.options?.wdi5?.url) {
const url = browserInstance.options.wdi5["url"] || (await browserInstance.getUrl());
// navigate via hash if defined
if (url && url.length > 0 && url !== "#") {
// prefix url config if is not just a hash (#)
const currentUrl = await browserInstance.getUrl();
const alreadyNavByHash = currentUrl.includes("#");
const navToRoot = url.startsWith("/");
if (alreadyNavByHash && !navToRoot) {
await browserInstance.url(`${currentUrl.split("#")[0]}${sHash}`);
}
else {
await browserInstance.url(`${url}${sHash}`);
}
}
else if (url && url.length > 0 && url === "#") {
// route without the double hash
await browserInstance.url(`${sHash}`);
}
else {
// just a fallback
await browserInstance.url(`${sHash}`);
}
}
else {
await browserInstance.url(sHash);
}
}
else if (oRoute && oRoute.sName) {
// navigate using the ui5 router
// sComponentId, sName, oParameters, oComponentTargetInfo, bReplace
await _navTo(oRoute.sComponentId, oRoute.sName, oRoute.oParameters, oRoute.oComponentTargetInfo, oRoute.bReplace, browserInstance);
}
else {
Logger.error("ERROR: navigating to another page");
}
});
// inspired by and after staring a long time hard at:
// https://stackoverflow.com/questions/51635378/keep-object-chainable-using-async-methods
// https://github.com/Shigma/prochain
// https://github.com/l8js/l8/blob/main/src/core/liquify.js
// channel the async function browser._asControl (init'ed via browser.addCommand above) through a Proxy
// in order to chain calls of any subsequent UI5 api methods on the retrieved UI5 control:
// await browser.asControl(selector).methodOfUI5control().anotherMethodOfUI5control()
// the way this works is twofold:
// 1. (almost) all UI5 $control's API methods are reinjected from the browser-scope
// into the Node.js scope via async wdi5._executeControlMethod(), which in term actually calls
// the reinjected API method within the browser scope
// 2. the execution of each UI5 $control's API method (via async wdi5._executeControlMethod() => Promise) is then chained
// via the below "then"-ing of the (async wdi5._executeControlMethod() => Promise)-Promises with the help of
// the a Proxy and a recursive `handler` function
if (!browserInstance.asControl) {
browserInstance.asControl = function (ui5ControlSelector) {
const asyncMethods = ["then", "catch", "finally"];
const functionQueue = [];
// we need to do the same operation as in the 'init' of 'wdi5-control.ts'
const logging = ui5ControlSelector?.logging ?? true;
function makeFluent(target) {
const promise = Promise.resolve(target);
const handler = {
get(_, prop) {
functionQueue.push(prop);
return asyncMethods.includes(prop)
? (...boundArgs) => makeFluent(promise[prop](...boundArgs))
: makeFluent(promise.then((object) => {
// when object is undefined the previous function call failed
try {
return object[prop];
// eslint-disable-next-line @typescript-eslint/no-unused-vars
}
catch (error) {
// different node versions return a different `error.message` so we use our own message
if (logging) {
Logger.error(`Cannot read property '${prop}' in the execution queue!`);
}
}
}));
},
apply(_, thisArg, boundArgs) {
return makeFluent(
// When "targetFunction" is empty we can assume that there are errors in the execution queue
promise.then((targetFunction) => {
if (targetFunction) {
return Reflect.apply(targetFunction, thisArg, boundArgs);
}
else {
// a functionQueue without a 'then' can be ignored
// as the original error was already logged
if (functionQueue.includes("then") && logging) {
functionQueue.splice(functionQueue.indexOf("then"));
Logger.error(`One of the calls in the queue "${functionQueue.join("().")}()" previously failed!`);
}
}
}));
}
};
return new Proxy(function () { }, handler);
}
return makeFluent(browserInstance._asControl(ui5ControlSelector));
};
}
}
/**
* 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 function _allControls(controlSelector = this._controlSelector, browserInstance = browser) {
// 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();
}
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();
}
// pre retrive control information
const response = await (0, allControls_js_1.clientSide_allControls)(controlSelector, browserInstance);
_writeObjectResultLog(response, "allControls()");
if (response.status === 0) {
const retrievedElements = response.result;
const resultWDi5Elements = [];
// domElement: domElement, id: id, aProtoFunctions
for await (const cControl of retrievedElements) {
const _controlSelector = {
...controlSelector,
selector: { ...controlSelector.selector, id: cControl.id }
};
const oOptions = {
controlSelector: _controlSelector,
wdio_ui5_key: controlSelector.wdio_ui5_key
? `${controlSelector.wdio_ui5_key}__${cControl.id}`
: undefined,
forceSelect: controlSelector.forceSelect,
generatedUI5Methods: cControl.aProtoFunctions,
webdriverRepresentation: null,
webElement: cControl.domElement,
domId: cControl.id,
browserInstance
};
// FIXME: multi remote support by providing browserInstance in constructor
resultWDi5Elements.push(new wdi5_control_js_1.WDI5Control(oOptions));
}
return resultWDi5Elements;
}
else {
return "[wdi5] Error: fetch multiple elements failed: " + response.message;
}
}
/**
* can be called to make sure before you access any eg. DOM Node the ui5 framework is done loading
* @returns {Boolean} if the UI5 page is fully loaded and ready to interact.
*/
async function _waitForUI5(browserInstance) {
if (_isInitialized) {
// injectUI5 was already called and was successful attached
return await _checkForUI5Ready(browserInstance);
}
else {
if (await injectUI5(_config, browserInstance)) {
return await _checkForUI5Ready(browserInstance);
}
else {
return false;
}
}
}
/**
* check for UI5 via the RecordReplay.waitForUI5 method
*/
async function _checkForUI5Ready(browserInstance) {
const ready = false;
if (_isInitialized) {
// can only be executed when RecordReplay is attached
return await (0, _checkForUI5Ready_js_1.clientSide_checkForUI5Ready)(browserInstance);
}
return ready;
}
/**
* @param fileAppendix
*/
async function _writeScreenshot(browserInstance, fileAppendix = "-screenshot") {
// if config param screenshotsDisabled is set to true -> no screenshots will be taken
if (!_config?.wdi5 || _config.wdi5["screenshotsDisabled"]) {
Logger.warn("screenshot skipped due to config parameter");
return;
}
// browserInstance.screenshot returns the screenshot as a base64 string
const screenshot = await browserInstance.takeScreenshot();
const seed = _getDateString();
const _path = _config.wdi5.screenshotPath || (0, node_os_1.tmpdir)();
const path = (0, node_path_1.resolve)(_path, `${seed}-${fileAppendix}.png`);
try {
await (0, promises_1.writeFile)(path, screenshot, "base64");
Logger.success(`screenshot at ${path} created`);
}
catch (error) {
Logger.error(`error while saving screenshot: ${error}`);
}
}
/**
* generates date string with format M-d-hh-mm-ss
* @returns {String}
*/
function _getDateString() {
const x = new Date();
return `${x.getMonth() + 1}-${x.getDate()}-${x.getHours()}-${x.getMinutes()}-${x.getSeconds()}`;
}
/**
* navigates to a UI5 route using the Component router
* @param {String} sComponentId
* @param {String} sName
* @param {Object} oParameters
* @param {Object} oComponentTargetInfo
* @param {Boolean} bReplace
*/
async function _navTo(sComponentId, sName, oParameters, oComponentTargetInfo, bReplace, browserInstance) {
const result = await (0, _navTo_js_1.clientSide__navTo)(sComponentId, sName, oParameters, oComponentTargetInfo, bReplace, browserInstance);
if (result.status === 1) {
Logger.error("ERROR: navigation using UI5 router failed because of: " + result.message);
return result.result;
}
else if (result.status === 0) {
Logger.info(`SUCCESS: navigation using UI5 router to hash: ${JSON.stringify(result.status)}`);
return result.result;
}
}
/**
* create log based on the status of result.status
* @param {Array} result
* @param {*} functionName
*/
function _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-bridge.js.map