How to use isPackageOrBundle method in Appium Base Driver

Best JavaScript code snippet using appium-base-driver

driver.js

Source:driver.js Github

copy

Full Screen

...125 log.warn("Consider setting 'automationName' capability to " +126 "'uiautomator2' on Android >= 6, since UIAutomator framework " +127 'is not maintained anymore by the OS vendor.');128 }129 if (this.helpers.isPackageOrBundle(this.opts.app)) {130 // user provided package instead of app for 'app' capability, massage options131 this.opts.appPackage = this.opts.app;132 this.opts.app = null;133 }134 if (this.opts.app) {135 // find and copy, or download and unzip an app url or path136 this.opts.app = await this.helpers.configureApp(this.opts.app, APP_EXTENSION);137 await this.checkAppPresent();138 } else if (this.appOnDevice) {139 // the app isn't an actual app file but rather something we want to140 // assume is on the device and just launch via the appPackage141 log.info(`App file was not listed, instead we're going to run ` +142 `${this.opts.appPackage} directly on the device`);143 await this.checkPackagePresent();144 }145 // Some cloud services using appium launch the avd themselves, so we ensure netspeed146 // is set for emulators by calling adb.networkSpeed before running the app147 if (util.hasValue(this.opts.networkSpeed)) {148 if (!this.isEmulator()) {149 log.warn('Sorry, networkSpeed capability is only available for emulators');150 } else {151 const networkSpeed = ensureNetworkSpeed(this.adb, this.opts.networkSpeed);152 await this.adb.networkSpeed(networkSpeed);153 }154 }155 // check if we have to enable/disable gps before running the application156 if (util.hasValue(this.opts.gpsEnabled)) {157 if (this.isEmulator()) {158 log.info(`Trying to ${this.opts.gpsEnabled ? 'enable' : 'disable'} gps location provider`);159 await this.adb.toggleGPSLocationProvider(this.opts.gpsEnabled);160 } else {161 log.warn('Sorry! gpsEnabled capability is only available for emulators');162 }163 }164 await this.startAndroidSession(this.opts);165 return [sessionId, this.caps];166 } catch (e) {167 // ignoring delete session exception if any and throw the real error168 // that happened while creating the session.169 try {170 await this.deleteSession();171 } catch (ign) {}172 throw e;173 }174 }175 isEmulator () {176 return helpers.isEmulator(this.adb, this.opts);177 }178 setAvdFromCapabilities (caps) {179 if (this.opts.avd) {180 log.info('avd name defined, ignoring device name and platform version');181 } else {182 if (!caps.deviceName) {183 log.errorAndThrow('avd or deviceName should be specified when reboot option is enables');184 }185 if (!caps.platformVersion) {186 log.errorAndThrow('avd or platformVersion should be specified when reboot option is enabled');187 }188 let avdDevice = caps.deviceName.replace(/[^a-zA-Z0-9_.]/g, '-');189 this.opts.avd = `${avdDevice}__${caps.platformVersion}`;190 }191 }192 get appOnDevice () {193 return this.helpers.isPackageOrBundle(this.opts.app) || (!this.opts.app &&194 this.helpers.isPackageOrBundle(this.opts.appPackage));195 }196 get isChromeSession () {197 return helpers.isChromeBrowser(this.opts.browserName);198 }199 async onSettingsUpdate (key, value) {200 if (key === 'ignoreUnimportantViews') {201 await this.setCompressedLayoutHierarchy(value);202 }203 }204 async startAndroidSession () {205 log.info(`Starting Android session`);206 // set up the device to run on (real or emulator, etc)207 this.defaultIME = await helpers.initDevice(this.adb, this.opts);208 // set actual device name, udid, platform version, screen size, model and manufacturer details....

Full Screen

Full Screen

helpers.js

Source:helpers.js Github

copy

Full Screen

1import _ from 'lodash';2import path from 'path';3import url from 'url';4import logger from './logger';5import _fs from 'fs';6import B from 'bluebird';7import { tempDir, fs, util, zip } from 'appium-support';8import request from 'request';9import asyncRequest from 'request-promise';10import LRU from 'lru-cache';11import AsyncLock from 'async-lock';12import sanitize from 'sanitize-filename';13const ZIP_EXTS = ['.zip', '.ipa'];14const ZIP_MIME_TYPES = [15 'application/zip',16 'application/x-zip-compressed',17 'multipart/x-zip',18];19const APPLICATIONS_CACHE = new LRU({20 max: 100,21});22const APPLICATIONS_CACHE_GUARD = new AsyncLock();23const SANITIZE_REPLACEMENT = '-';24const DEFAULT_BASENAME = 'appium-app';25async function retrieveHeaders (link) {26 try {27 const response = await asyncRequest({28 url: link,29 method: 'HEAD',30 resolveWithFullResponse: true,31 timeout: 5000,32 });33 return response.headers;34 } catch (e) {35 logger.debug(`Cannot send HEAD request to '${link}'. Original error: ${e.message}`);36 }37 return {};38}39function getCachedApplicationPath (link, currentModified) {40 if (!APPLICATIONS_CACHE.has(link) || !currentModified) {41 return null;42 }43 const {lastModified, fullPath} = APPLICATIONS_CACHE.get(link);44 if (lastModified && currentModified.getTime() <= lastModified.getTime()) {45 logger.debug(`Reusing already downloaded application at '${fullPath}'`);46 return fullPath;47 }48 logger.debug(`'Last-Modified' timestamp of '${link}' has been updated. ` +49 `An updated copy of the application is going to be downloaded.`);50 return null;51}52function verifyAppExtension (app, supportedAppExtensions) {53 if (supportedAppExtensions.includes(path.extname(app))) {54 return app;55 }56 throw new Error(`New app path '${app}' did not have extension(s) '${supportedAppExtensions}'`);57}58async function configureApp (app, supportedAppExtensions) {59 if (!_.isString(app)) {60 // immediately shortcircuit if not given an app61 return;62 }63 if (!_.isArray(supportedAppExtensions)) {64 supportedAppExtensions = [supportedAppExtensions];65 }66 let newApp = app;67 let shouldUnzipApp = false;68 let archiveHash = null;69 let currentModified = null;70 const {protocol, pathname} = url.parse(newApp);71 const isUrl = ['http:', 'https:'].includes(protocol);72 return await APPLICATIONS_CACHE_GUARD.acquire(app, async () => {73 if (isUrl) {74 // Use the app from remote URL75 logger.info(`Using downloadable app '${newApp}'`);76 const headers = await retrieveHeaders(newApp);77 if (headers['last-modified']) {78 logger.debug(`Last-Modified: ${headers['last-modified']}`);79 currentModified = new Date(headers['last-modified']);80 }81 const cachedPath = getCachedApplicationPath(app, currentModified);82 if (cachedPath) {83 if (await fs.exists(cachedPath)) {84 logger.info(`Reusing the previously downloaded application at '${cachedPath}'`);85 return verifyAppExtension(cachedPath, supportedAppExtensions);86 }87 logger.info(`The application at '${cachedPath}' does not exist anymore. Deleting it from the cache`);88 APPLICATIONS_CACHE.del(app);89 }90 let fileName = null;91 const basename = sanitize(path.basename(decodeURIComponent(pathname)), {92 replacement: SANITIZE_REPLACEMENT93 });94 const extname = path.extname(basename);95 // to determine if we need to unzip the app, we have a number of places96 // to look: content type, content disposition, or the file extension97 if (ZIP_EXTS.includes(extname)) {98 fileName = basename;99 shouldUnzipApp = true;100 }101 if (headers['content-type']) {102 logger.debug(`Content-Type: ${headers['content-type']}`);103 // the filetype may not be obvious for certain urls, so check the mime type too104 if (ZIP_MIME_TYPES.includes(headers['content-type'])) {105 if (!fileName) {106 fileName = `${DEFAULT_BASENAME}.zip`;107 }108 shouldUnzipApp = true;109 }110 }111 if (headers['content-disposition'] && /^attachment/i.test(headers['content-disposition'])) {112 logger.debug(`Content-Disposition: ${headers['content-disposition']}`);113 const match = /filename="([^"]+)/i.exec(headers['content-disposition']);114 if (match) {115 fileName = sanitize(match[1], {116 replacement: SANITIZE_REPLACEMENT117 });118 shouldUnzipApp = shouldUnzipApp || ZIP_EXTS.includes(path.extname(fileName));119 }120 }121 if (!fileName) {122 // assign the default file name and the extension if none has been detected123 const resultingName = basename124 ? basename.substring(0, basename.length - extname.length)125 : DEFAULT_BASENAME;126 let resultingExt = extname;127 if (!supportedAppExtensions.includes(resultingExt)) {128 logger.info(`The current file extension '${resultingExt}' is not supported. ` +129 `Defaulting to '${_.first(supportedAppExtensions)}'`);130 resultingExt = _.first(supportedAppExtensions);131 }132 fileName = `${resultingName}${resultingExt}`;133 }134 const targetPath = await tempDir.path({135 prefix: fileName,136 suffix: '',137 });138 newApp = await downloadApp(newApp, targetPath);139 } else if (await fs.exists(newApp)) {140 // Use the local app141 logger.info(`Using local app '${newApp}'`);142 shouldUnzipApp = ZIP_EXTS.includes(path.extname(newApp));143 } else {144 let errorMessage = `The application at '${newApp}' does not exist or is not accessible`;145 // protocol value for 'C:\\temp' is 'c:', so we check the length as well146 if (_.isString(protocol) && protocol.length > 2) {147 errorMessage = `The protocol '${protocol}' used in '${newApp}' is not supported. ` +148 `Only http: and https: protocols are supported`;149 }150 throw new Error(errorMessage);151 }152 if (shouldUnzipApp) {153 const archivePath = newApp;154 archiveHash = await fs.hash(archivePath);155 if (APPLICATIONS_CACHE.has(app) && archiveHash === APPLICATIONS_CACHE.get(app).hash) {156 const {fullPath} = APPLICATIONS_CACHE.get(app);157 if (await fs.exists(fullPath)) {158 if (archivePath !== app) {159 await fs.rimraf(archivePath);160 }161 logger.info(`Will reuse previously cached application at '${fullPath}'`);162 return verifyAppExtension(fullPath, supportedAppExtensions);163 }164 logger.info(`The application at '${fullPath}' does not exist anymore. Deleting it from the cache`);165 APPLICATIONS_CACHE.del(app);166 }167 const tmpRoot = await tempDir.openDir();168 try {169 newApp = await unzipApp(archivePath, tmpRoot, supportedAppExtensions);170 } finally {171 if (newApp !== archivePath && archivePath !== app) {172 await fs.rimraf(archivePath);173 }174 }175 logger.info(`Unzipped local app to '${newApp}'`);176 } else if (!path.isAbsolute(newApp)) {177 newApp = path.resolve(process.cwd(), newApp);178 logger.warn(`The current application path '${app}' is not absolute ` +179 `and has been rewritten to '${newApp}'. Consider using absolute paths rather than relative`);180 app = newApp;181 }182 verifyAppExtension(newApp, supportedAppExtensions);183 if (app !== newApp && (archiveHash || currentModified)) {184 APPLICATIONS_CACHE.set(app, {185 hash: archiveHash,186 lastModified: currentModified,187 fullPath: newApp,188 });189 }190 return newApp;191 });192}193async function downloadApp (app, targetPath) {194 let appUrl;195 try {196 appUrl = url.parse(app);197 } catch (err) {198 throw new Error(`Invalid App URL (${app})`);199 }200 try {201 const started = process.hrtime();202 // don't use request-promise here, we need streams203 await new B((resolve, reject) => {204 request(appUrl.href)205 .on('error', reject) // handle real errors, like connection errors206 .on('response', (res) => {207 // handle responses that fail, like 404s208 if (res.statusCode >= 400) {209 return reject(`Error downloading file: ${res.statusCode}`);210 }211 })212 .pipe(_fs.createWriteStream(targetPath))213 .on('close', resolve);214 });215 const [seconds, ns] = process.hrtime(started);216 const secondsElapsed = seconds + ns / 1E09;217 const {size} = await fs.stat(targetPath);218 logger.debug(`'${appUrl.href}' (${util.toReadableSizeString(size)}) ` +219 `has been downloaded to '${targetPath}' in ${secondsElapsed.toFixed(3)}s`);220 if (secondsElapsed >= 2) {221 const bytesPerSec = Math.floor(size / secondsElapsed);222 logger.debug(`Approximate download speed: ${util.toReadableSizeString(bytesPerSec)}/s`);223 }224 return targetPath;225 } catch (err) {226 throw new Error(`Problem downloading app from url ${appUrl.href}: ${err.message}`);227 }228}229async function walkDir (dir) {230 const result = [];231 for (const name of await fs.readdir(dir)) {232 const currentPath = path.join(dir, name);233 result.push(currentPath);234 if ((await fs.stat(currentPath)).isDirectory()) {235 result.push(...(await walkDir(currentPath)));236 }237 }238 return result;239}240async function unzipApp (zipPath, dstRoot, supportedAppExtensions) {241 await zip.assertValidZip(zipPath);242 if (!_.isArray(supportedAppExtensions)) {243 supportedAppExtensions = [supportedAppExtensions];244 }245 const tmpRoot = await tempDir.openDir();246 try {247 logger.debug(`Unzipping '${zipPath}'`);248 await zip.extractAllTo(zipPath, tmpRoot);249 const allExtractedItems = await walkDir(tmpRoot);250 logger.debug(`Extracted ${allExtractedItems.length} item(s) from '${zipPath}'`);251 const isSupportedAppItem = (relativePath) => supportedAppExtensions.includes(path.extname(relativePath))252 || _.some(supportedAppExtensions, (x) => relativePath.includes(`${x}${path.sep}`));253 const itemsToKeep = allExtractedItems254 .map((itemPath) => path.relative(tmpRoot, itemPath))255 .filter((relativePath) => isSupportedAppItem(relativePath))256 .map((relativePath) => path.resolve(tmpRoot, relativePath));257 const itemsToRemove = _.difference(allExtractedItems, itemsToKeep)258 // Avoid parent folders to be recursively removed259 .filter((itemToRemovePath) => !_.some(itemsToKeep, (itemToKeepPath) => itemToKeepPath.startsWith(itemToRemovePath)));260 await B.all(itemsToRemove, async (itemPath) => {261 if (await fs.exists(itemPath)) {262 await fs.rimraf(itemPath);263 }264 });265 const allBundleItems = (await walkDir(tmpRoot))266 .map((itemPath) => path.relative(tmpRoot, itemPath))267 .filter((relativePath) => isSupportedAppItem(relativePath))268 // Get the top level match269 .sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);270 if (_.isEmpty(allBundleItems)) {271 throw new Error(`App zip unzipped OK, but we could not find ${supportedAppExtensions} bundle(s) ` +272 `in it. Make sure your archive contains ${supportedAppExtensions} package(s) ` +273 `and nothing else`);274 }275 const matchedBundle = _.first(allBundleItems);276 logger.debug(`Matched ${allBundleItems.length} item(s) in the extracted archive. ` +277 `Assuming '${matchedBundle}' is the correct bundle`);278 await fs.mv(path.resolve(tmpRoot, matchedBundle), path.resolve(dstRoot, matchedBundle), {279 mkdirp: true280 });281 return path.resolve(dstRoot, matchedBundle);282 } finally {283 await fs.rimraf(tmpRoot);284 }285}286function isPackageOrBundle (app) {287 return (/^([a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+)+$/).test(app);288}289function getCoordDefault (val) {290 // going the long way and checking for undefined and null since291 // we can't be assured `elId` is a string and not an int. Same292 // thing with destElement below.293 return util.hasValue(val) ? val : 0.5;294}295function getSwipeTouchDuration (waitGesture) {296 // the touch action api uses ms, we want seconds297 // 0.8 is the default time for the operation298 let duration = 0.8;299 if (typeof waitGesture.options.ms !== 'undefined' && waitGesture.options.ms) {300 duration = waitGesture.options.ms / 1000;301 if (duration === 0) {302 // set to a very low number, since they wanted it fast303 // but below 0.1 becomes 0 steps, which causes errors304 duration = 0.1;305 }306 }307 return duration;308}309/**310 * Finds all instances 'firstKey' and create a duplicate with the key 'secondKey',311 * Do the same thing in reverse. If we find 'secondKey', create a duplicate with the key 'firstKey'.312 *313 * This will cause keys to be overwritten if the object contains 'firstKey' and 'secondKey'.314 * @param {*} input Any type of input315 * @param {String} firstKey The first key to duplicate316 * @param {String} secondKey The second key to duplicate317 */318function duplicateKeys (input, firstKey, secondKey) {319 // If array provided, recursively call on all elements320 if (_.isArray(input)) {321 return input.map((item) => duplicateKeys(item, firstKey, secondKey));322 }323 // If object, create duplicates for keys and then recursively call on values324 if (_.isPlainObject(input)) {325 const resultObj = {};326 for (let [key, value] of _.toPairs(input)) {327 const recursivelyCalledValue = duplicateKeys(value, firstKey, secondKey);328 if (key === firstKey) {329 resultObj[secondKey] = recursivelyCalledValue;330 } else if (key === secondKey) {331 resultObj[firstKey] = recursivelyCalledValue;332 }333 resultObj[key] = recursivelyCalledValue;334 }335 return resultObj;336 }337 // Base case. Return primitives without doing anything.338 return input;339}340export {341 configureApp, isPackageOrBundle, getCoordDefault, getSwipeTouchDuration, duplicateKeys,...

Full Screen

Full Screen

helpers-specs.js

Source:helpers-specs.js Github

copy

Full Screen

...5const should = chai.should();6describe('helpers', function () {7 describe('#isPackageOrBundle', function () {8 it('should accept packages and bundles', function () {9 isPackageOrBundle('io.appium.testapp').should.be.true;10 });11 it('should not accept non-packages or non-bundles', function () {12 isPackageOrBundle('foo').should.be.false;13 isPackageOrBundle('/path/to/an.app').should.be.false;14 isPackageOrBundle('/path/to/an.apk').should.be.false;15 });16 });17 describe('#duplicateKeys', function () {18 it('should translate key in an object', function () {19 duplicateKeys({'foo': 'hello world'}, 'foo', 'bar').should.eql({'foo': 'hello world', 'bar': 'hello world'});20 });21 it('should translate key in an object within an object', function () {22 duplicateKeys({'key': {'foo': 'hello world'}}, 'foo', 'bar').should.eql({'key': {'foo': 'hello world', 'bar': 'hello world'}});23 });24 it('should translate key in an object with an array', function () {25 duplicateKeys([26 {'key': {'foo': 'hello world'}},27 {'foo': 'HELLO WORLD'}28 ], 'foo', 'bar').should.eql([...

Full Screen

Full Screen

Using AI Code Generation

copy

Full Screen

1var AppiumBaseDriver = require('appium-base-driver');2var appiumBaseDriver = new AppiumBaseDriver();3var isPackageOrBundle = appiumBaseDriver.helpers.isPackageOrBundle;4var isPackageOrBundleResult = isPackageOrBundle('com.apple.mobilesafari');5var AppiumBaseDriver = require('appium-base-driver');6var appiumBaseDriver = new AppiumBaseDriver();7var isPackageOrBundle = appiumBaseDriver.helpers.isPackageOrBundle;8var isPackageOrBundleResult = isPackageOrBundle('com.apple.mobilesafari');9 throw err;10var AppiumBaseDriver = require('appium-base-driver/lib/basedriver/driver');11var appiumBaseDriver = new AppiumBaseDriver();12var isPackageOrBundle = appiumBaseDriver.helpers.isPackageOrBundle;13var isPackageOrBundleResult = isPackageOrBundle('com.apple.mobilesafari');14var AppiumBaseDriver = require('appium-base-driver');15var appiumBaseDriver = new AppiumBaseDriver();16var isPackageOrBundle = appiumBaseDriver.helpers.isPackageOrBundle;17var isPackageOrBundleResult = isPackageOrBundle('com.apple.mobilesafari');

Full Screen

Using AI Code Generation

copy

Full Screen

1const AppiumBaseDriver = require('./appium-base-driver');2const driver = new AppiumBaseDriver();3const isPackageOrBundle = driver.isPackageOrBundle;4const isPackageOrBundle = driver.isPackageOrBundle;5console.log(isPackageOrBundle('com.android.settings'));6console.log(isPackageOrBundle('com.android.settings.apk'));7console.log(isPackageOrBundle('com.android.settings.app'));8console.log(isPackageOrBundle('com.android.settings.ipa'));9console.log(isPackageOrBundle('com.android.settings.zip'));10console.log(isPackageOrBundle('com.android.settings.tar'));11const isPackageOrBundle = require('appium-base-driver').isPackageOrBundle;12console.log(isPackageOrBundle('com.android.settings'));13console.log(isPackageOrBundle('com.android.settings.apk'));14console.log(isPackageOrBundle('com.android.settings.app'));15console.log(isPackageOrBundle('com.android.settings.ipa'));16console.log(isPackageOrBundle('com.android.settings.zip'));17console.log(isPackageOrBundle('com.android.settings.tar'));18const isPackageOrBundle = require('appium-base-driver').isPackageOrBundle;19console.log(isPackageOrBundle('com.android.settings'));20console.log(isPackageOrBundle('com.android.settings.apk'));21console.log(isPackageOrBundle('com.android.settings.app'));22console.log(isPackageOrBundle('com.android.settings.ipa'));23console.log(isPackageOrBundle('com.android.settings.zip'));24console.log(isPackageOrBundle('com.android.settings.tar'));25const isPackageOrBundle = require('appium-base-driver').isPackageOrBundle;26console.log(isPackageOrBundle('com.android.settings'));27console.log(isPackageOrBundle('com.android.settings.apk'));

Full Screen

Using AI Code Generation

copy

Full Screen

1const BaseDriver = require('appium-base-driver');2const {isPackageOrBundle} = BaseDriver;3const pkg = 'com.android.chrome';4const bundle = 'com.android.chrome/com.google.android.apps.chrome.Main';5const app = '/path/to/app.apk';6const AndroidDriver = require('appium-android-driver');7const {isPackageOrBundle} = AndroidDriver;8const pkg = 'com.android.chrome';9const bundle = 'com.android.chrome/com.google.android.apps.chrome.Main';10const app = '/path/to/app.apk';11const IOSDriver = require('appium-ios-driver');12const {isPackageOrBundle} = IOSDriver;13const pkg = 'com.apple.mobilesafari';14const bundle = 'com.apple.mobilesafari';15const app = '/path/to/app.apk';16const WindowsDriver = require('appium-windows-driver');17const {isPackageOrBundle} = WindowsDriver;18const pkg = 'Microsoft.WindowsCalculator_8wekyb3d8bbwe!App';19const bundle = 'Microsoft.WindowsCalculator_8wekyb3d8bbwe!App';20const app = '/path/to/app.apk';21const MacDriver = require('appium-mac-driver');22const {isPackageOrBundle} = MacDriver;23const pkg = 'com.apple.Safari';24const bundle = 'com.apple.Safari';25const app = '/path/to/app.apk';26console.log(isPackageOrBundle(pkg

Full Screen

Using AI Code Generation

copy

Full Screen

1describe('test', function() {2 it('should skip test if package name is a bundle', function() {3 if (driver.isPackageOrBundle('com.apple.mobilesafari')) {4 this.skip();5 }6 });7});

Full Screen

Using AI Code Generation

copy

Full Screen

1var appium = require('appium');2var appiumServer = new appium.AppiumServer(4723);3appiumServer.start().then(function(){4 var appPath = appiumDriver.isPackageOrBundle('com.example.android.apis');5 console.log(appPath);6 appiumDriver.startSession({7 });8});9var appium = require('appium');10var appiumServer = new appium.AppiumServer(4723);11appiumServer.start().then(function(){12 appiumDriver.startSession({13 }).then(function(){14 appiumDriver.findElement('accessibility id', 'xxx').then(function(element){15 console.log(element);16 });17 });18});19{ [Error: An element could not be located on the page using the given search parameters.]20 { status: 7,21 message: 'An element could not be located on the page using the given search parameters.' } }

Full Screen

Automation Testing Tutorials

Learn to execute automation testing from scratch with LambdaTest Learning Hub. Right from setting up the prerequisites to run your first automation test, to following best practices and diving deeper into advanced test scenarios. LambdaTest Learning Hubs compile a list of step-by-step guides to help you be proficient with different test automation frameworks i.e. Selenium, Cypress, TestNG etc.

LambdaTest Learning Hubs:

YouTube

You could also refer to video tutorials over LambdaTest YouTube channel to get step by step demonstration from industry experts.

Run Appium Base Driver automation tests on LambdaTest cloud grid

Perform automation testing on 3000+ real desktop and mobile devices online.

Try LambdaTest Now !!

Get 100 minutes of automation test minutes FREE!!

Next-Gen App & Browser Testing Cloud

Was this article helpful?

Helpful

NotHelpful