diff --git a/.github/workflows/doc-generation.yml b/.github/workflows/doc-generation.yml index 0b8afc3e6..c5b84a0fb 100644 --- a/.github/workflows/doc-generation.yml +++ b/.github/workflows/doc-generation.yml @@ -41,7 +41,7 @@ jobs: - name: Generate and update documentation run: | npm run def && npm run docs - git add docs/**/*.md docs/**/*.js -f + git add docs/**/*.md if ! git diff --cached --quiet; then git commit -m "DOC: Autogenerate and update documentation" --no-verify fi diff --git a/docs/build/ApiDataFactory.js b/docs/build/ApiDataFactory.js deleted file mode 100644 index e8f9c5dd6..000000000 --- a/docs/build/ApiDataFactory.js +++ /dev/null @@ -1,410 +0,0 @@ -const path = require('path'); - -const Helper = require('@codeceptjs/helper'); -const REST = require('./REST'); - -/** - * Helper for managing remote data using REST API. - * Uses data generators like [rosie](https://github.com/rosiejs/rosie) or factory girl to create new record. - * - * By defining a factory you set the rules of how data is generated. - * This data will be saved on server via REST API and deleted in the end of a test. - * - * ## Use Case - * - * Acceptance tests interact with a websites using UI and real browser. - * There is no way to create data for a specific test other than from user interface. - * That makes tests slow and fragile. Instead of testing a single feature you need to follow all creation/removal process. - * - * This helper solves this problem. - * Most of web application have API, and it can be used to create and delete test records. - * By combining REST API with Factories you can easily create records for tests: - * - * ```js - * I.have('user', { login: 'davert', email: 'davert@mail.com' }); - * let id = await I.have('post', { title: 'My first post'}); - * I.haveMultiple('comment', 3, {post_id: id}); - * ``` - * - * To make this work you need - * - * 1. REST API endpoint which allows to perform create / delete requests and - * 2. define data generation rules - * - * ### Setup - * - * Install [Rosie](https://github.com/rosiejs/rosie) and [Faker](https://www.npmjs.com/package/faker) libraries. - * - * ```sh - * npm i rosie @faker-js/faker --save-dev - * ``` - * - * Create a factory file for a resource. - * - * See the example for Posts factories: - * - * ```js - * // tests/factories/posts.js - * - * const { Factory } = require('rosie'); - * const { faker } = require('@faker-js/faker'); - * - * module.exports = new Factory() - * // no need to set id, it will be set by REST API - * .attr('author', () => faker.name.findName()) - * .attr('title', () => faker.lorem.sentence()) - * .attr('body', () => faker.lorem.paragraph()); - * ``` - * For more options see [rosie documentation](https://github.com/rosiejs/rosie). - * - * Then configure ApiDataHelper to match factories and REST API: - - * ### Configuration - * - * ApiDataFactory has following config options: - * - * * `endpoint`: base URL for the API to send requests to. - * * `cleanup` (default: true): should inserted records be deleted up after tests - * * `factories`: list of defined factories - * * `returnId` (default: false): return id instead of a complete response when creating items. - * * `headers`: list of headers - * * `REST`: configuration for REST requests - * - * See the example: - * - * ```js - * ApiDataFactory: { - * endpoint: "http://user.com/api", - * cleanup: true, - * headers: { - * 'Content-Type': 'application/json', - * 'Accept': 'application/json', - * }, - * factories: { - * post: { - * uri: "/posts", - * factory: "./factories/post", - * }, - * comment: { - * factory: "./factories/comment", - * create: { post: "/comments/create" }, - * delete: { post: "/comments/delete/{id}" }, - * fetchId: (data) => data.result.id - * } - * } - * } - * ``` - - * It is required to set REST API `endpoint` which is the baseURL for all API requests. - * Factory file is expected to be passed via `factory` option. - * - * This Helper uses [REST](http://codecept.io/helpers/REST/) helper and accepts its configuration in "REST" section. - * For instance, to set timeout you should add: - * - * ```js - * "ApiDataFactory": { - * "REST": { - * "timeout": "100000", - * } - * } - * ``` - * - * ### Requests - * - * By default to create a record ApiDataFactory will use endpoint and plural factory name: - * - * * create: `POST {endpoint}/{resource} data` - * * delete: `DELETE {endpoint}/{resource}/id` - * - * Example (`endpoint`: `http://app.com/api`): - * - * * create: POST request to `http://app.com/api/users` - * * delete: DELETE request to `http://app.com/api/users/1` - * - * This behavior can be configured with following options: - * - * * `uri`: set different resource uri. Example: `uri: account` => `http://app.com/api/account`. - * * `create`: override create options. Expected format: `{ method: uri }`. Example: `{ "post": "/users/create" }` - * * `delete`: override delete options. Expected format: `{ method: uri }`. Example: `{ "post": "/users/delete/{id}" }` - * - * Requests can also be overridden with a function which returns [axois request config](https://github.com/axios/axios#request-config). - * - * ```js - * create: (data) => ({ method: 'post', url: '/posts', data }), - * delete: (id) => ({ method: 'delete', url: '/posts', data: { id } }) - * - * ``` - * - * Requests can be updated on the fly by using `onRequest` function. For instance, you can pass in current session from a cookie. - * - * ```js - * onRequest: async (request) => { - * // using global codeceptjs instance - * let cookie = await codeceptjs.container.helpers('WebDriver').grabCookie('session'); - * request.headers = { Cookie: `session=${cookie.value}` }; - * } - * ``` - * - * ### Responses - * - * By default `I.have()` returns a promise with a created data: - * - * ```js - * let client = await I.have('client'); - * ``` - * - * Ids of created records are collected and used in the end of a test for the cleanup. - * If you need to receive `id` instead of full response enable `returnId` in a helper config: - * - * ```js - * // returnId: false - * let clientId = await I.have('client'); - * // clientId == 1 - * - * // returnId: true - * let clientId = await I.have('client'); - * // client == { name: 'John', email: 'john@snow.com' } - * ``` - * - * By default `id` property of response is taken. This behavior can be changed by setting `fetchId` function in a factory config. - * - * - * ```js - * factories: { - * post: { - * uri: "/posts", - * factory: "./factories/post", - * fetchId: (data) => data.result.posts[0].id - * } - * } - * ``` - * - * - * ## Methods - */ -class ApiDataFactory extends Helper { - constructor(config) { - super(config); - - const defaultConfig = { - cleanup: true, - REST: {}, - factories: {}, - returnId: false, - }; - this.config = Object.assign(defaultConfig, this.config); - - if (this.config.headers) this.config.REST.defaultHeaders = this.config.headers; - if (this.config.onRequest) this.config.REST.onRequest = this.config.onRequest; - this.restHelper = new REST(Object.assign(this.config.REST, { endpoint: this.config.endpoint })); - this.factories = this.config.factories; - - for (const factory in this.factories) { - const factoryConfig = this.factories[factory]; - if (!factoryConfig.uri && !factoryConfig.create) { - throw new Error(`Uri for factory "${factory}" is not defined. Please set "uri" parameter: - - "factories": { - "${factory}": { - "uri": ... - `); - } - - if (!factoryConfig.create) factoryConfig.create = { post: factoryConfig.uri }; - if (!factoryConfig.delete) factoryConfig.delete = { delete: `${factoryConfig.uri}/{id}` }; - - this.factories[factory] = factoryConfig; - } - - this.created = {}; - Object.keys(this.factories).forEach(f => this.created[f] = []); - } - - static _checkRequirements() { - try { - require('axios'); - require('rosie'); - } catch (e) { - return ['axios', 'rosie']; - } - } - - _after() { - if (!this.config.cleanup || this.config.cleanup === false) { - return Promise.resolve(); - } - const promises = []; - - // clean up all created items - for (const factoryName in this.created) { - const createdItems = this.created[factoryName]; - if (!createdItems.length) continue; - this.debug(`Deleting ${createdItems.length} ${factoryName}(s)`); - for (const id in createdItems) { - promises.push(this._requestDelete(factoryName, createdItems[id])); - } - } - return Promise.all(promises); - } - - /** - * Generates a new record using factory and saves API request to store it. - * - * ```js - * // create a user - * I.have('user'); - * // create user with defined email - * // and receive it when inside async function - * const user = await I.have('user', { email: 'user@user.com'}); - * // create a user with options that will not be included in the final request - * I.have('user', { }, { age: 33, height: 55 }) - * ``` - * - * @param {*} factory factory to use - * @param {*} [params] predefined parameters - * @param {*} [options] options for programmatically generate the attributes - * @returns {Promise<*>} - */ - have(factory, params, options) { - const item = this._createItem(factory, params, options); - this.debug(`Creating ${factory} ${JSON.stringify(item)}`); - return this._requestCreate(factory, item); - } - - /** - * Generates bunch of records and saves multiple API requests to store them. - * - * ```js - * // create 3 posts - * I.haveMultiple('post', 3); - * - * // create 3 posts by one author - * I.haveMultiple('post', 3, { author: 'davert' }); - * - * // create 3 posts by one author with options - * I.haveMultiple('post', 3, { author: 'davert' }, { publish_date: '01.01.1997' }); - * ``` - * - * @param {*} factory - * @param {*} times - * @param {*} [params] - * @param {*} [options] - */ - haveMultiple(factory, times, params, options) { - const promises = []; - for (let i = 0; i < times; i++) { - promises.push(this.have(factory, params, options)); - } - return Promise.all(promises); - } - - _createItem(model, data, options) { - if (!this.factories[model]) { - throw new Error(`Factory ${model} is not defined in config`); - } - let modulePath = this.factories[model].factory; - try { - try { - require.resolve(modulePath); - } catch (e) { - modulePath = path.join(global.codecept_dir, modulePath); - } - // check if the new syntax `export default new Factory()` is used and loads the builder, otherwise loads the module that used old syntax `module.exports = new Factory()`. - const builder = require(modulePath).default || require(modulePath); - return builder.build(data, options); - } catch (err) { - throw new Error(`Couldn't load factory file from ${modulePath}, check that - - "factories": { - "${model}": { - "factory": "./path/to/factory" - -points to valid factory file. -Factory file should export an object with build method. - -Current file error: ${err.message}`); - } - } - - _fetchId(body, factory) { - if (this.config.factories[factory].fetchId) { - return this.config.factories[factory].fetchId(body); - } - if (body.id) return body.id; - if (body[factory] && body[factory].id) return body[factory].id; - return null; - } - - /** - * Executes request to create a record in API. - * Can be replaced from a in custom helper. - * - * @param {*} factory - * @param {*} data - */ - _requestCreate(factory, data) { - let request = createRequestFromFunction(this.factories[factory].create, data); - - if (!request) { - const method = Object.keys(this.factories[factory].create)[0]; - const url = this.factories[factory].create[method]; - request = { - method, - url, - data, - }; - } - - request.baseURL = this.config.endpoint; - - return this.restHelper._executeRequest(request).then((resp) => { - const id = this._fetchId(resp.data, factory); - this.created[factory].push(id); - this.debugSection('Created', `Id: ${id}`); - if (this.config.returnId) return id; - return resp.data; - }); - } - - /** - * Executes request to delete a record in API - * Can be replaced from a custom helper. - * - * @param {*} factory - * @param {*} id - */ - _requestDelete(factory, id) { - if (!this.factories[factory].delete) return; - let request = createRequestFromFunction(this.factories[factory].delete, id); - - if (!request) { - const method = Object.keys(this.factories[factory].delete)[0]; - - const url = this.factories[factory].delete[method].replace('{id}', id); - - request = { - method, - url, - }; - } - - request.baseURL = this.config.endpoint; - - if (request.url.match(/^undefined/)) { - return this.debugSection('Please configure the delete request in your ApiDataFactory helper', 'delete: () => ({ method: \'DELETE\', url: \'/api/users\' })'); - } - - return this.restHelper._executeRequest(request).then(() => { - const idx = this.created[factory].indexOf(id); - this.debugSection('Deleted Id', `Id: ${id}`); - this.created[factory].splice(idx, 1); - }); - } -} - -module.exports = ApiDataFactory; - -function createRequestFromFunction(param, data) { - if (typeof param !== 'function') return; - return param(data); -} diff --git a/docs/build/Appium.js b/docs/build/Appium.js deleted file mode 100644 index 8a5dfe509..000000000 --- a/docs/build/Appium.js +++ /dev/null @@ -1,2051 +0,0 @@ -let webdriverio; - -const fs = require('fs'); -const axios = require('axios').default; -const { v4: uuidv4 } = require('uuid'); - -const Webdriver = require('./WebDriver'); -const AssertionFailedError = require('../assert/error'); -const { truth } = require('../assert/truth'); -const recorder = require('../recorder'); -const Locator = require('../locator'); -const ConnectionRefused = require('./errors/ConnectionRefused'); - -const mobileRoot = '//*'; -const webRoot = 'body'; -const supportedPlatform = { - android: 'Android', - iOS: 'iOS', -}; - -const vendorPrefix = { - appium: 'appium', -}; - -/** - * Appium helper extends [Webdriver](http://codecept.io/helpers/WebDriver/) helper. - * It supports all browser methods and also includes special methods for mobile apps testing. - * You can use this helper to test Web on desktop and mobile devices and mobile apps. - * - * ## Appium Installation - * - * Appium is an open source test automation framework for use with native, hybrid and mobile web apps that implements the WebDriver protocol. - * It allows you to run Selenium tests on mobile devices and also test native, hybrid and mobile web apps. - * - * Download and install [Appium](https://appium.io/docs/en/2.1/) - * - * ```sh - * npm install -g appium - * ``` - * - * Launch the daemon: `appium` - * - * ## Helper configuration - * - * This helper should be configured in codecept.conf.ts or codecept.conf.js - * - * * `appiumV2`: set this to true if you want to run tests with AppiumV2. See more how to setup [here](https://codecept.io/mobile/#setting-up) - * * `app`: Application path. Local path or remote URL to an .ipa or .apk file, or a .zip containing one of these. Alias to desiredCapabilities.appPackage - * * `host`: (default: 'localhost') Appium host - * * `port`: (default: '4723') Appium port - * * `platform`: (Android or IOS), which mobile OS to use; alias to desiredCapabilities.platformName - * * `restart`: restart browser or app between tests (default: true), if set to false cookies will be cleaned but browser window will be kept and for apps nothing will be changed. - * * `desiredCapabilities`: [], Appium capabilities, see below - * * `platformName` - Which mobile OS platform to use - * * `appPackage` - Java package of the Android app you want to run - * * `appActivity` - Activity name for the Android activity you want to launch from your package. - * * `deviceName`: The kind of mobile device or emulator to use - * * `platformVersion`: Mobile OS version - * * `app` - The absolute local path or remote http URL to an .ipa or .apk file, or a .zip containing one of these. Appium will attempt to install this app binary on the appropriate device first. - * * `browserName`: Name of mobile web browser to automate. Should be an empty string if automating an app instead. - * - * Example Android App: - * - * ```js - * { - * helpers: { - * Appium: { - * platform: "Android", - * desiredCapabilities: { - * appPackage: "com.example.android.myApp", - * appActivity: "MainActivity", - * deviceName: "OnePlus3", - * platformVersion: "6.0.1" - * } - * } - * } - * } - * ``` - * - * Example iOS Mobile Web with local Appium: - * - * ```js - * { - * helpers: { - * Appium: { - * platform: "iOS", - * url: "https://the-internet.herokuapp.com/", - * desiredCapabilities: { - * deviceName: "iPhone X", - * platformVersion: "12.0", - * browserName: "safari" - * } - * } - * } - * } - * ``` - * - * Example iOS Mobile Web on BrowserStack: - * - * ```js - * { - * helpers: { - * Appium: { - * host: "hub-cloud.browserstack.com", - * port: 4444, - * user: process.env.BROWSERSTACK_USER, - * key: process.env.BROWSERSTACK_KEY, - * platform: "iOS", - * url: "https://the-internet.herokuapp.com/", - * desiredCapabilities: { - * realMobile: "true", - * device: "iPhone 8", - * os_version: "12", - * browserName: "safari" - * } - * } - * } - * } - * ``` - * - * Example Android App using AppiumV2 on BrowserStack: - * - * ```js - * { - * helpers: { - * Appium: { - * appiumV2: true, - * host: "hub-cloud.browserstack.com", - * port: 4444, - * user: process.env.BROWSERSTACK_USER, - * key: process.env.BROWSERSTACK_KEY, - * app: `bs://c700ce60cf1gjhgjh3ae8ed9770ghjg5a55b8e022f13c5827cg`, - * browser: '', - * desiredCapabilities: { - * 'appPackage': data.packageName, - * 'deviceName': process.env.DEVICE || 'Google Pixel 3', - * 'platformName': process.env.PLATFORM || 'android', - * 'platformVersion': process.env.OS_VERSION || '10.0', - * 'automationName': process.env.ENGINE || 'UIAutomator2', - * 'newCommandTimeout': 300000, - * 'androidDeviceReadyTimeout': 300000, - * 'androidInstallTimeout': 90000, - * 'appWaitDuration': 300000, - * 'autoGrantPermissions': true, - * 'gpsEnabled': true, - * 'isHeadless': false, - * 'noReset': false, - * 'noSign': true, - * 'bstack:options' : { - * "appiumVersion" : "2.0.1", - * }, - * } - * } - * } - * } - * ``` - * - * Additional configuration params can be used from - * - * ## Access From Helpers - * - * Receive Appium client from a custom helper by accessing `browser` property: - * - * ```js - * let browser = this.helpers['Appium'].browser - * ``` - * - * ## Methods - */ -class Appium extends Webdriver { - /** - * Appium Special Methods for Mobile only - * @augments WebDriver - */ - - // @ts-ignore - constructor(config) { - super(config); - - this.isRunning = false; - if (config.appiumV2 === true) { - this.appiumV2 = true; - } - this.axios = axios.create(); - - webdriverio = require('webdriverio'); - if (!config.appiumV2) { - console.log('The Appium core team does not maintain Appium 1.x anymore since the 1st of January 2022. Please migrating to Appium 2.x by adding appiumV2: true to your config.'); - console.log('More info: https://bit.ly/appium-v2-migration'); - console.log('This Appium 1.x support will be removed in next major release.'); - } - } - - _validateConfig(config) { - if (!(config.app || config.platform) && !config.browser) { - throw new Error(` - Appium requires either platform and app or a browser to be set. - Check your codeceptjs config file to ensure these are set properly - { - "helpers": { - "Appium": { - "app": "/path/to/app/package" - "platform": "MOBILE_OS", - } - } - } - `); - } - - // set defaults - const defaults = { - // webdriverio defaults - protocol: 'http', - hostname: '0.0.0.0', // webdriverio specs - port: 4723, - path: '/wd/hub', - - // config - waitForTimeout: 1000, // ms - logLevel: 'error', - capabilities: {}, - deprecationWarnings: false, - restart: true, - manualStart: false, - timeouts: { - script: 0, // ms - }, - }; - - // override defaults with config - config = Object.assign(defaults, config); - - config.baseUrl = config.url || config.baseUrl; - if (config.desiredCapabilities && Object.keys(config.desiredCapabilities).length) { - config.capabilities = this.appiumV2 === true ? this._convertAppiumV2Caps(config.desiredCapabilities) : config.desiredCapabilities; - } - - if (this.appiumV2) { - config.capabilities[`${vendorPrefix.appium}:deviceName`] = config[`${vendorPrefix.appium}:device`] || config.capabilities[`${vendorPrefix.appium}:deviceName`]; - config.capabilities[`${vendorPrefix.appium}:browserName`] = config[`${vendorPrefix.appium}:browser`] || config.capabilities[`${vendorPrefix.appium}:browserName`]; - config.capabilities[`${vendorPrefix.appium}:app`] = config[`${vendorPrefix.appium}:app`] || config.capabilities[`${vendorPrefix.appium}:app`]; - config.capabilities[`${vendorPrefix.appium}:tunnelIdentifier`] = config[`${vendorPrefix.appium}:tunnelIdentifier`] || config.capabilities[`${vendorPrefix.appium}:tunnelIdentifier`]; // Adding the code to connect to sauce labs via sauce tunnel - } else { - config.capabilities.deviceName = config.device || config.capabilities.deviceName; - config.capabilities.browserName = config.browser || config.capabilities.browserName; - config.capabilities.app = config.app || config.capabilities.app; - config.capabilities.tunnelIdentifier = config.tunnelIdentifier || config.capabilities.tunnelIdentifier; // Adding the code to connect to sauce labs via sauce tunnel - } - - config.capabilities.platformName = config.platform || config.capabilities.platformName; - config.waitForTimeoutInSeconds = config.waitForTimeout / 1000; // convert to seconds - - // [CodeceptJS compatible] transform host to hostname - config.hostname = config.host || config.hostname; - - if (!config.app && config.capabilities.browserName) { - this.isWeb = true; - this.root = webRoot; - } else { - this.isWeb = false; - this.root = mobileRoot; - } - - this.platform = null; - if (config.capabilities[`${vendorPrefix.appium}:platformName`]) { - this.platform = config.capabilities[`${vendorPrefix.appium}:platformName`].toLowerCase(); - } - - if (config.capabilities.platformName) { - this.platform = config.capabilities.platformName.toLowerCase(); - } - - return config; - } - - _convertAppiumV2Caps(capabilities) { - const _convertedCaps = {}; - for (const [key, value] of Object.entries(capabilities)) { - if (!key.startsWith(vendorPrefix.appium)) { - if (key !== 'platformName' && key !== 'bstack:options') { - _convertedCaps[`${vendorPrefix.appium}:${key}`] = value; - } else { - _convertedCaps[`${key}`] = value; - } - } else { - _convertedCaps[`${key}`] = value; - } - } - return _convertedCaps; - } - - static _config() { - return [{ - name: 'app', - message: 'Application package. Path to file or url', - default: 'http://localhost', - }, { - name: 'platform', - message: 'Mobile Platform', - type: 'list', - choices: ['iOS', supportedPlatform.android], - default: supportedPlatform.android, - }, { - name: 'device', - message: 'Device to run tests on', - default: 'emulator', - }]; - } - - async _startBrowser() { - if (this.appiumV2 === true) { - this.options.capabilities = this._convertAppiumV2Caps(this.options.capabilities); - this.options.desiredCapabilities = this._convertAppiumV2Caps(this.options.desiredCapabilities); - } - - try { - if (this.options.multiremote) { - this.browser = await webdriverio.multiremote(this.options.multiremote); - } else { - this.browser = await webdriverio.remote(this.options); - } - } catch (err) { - if (err.toString().indexOf('ECONNREFUSED')) { - throw new ConnectionRefused(err); - } - throw err; - } - this.$$ = this.browser.$$.bind(this.browser); - - this.isRunning = true; - if (this.options.timeouts && this.isWeb) { - await this.defineTimeout(this.options.timeouts); - } - if (this.options.windowSize === 'maximize' && !this.platform) { - const res = await this.browser.execute('return [screen.width, screen.height]'); - return this.browser.windowHandleSize({ - width: res.value[0], - height: res.value[1], - }); - } - if (this.options.windowSize && this.options.windowSize.indexOf('x') > 0 && !this.platform) { - const dimensions = this.options.windowSize.split('x'); - await this.browser.windowHandleSize({ - width: dimensions[0], - height: dimensions[1], - }); - } - } - - async _after() { - if (!this.isRunning) return; - if (this.options.restart) { - this.isRunning = false; - return this.browser.deleteSession(); - } - if (this.isWeb && !this.platform) { - return super._after(); - } - } - - async _withinBegin(context) { - if (this.isWeb) { - return super._withinBegin(context); - } - if (context === 'webview') { - return this.switchToWeb(); - } - if (typeof context === 'object') { - if (context.web) return this.switchToWeb(context.web); - if (context.webview) return this.switchToWeb(context.webview); - } - return this.switchToContext(context); - } - - _withinEnd() { - if (this.isWeb) { - return super._withinEnd(); - } - return this.switchToNative(); - } - - _buildAppiumEndpoint() { - const { - protocol, port, hostname, path, - } = this.browser.options; - // Build path to Appium REST API endpoint - return `${protocol}://${hostname}:${port}${path}`; - } - - /** - * Execute code only on iOS - * - * ```js - * I.runOnIOS(() => { - * I.click('//UIAApplication[1]/UIAWindow[1]/UIAButton[1]'); - * I.see('Hi, IOS', '~welcome'); - * }); - * ``` - * - * Additional filter can be applied by checking for capabilities. - * For instance, this code will be executed only on iPhone 5s: - * - * - * ```js - * I.runOnIOS({deviceName: 'iPhone 5s'},() => { - * // ... - * }); - * ``` - * - * Also capabilities can be checked by a function. - * - * ```js - * I.runOnAndroid((caps) => { - * // caps is current config of desiredCapabiliites - * return caps.platformVersion >= 6 - * },() => { - * // ... - * }); - * ``` - * - * @param {*} caps - * @param {*} fn - */ - async runOnIOS(caps, fn) { - if (this.platform !== 'ios') return; - recorder.session.start('iOS-only actions'); - await this._runWithCaps(caps, fn); - await recorder.add('restore from iOS session', () => recorder.session.restore()); - return recorder.promise(); - } - - /** - * Execute code only on Android - * - * ```js - * I.runOnAndroid(() => { - * I.click('io.selendroid.testapp:id/buttonTest'); - * }); - * ``` - * - * Additional filter can be applied by checking for capabilities. - * For instance, this code will be executed only on Android 6.0: - * - * - * ```js - * I.runOnAndroid({platformVersion: '6.0'},() => { - * // ... - * }); - * ``` - * - * Also capabilities can be checked by a function. - * In this case, code will be executed only on Android >= 6. - * - * ```js - * I.runOnAndroid((caps) => { - * // caps is current config of desiredCapabiliites - * return caps.platformVersion >= 6 - * },() => { - * // ... - * }); - * ``` - * - * @param {*} caps - * @param {*} fn - */ - async runOnAndroid(caps, fn) { - if (this.platform !== 'android') return; - recorder.session.start('Android-only actions'); - await this._runWithCaps(caps, fn); - await recorder.add('restore from Android session', () => recorder.session.restore()); - return recorder.promise(); - } - - /** - * Execute code only in Web mode. - * - * ```js - * I.runInWeb(() => { - * I.waitForElement('#data'); - * I.seeInCurrentUrl('/data'); - * }); - * ``` - * - * @param {*} fn - */ - /* eslint-disable */ - async runInWeb(fn) { - if (!this.isWeb) return; - recorder.session.start('Web-only actions'); - - recorder.add('restore from Web session', () => recorder.session.restore(), true); - return recorder.promise(); - } - /* eslint-enable */ - - async _runWithCaps(caps, fn) { - if (typeof caps === 'object') { - for (const key in caps) { - // skip if capabilities do not match - if (this.config.desiredCapabilities[key] !== caps[key]) { - return; - } - } - } - if (typeof caps === 'function') { - if (!fn) { - fn = caps; - } else { - // skip if capabilities are checked inside a function - const enabled = caps(this.config.desiredCapabilities); - if (!enabled) return; - } - } - - fn(); - } - - /** - * Returns app installation status. - * - * ```js - * I.checkIfAppIsInstalled("com.example.android.apis"); - * ``` - * - * @param {string} bundleId String ID of bundled app - * @return {Promise} - * - * Appium: support only Android - */ - async checkIfAppIsInstalled(bundleId) { - onlyForApps.call(this, supportedPlatform.android); - - return this.browser.isAppInstalled(bundleId); - } - - /** - * Check if an app is installed. - * - * ```js - * I.seeAppIsInstalled("com.example.android.apis"); - * ``` - * - * @param {string} bundleId String ID of bundled app - * @return {Promise} - * - * Appium: support only Android - */ - async seeAppIsInstalled(bundleId) { - onlyForApps.call(this, supportedPlatform.android); - const res = await this.browser.isAppInstalled(bundleId); - return truth(`app ${bundleId}`, 'to be installed').assert(res); - } - - /** - * Check if an app is not installed. - * - * ```js - * I.seeAppIsNotInstalled("com.example.android.apis"); - * ``` - * - * @param {string} bundleId String ID of bundled app - * @return {Promise} - * - * Appium: support only Android - */ - async seeAppIsNotInstalled(bundleId) { - onlyForApps.call(this, supportedPlatform.android); - const res = await this.browser.isAppInstalled(bundleId); - return truth(`app ${bundleId}`, 'not to be installed').negate(res); - } - - /** - * Install an app on device. - * - * ```js - * I.installApp('/path/to/file.apk'); - * ``` - * @param {string} path path to apk file - * @return {Promise} - * - * Appium: support only Android - */ - async installApp(path) { - onlyForApps.call(this, supportedPlatform.android); - return this.browser.installApp(path); - } - - /** - * Remove an app from the device. - * - * ```js - * I.removeApp('appName', 'com.example.android.apis'); - * ``` - * - * Appium: support only Android - * - * @param {string} appId - * @param {string} [bundleId] ID of bundle - */ - async removeApp(appId, bundleId) { - onlyForApps.call(this, supportedPlatform.android); - - return this.axios({ - method: 'post', - url: `${this._buildAppiumEndpoint()}/session/${this.browser.sessionId}/appium/device/remove_app`, - data: { appId, bundleId }, - }); - } - - /** - * Reset the currently running app for current session. - * - * ```js - * I.resetApp(); - * ``` - * - */ - async resetApp() { - onlyForApps.call(this); - return this.axios({ - method: 'post', - url: `${this._buildAppiumEndpoint()}/session/${this.browser.sessionId}/appium/app/reset`, - }); - } - - /** - * Check current activity on an Android device. - * - * ```js - * I.seeCurrentActivityIs(".HomeScreenActivity") - * ``` - * @param {string} currentActivity - * @return {Promise} - * - * Appium: support only Android - */ - async seeCurrentActivityIs(currentActivity) { - onlyForApps.call(this, supportedPlatform.android); - const res = await this.browser.getCurrentActivity(); - return truth('current activity', `to be ${currentActivity}`).assert(res === currentActivity); - } - - /** - * Check whether the device is locked. - * - * ```js - * I.seeDeviceIsLocked(); - * ``` - * - * @return {Promise} - * - * Appium: support only Android - */ - async seeDeviceIsLocked() { - onlyForApps.call(this, supportedPlatform.android); - const res = await this.browser.isLocked(); - return truth('device', 'to be locked').assert(res); - } - - /** - * Check whether the device is not locked. - * - * ```js - * I.seeDeviceIsUnlocked(); - * ``` - * - * @return {Promise} - * - * Appium: support only Android - */ - async seeDeviceIsUnlocked() { - onlyForApps.call(this, supportedPlatform.android); - const res = await this.browser.isLocked(); - return truth('device', 'to be locked').negate(res); - } - - /** - * Check the device orientation - * - * ```js - * I.seeOrientationIs('PORTRAIT'); - * I.seeOrientationIs('LANDSCAPE') - * ``` - * - * @return {Promise} - * - * @param {'LANDSCAPE'|'PORTRAIT'} orientation LANDSCAPE or PORTRAIT - * - * Appium: support Android and iOS - */ - async seeOrientationIs(orientation) { - onlyForApps.call(this); - - const res = await this.axios({ - method: 'get', - url: `${this._buildAppiumEndpoint()}/session/${this.browser.sessionId}/orientation`, - }); - - const currentOrientation = res.data.value; - return truth('orientation', `to be ${orientation}`).assert(currentOrientation === orientation); - } - - /** - * Set a device orientation. Will fail, if app will not set orientation - * - * ```js - * I.setOrientation('PORTRAIT'); - * I.setOrientation('LANDSCAPE') - * ``` - * - * @param {'LANDSCAPE'|'PORTRAIT'} orientation LANDSCAPE or PORTRAIT - * - * Appium: support Android and iOS - */ - async setOrientation(orientation) { - onlyForApps.call(this); - - return this.axios({ - method: 'post', - url: `${this._buildAppiumEndpoint()}/session/${this.browser.sessionId}/orientation`, - data: { orientation }, - }); - } - - /** - * Get list of all available contexts - * - * ``` - * let contexts = await I.grabAllContexts(); - * ``` - * - * @return {Promise} - * - * Appium: support Android and iOS - */ - async grabAllContexts() { - onlyForApps.call(this); - return this.browser.getContexts(); - } - - /** - * Retrieve current context - * - * ```js - * let context = await I.grabContext(); - * ``` - * - * @return {Promise} - * - * Appium: support Android and iOS - */ - async grabContext() { - onlyForApps.call(this); - return this.browser.getContext(); - } - - /** - * Get current device activity. - * - * ```js - * let activity = await I.grabCurrentActivity(); - * ``` - * - * @return {Promise} - * - * Appium: support only Android - */ - async grabCurrentActivity() { - onlyForApps.call(this, supportedPlatform.android); - return this.browser.getCurrentActivity(); - } - - /** - * Get information about the current network connection (Data/WIFI/Airplane). - * The actual server value will be a number. However WebdriverIO additional - * properties to the response object to allow easier assertions. - * - * ```js - * let con = await I.grabNetworkConnection(); - * ``` - * - * @return {Promise<{}>} - * - * Appium: support only Android - */ - async grabNetworkConnection() { - onlyForApps.call(this, supportedPlatform.android); - const res = await this.browser.getNetworkConnection(); - return { - value: res, - inAirplaneMode: res.inAirplaneMode, - hasWifi: res.hasWifi, - hasData: res.hasData, - }; - } - - /** - * Get current orientation. - * - * ```js - * let orientation = await I.grabOrientation(); - * ``` - * - * @return {Promise} - * - * Appium: support Android and iOS - */ - async grabOrientation() { - onlyForApps.call(this); - const res = await this.browser.orientation(); - this.debugSection('Orientation', res); - return res; - } - - /** - * Get all the currently specified settings. - * - * ```js - * let settings = await I.grabSettings(); - * ``` - * - * @return {Promise} - * - * Appium: support Android and iOS - */ - async grabSettings() { - onlyForApps.call(this); - const res = await this.browser.getSettings(); - this.debugSection('Settings', JSON.stringify(res)); - return res; - } - - /** - * Switch to the specified context. - * - * @param {*} context the context to switch to - */ - async switchToContext(context) { - return this.browser.switchContext(context); - } - - /** - * Switches to web context. - * If no context is provided switches to the first detected web context - * - * ```js - * // switch to first web context - * I.switchToWeb(); - * - * // or set the context explicitly - * I.switchToWeb('WEBVIEW_io.selendroid.testapp'); - * ``` - * - * @return {Promise} - * - * @param {string} [context] - */ - async switchToWeb(context) { - this.isWeb = true; - this.defaultContext = 'body'; - - if (context) return this.switchToContext(context); - const contexts = await this.grabAllContexts(); - this.debugSection('Contexts', contexts.toString()); - for (const idx in contexts) { - if (contexts[idx].match(/^WEBVIEW/)) return this.switchToContext(contexts[idx]); - } - - throw new Error('No WEBVIEW could be guessed, please specify one in params'); - } - - /** - * Switches to native context. - * By default switches to NATIVE_APP context unless other specified. - * - * ```js - * I.switchToNative(); - * - * // or set context explicitly - * I.switchToNative('SOME_OTHER_CONTEXT'); - * ``` - * @param {*} [context] - * @return {Promise} - */ - async switchToNative(context = null) { - this.isWeb = false; - this.defaultContext = '//*'; - - if (context) return this.switchToContext(context); - return this.switchToContext('NATIVE_APP'); - } - - /** - * Start an arbitrary Android activity during a session. - * - * ```js - * I.startActivity('io.selendroid.testapp', '.RegisterUserActivity'); - * ``` - * - * Appium: support only Android - * - * @param {string} appPackage - * @param {string} appActivity - * @return {Promise} - */ - async startActivity(appPackage, appActivity) { - onlyForApps.call(this, supportedPlatform.android); - return this.browser.startActivity(appPackage, appActivity); - } - - /** - * Set network connection mode. - * - * * airplane mode - * * wifi mode - * * data data - * - * ```js - * I.setNetworkConnection(0) // airplane mode off, wifi off, data off - * I.setNetworkConnection(1) // airplane mode on, wifi off, data off - * I.setNetworkConnection(2) // airplane mode off, wifi on, data off - * I.setNetworkConnection(4) // airplane mode off, wifi off, data on - * I.setNetworkConnection(6) // airplane mode off, wifi on, data on - * ``` - * See corresponding [webdriverio reference](https://webdriver.io/docs/api/chromium/#setnetworkconnection). - * - * Appium: support only Android - * - * @param {number} value The network connection mode bitmask - * @return {Promise} - */ - async setNetworkConnection(value) { - onlyForApps.call(this, supportedPlatform.android); - return this.browser.setNetworkConnection(value); - } - - /** - * Update the current setting on the device - * - * ```js - * I.setSettings({cyberdelia: 'open'}); - * ``` - * - * @param {object} settings object - * - * Appium: support Android and iOS - */ - async setSettings(settings) { - onlyForApps.call(this); - return this.browser.settings(settings); - } - - /** - * Hide the keyboard. - * - * ```js - * // taps outside to hide keyboard per default - * I.hideDeviceKeyboard(); - * I.hideDeviceKeyboard('tapOutside'); - * - * // or by pressing key - * I.hideDeviceKeyboard('pressKey', 'Done'); - * ``` - * - * Appium: support Android and iOS - * - * @param {'tapOutside' | 'pressKey'} [strategy] Desired strategy to close keyboard (‘tapOutside’ or ‘pressKey’) - * @param {string} [key] Optional key - */ - async hideDeviceKeyboard(strategy, key) { - onlyForApps.call(this); - strategy = strategy || 'tapOutside'; - return this.browser.hideKeyboard(strategy, key); - } - - /** - * Send a key event to the device. - * List of keys: https://developer.android.com/reference/android/view/KeyEvent.html - * - * ```js - * I.sendDeviceKeyEvent(3); - * ``` - * - * @param {number} keyValue Device specific key value - * @return {Promise} - * - * Appium: support only Android - */ - async sendDeviceKeyEvent(keyValue) { - onlyForApps.call(this, supportedPlatform.android); - return this.browser.pressKeyCode(keyValue); - } - - /** - * Open the notifications panel on the device. - * - * ```js - * I.openNotifications(); - * ``` - * - * @return {Promise} - * - * Appium: support only Android - */ - async openNotifications() { - onlyForApps.call(this, supportedPlatform.android); - return this.browser.openNotifications(); - } - - /** - * The Touch Action API provides the basis of all gestures that can be - * automated in Appium. At its core is the ability to chain together ad hoc - * individual actions, which will then be applied to an element in the - * application on the device. - * [See complete documentation](http://webdriver.io/api/mobile/touchAction.html) - * - * ```js - * I.makeTouchAction("~buttonStartWebviewCD", 'tap'); - * ``` - * - * @return {Promise} - * - * Appium: support Android and iOS - */ - async makeTouchAction(locator, action) { - onlyForApps.call(this); - const element = await this.browser.$(parseLocator.call(this, locator)); - - return this.browser.touchAction({ - action, - element, - }); - } - - /** - * Taps on element. - * - * ```js - * I.tap("~buttonStartWebviewCD"); - * ``` - * - * Shortcut for `makeTouchAction` - * - * @return {Promise} - * - * @param {*} locator - */ - async tap(locator) { - return this.makeTouchAction(locator, 'tap'); - } - - /** - * Perform a swipe on the screen or an element. - * - * ```js - * let locator = "#io.selendroid.testapp:id/LinearLayout1"; - * I.swipe(locator, 800, 1200, 1000); - * ``` - * - * [See complete reference](http://webdriver.io/api/mobile/swipe.html) - * - * @param {string | object} locator - * @param {number} xoffset - * @param {number} yoffset - * @param {number} [speed=1000] (optional), 1000 by default - * @return {Promise} - * - * Appium: support Android and iOS - */ - /* eslint-disable */ - async swipe(locator, xoffset, yoffset, speed = 1000) { - onlyForApps.call(this); - const res = await this.browser.$(parseLocator.call(this, locator)); - // if (!res.length) throw new ElementNotFound(locator, 'was not found in UI'); - return this.performSwipe(await res.getLocation(), { x: (await res.getLocation()).x + xoffset, y: (await res.getLocation()).y + yoffset }); - } - /* eslint-enable */ - - /** - * Perform a swipe on the screen. - * - * ```js - * I.performSwipe({ x: 300, y: 100 }, { x: 200, y: 100 }); - * ``` - * - * @param {object} from - * @param {object} to - * - * Appium: support Android and iOS - */ - async performSwipe(from, to) { - await this.browser.performActions([{ - id: uuidv4(), - type: 'pointer', - parameters: { - pointerType: 'touch', - }, - actions: [ - { - duration: 0, - x: from.x, - y: from.y, - type: 'pointerMove', - origin: 'viewport', - }, - { - button: 1, - type: 'pointerDown', - }, - { - duration: 200, - type: 'pause', - }, - { - duration: 600, - x: to.x, - y: to.y, - type: 'pointerMove', - origin: 'viewport', - }, - { - button: 1, - type: 'pointerUp', - }, - ], - }]); - await this.browser.pause(1000); - } - - /** - * Perform a swipe down on an element. - * - * ```js - * let locator = "#io.selendroid.testapp:id/LinearLayout1"; - * I.swipeDown(locator); // simple swipe - * I.swipeDown(locator, 500); // set speed - * I.swipeDown(locator, 1200, 1000); // set offset and speed - * ``` - * - * @param {string | object} locator - * @param {number} [yoffset] (optional) - * @param {number} [speed=1000] (optional), 1000 by default - * @return {Promise} - * - * Appium: support Android and iOS - */ - async swipeDown(locator, yoffset = 1000, speed) { - onlyForApps.call(this); - - if (!speed) { - speed = yoffset; - yoffset = 100; - } - - return this.swipe(parseLocator.call(this, locator), 0, yoffset, speed); - } - - /** - * - * Perform a swipe left on an element. - * - * ```js - * let locator = "#io.selendroid.testapp:id/LinearLayout1"; - * I.swipeLeft(locator); // simple swipe - * I.swipeLeft(locator, 500); // set speed - * I.swipeLeft(locator, 1200, 1000); // set offset and speed - * ``` - * - * @param {string | object} locator - * @param {number} [xoffset] (optional) - * @param {number} [speed=1000] (optional), 1000 by default - * @return {Promise} - * - * Appium: support Android and iOS - */ - async swipeLeft(locator, xoffset = 1000, speed) { - onlyForApps.call(this); - if (!speed) { - speed = xoffset; - xoffset = 100; - } - - return this.swipe(parseLocator.call(this, locator), -xoffset, 0, speed); - } - - /** - * Perform a swipe right on an element. - * - * ```js - * let locator = "#io.selendroid.testapp:id/LinearLayout1"; - * I.swipeRight(locator); // simple swipe - * I.swipeRight(locator, 500); // set speed - * I.swipeRight(locator, 1200, 1000); // set offset and speed - * ``` - * - * @param {string | object} locator - * @param {number} [xoffset] (optional) - * @param {number} [speed=1000] (optional), 1000 by default - * @return {Promise} - * - * Appium: support Android and iOS - */ - async swipeRight(locator, xoffset = 1000, speed) { - onlyForApps.call(this); - if (!speed) { - speed = xoffset; - xoffset = 100; - } - - return this.swipe(parseLocator.call(this, locator), xoffset, 0, speed); - } - - /** - * Perform a swipe up on an element. - * - * ```js - * let locator = "#io.selendroid.testapp:id/LinearLayout1"; - * I.swipeUp(locator); // simple swipe - * I.swipeUp(locator, 500); // set speed - * I.swipeUp(locator, 1200, 1000); // set offset and speed - * ``` - * - * @param {string | object} locator - * @param {number} [yoffset] (optional) - * @param {number} [speed=1000] (optional), 1000 by default - * @return {Promise} - * - * Appium: support Android and iOS - */ - async swipeUp(locator, yoffset = 1000, speed) { - onlyForApps.call(this); - - if (!speed) { - speed = yoffset; - yoffset = 100; - } - - return this.swipe(parseLocator.call(this, locator), 0, -yoffset, speed); - } - - /** - * Perform a swipe in selected direction on an element to searchable element. - * - * ```js - * I.swipeTo( - * "android.widget.CheckBox", // searchable element - * "//android.widget.ScrollView/android.widget.LinearLayout", // scroll element - * "up", // direction - * 30, - * 100, - * 500); - * ``` - * - * @param {string} searchableLocator - * @param {string} scrollLocator - * @param {string} direction - * @param {number} timeout - * @param {number} offset - * @param {number} speed - * @return {Promise} - * - * Appium: support Android and iOS - */ - async swipeTo(searchableLocator, scrollLocator, direction, timeout, offset, speed) { - onlyForApps.call(this); - direction = direction || 'down'; - switch (direction) { - case 'down': - direction = 'swipeDown'; - break; - case 'up': - direction = 'swipeUp'; - break; - case 'left': - direction = 'swipeLeft'; - break; - case 'right': - direction = 'swipeRight'; - break; - } - timeout = timeout || this.options.waitForTimeoutInSeconds; - - const errorMsg = `element ("${searchableLocator}") still not visible after ${timeout}seconds`; - const browser = this.browser; - let err = false; - let currentSource; - return browser.waitUntil(() => { - if (err) { - return new Error(`Scroll to the end and element ${searchableLocator} was not found`); - } - return browser.$$(parseLocator.call(this, searchableLocator)) - .then(els => els.length && els[0].isDisplayed()) - .then((res) => { - if (res) { - return true; - } - return this[direction](scrollLocator, offset, speed).getSource().then((source) => { - if (source === currentSource) { - err = true; - } else { - currentSource = source; - return false; - } - }); - }); - }, timeout * 1000, errorMsg) - .catch((e) => { - if (e.message.indexOf('timeout') && e.type !== 'NoSuchElement') { - throw new AssertionFailedError({ customMessage: `Scroll to the end and element ${searchableLocator} was not found` }, ''); - } else { - throw e; - } - }); - } - - /** - * Performs a specific touch action. - * The action object need to contain the action name, x/y coordinates - * - * ```js - * I.touchPerform([{ - * action: 'press', - * options: { - * x: 100, - * y: 200 - * } - * }, {action: 'release'}]) - * - * I.touchPerform([{ - * action: 'tap', - * options: { - * element: '1', // json web element was queried before - * x: 10, // x offset - * y: 20, // y offset - * count: 1 // number of touches - * } - * }]); - * ``` - * - * Appium: support Android and iOS - * - * @param {Array} actions Array of touch actions - */ - async touchPerform(actions) { - onlyForApps.call(this); - return this.browser.touchPerform(actions); - } - - /** - * Pulls a file from the device. - * - * ```js - * I.pullFile('/storage/emulated/0/DCIM/logo.png', 'my/path'); - * // save file to output dir - * I.pullFile('/storage/emulated/0/DCIM/logo.png', output_dir); - * ``` - * - * @param {string} path - * @param {string} dest - * @return {Promise} - * - * Appium: support Android and iOS - */ - async pullFile(path, dest) { - onlyForApps.call(this); - return this.browser.pullFile(path).then(res => fs.writeFile(dest, Buffer.from(res, 'base64'), (err) => { - if (err) { - return false; - } - return true; - })); - } - - /** - * Perform a shake action on the device. - * - * ```js - * I.shakeDevice(); - * ``` - * - * @return {Promise} - * - * Appium: support only iOS - */ - async shakeDevice() { - onlyForApps.call(this, 'iOS'); - return this.browser.shake(); - } - - /** - * Perform a rotation gesture centered on the specified element. - * - * ```js - * I.rotate(120, 120) - * ``` - * - * See corresponding [webdriverio reference](http://webdriver.io/api/mobile/rotate.html). - * - * @return {Promise} - * - * Appium: support only iOS - */ - async rotate(x, y, duration, radius, rotation, touchCount) { - onlyForApps.call(this, 'iOS'); - return this.browser.rotate(x, y, duration, radius, rotation, touchCount); - } - - /** - * Set immediate value in app. - * - * See corresponding [webdriverio reference](http://webdriver.io/api/mobile/setImmediateValue.html). - * - * @return {Promise} - * - * Appium: support only iOS - */ - async setImmediateValue(id, value) { - onlyForApps.call(this, 'iOS'); - return this.browser.setImmediateValue(id, value); - } - - /** - * Simulate Touch ID with either valid (match == true) or invalid (match == false) fingerprint. - * - * ```js - * I.touchId(); // simulates valid fingerprint - * I.touchId(true); // simulates valid fingerprint - * I.touchId(false); // simulates invalid fingerprint - * ``` - * - * @return {Promise} - * - * Appium: support only iOS - * TODO: not tested - */ - async simulateTouchId(match) { - onlyForApps.call(this, 'iOS'); - match = match || true; - return this.browser.touchId(match); - } - - /** - * Close the given application. - * - * ```js - * I.closeApp(); - * ``` - * - * @return {Promise} - * - * Appium: support both Android and iOS - */ - async closeApp() { - onlyForApps.call(this); - return this.browser.closeApp(); - } - - /** - * Appends text to a input field or textarea. - * Field is located by name, label, CSS or XPath - * - * ```js - * I.appendField('#myTextField', 'appended'); - * // typing secret - * I.appendField('password', secret('123456')); - * ``` - * @param {string | object} field located by label|name|CSS|XPath|strict locator - * @param {string} value text value to append. - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async appendField(field, value) { - if (this.isWeb) return super.appendField(field, value); - return super.appendField(parseLocator.call(this, field), value); - } - - /** - * Selects a checkbox or radio button. - * Element is located by label or name or CSS or XPath. - * - * The second parameter is a context (CSS or XPath locator) to narrow the search. - * - * ```js - * I.checkOption('#agree'); - * I.checkOption('I Agree to Terms and Conditions'); - * I.checkOption('agree', '//form'); - * ``` - * @param {string | object} field checkbox located by label | name | CSS | XPath | strict locator. - * @param {?string | object} [context=null] (optional, `null` by default) element located by CSS | XPath | strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async checkOption(field) { - if (this.isWeb) return super.checkOption(field); - return super.checkOption(parseLocator.call(this, field)); - } - - /** - * Perform a click on a link or a button, given by a locator. - * If a fuzzy locator is given, the page will be searched for a button, link, or image matching the locator string. - * For buttons, the "value" attribute, "name" attribute, and inner text are searched. For links, the link text is searched. - * For images, the "alt" attribute and inner text of any parent links are searched. - * - * The second parameter is a context (CSS or XPath locator) to narrow the search. - * - * ```js - * // simple link - * I.click('Logout'); - * // button of form - * I.click('Submit'); - * // CSS button - * I.click('#form input[type=submit]'); - * // XPath - * I.click('//form/*[@type=submit]'); - * // link in context - * I.click('Logout', '#nav'); - * // using strict locator - * I.click({css: 'nav a.login'}); - * ``` - * - * @param {string | object} locator clickable link or button located by text, or any element located by CSS|XPath|strict locator. - * @param {?string | object | null} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async click(locator, context) { - if (this.isWeb) return super.click(locator, context); - return super.click(parseLocator.call(this, locator), parseLocator.call(this, context)); - } - - /** - * Verifies that the specified checkbox is not checked. - * - * ```js - * I.dontSeeCheckboxIsChecked('#agree'); // located by ID - * I.dontSeeCheckboxIsChecked('I agree to terms'); // located by label - * I.dontSeeCheckboxIsChecked('agree'); // located by name - * ``` - * - * @param {string | object} field located by label|name|CSS|XPath|strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async dontSeeCheckboxIsChecked(field) { - if (this.isWeb) return super.dontSeeCheckboxIsChecked(field); - return super.dontSeeCheckboxIsChecked(parseLocator.call(this, field)); - } - - /** - * Opposite to `seeElement`. Checks that element is not visible (or in DOM) - * - * ```js - * I.dontSeeElement('.modal'); // modal is not shown - * ``` - * - * @param {string | object} locator located by CSS|XPath|Strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async dontSeeElement(locator) { - if (this.isWeb) return super.dontSeeElement(locator); - return super.dontSeeElement(parseLocator.call(this, locator)); - } - - /** - * Checks that value of input field or textarea doesn't equal to given value - * Opposite to `seeInField`. - * - * ```js - * I.dontSeeInField('email', 'user@user.com'); // field by name - * I.dontSeeInField({ css: 'form input.email' }, 'user@user.com'); // field by CSS - * ``` - * - * @param {string | object} field located by label|name|CSS|XPath|strict locator. - * @param {string | object} value value to check. - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async dontSeeInField(field, value) { - const _value = (typeof value === 'boolean') ? value : value.toString(); - if (this.isWeb) return super.dontSeeInField(field, _value); - return super.dontSeeInField(parseLocator.call(this, field), _value); - } - - /** - * Opposite to `see`. Checks that a text is not present on a page. - * Use context parameter to narrow down the search. - * - * ```js - * I.dontSee('Login'); // assume we are already logged in. - * I.dontSee('Login', '.nav'); // no login inside .nav element - * ``` - * - * @param {string} text which is not present. - * @param {string | object} [context] (optional) element located by CSS|XPath|strict locator in which to perfrom search. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async dontSee(text, context = null) { - if (this.isWeb) return super.dontSee(text, context); - return super.dontSee(text, parseLocator.call(this, context)); - } - - /** - * Fills a text field or textarea, after clearing its value, with the given string. - * Field is located by name, label, CSS, or XPath. - * - * ```js - * // by label - * I.fillField('Email', 'hello@world.com'); - * // by name - * I.fillField('password', secret('123456')); - * // by CSS - * I.fillField('form#login input[name=username]', 'John'); - * // or by strict locator - * I.fillField({css: 'form#login input[name=username]'}, 'John'); - * ``` - * @param {string | object} field located by label|name|CSS|XPath|strict locator. - * @param {string | object} value text value to fill. - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async fillField(field, value) { - value = value.toString(); - if (this.isWeb) return super.fillField(field, value); - return super.fillField(parseLocator.call(this, field), value); - } - - /** - * Retrieves all texts from an element located by CSS or XPath and returns it to test. - * Resumes test execution, so **should be used inside async with `await`** operator. - * - * ```js - * let pins = await I.grabTextFromAll('#pin li'); - * ``` - * - * @param {string | object} locator element located by CSS|XPath|strict locator. - * @returns {Promise} attribute value - * - * - */ - async grabTextFromAll(locator) { - if (this.isWeb) return super.grabTextFromAll(locator); - return super.grabTextFromAll(parseLocator.call(this, locator)); - } - - /** - * Retrieves a text from an element located by CSS or XPath and returns it to test. - * Resumes test execution, so **should be used inside async with `await`** operator. - * - * ```js - * let pin = await I.grabTextFrom('#pin'); - * ``` - * If multiple elements found returns first element. - * - * @param {string | object} locator element located by CSS|XPath|strict locator. - * @returns {Promise} attribute value - * - * - */ - async grabTextFrom(locator) { - if (this.isWeb) return super.grabTextFrom(locator); - return super.grabTextFrom(parseLocator.call(this, locator)); - } - - /** - * Grab number of visible elements by locator. - * Resumes test execution, so **should be used inside async function with `await`** operator. - * - * ```js - * let numOfElements = await I.grabNumberOfVisibleElements('p'); - * ``` - * - * @param {string | object} locator located by CSS|XPath|strict locator. - * @returns {Promise} number of visible elements - */ - async grabNumberOfVisibleElements(locator) { - if (this.isWeb) return super.grabNumberOfVisibleElements(locator); - return super.grabNumberOfVisibleElements(parseLocator.call(this, locator)); - } - - /** - * Can be used for apps only with several values ("contentDescription", "text", "className", "resourceId") - * - * Retrieves an attribute from an element located by CSS or XPath and returns it to test. - * Resumes test execution, so **should be used inside async with `await`** operator. - * If more than one element is found - attribute of first element is returned. - * - * ```js - * let hint = await I.grabAttributeFrom('#tooltip', 'title'); - * ``` - * @param {string | object} locator element located by CSS|XPath|strict locator. - * @param {string} attr attribute name. - * @returns {Promise} attribute value - * - */ - async grabAttributeFrom(locator, attr) { - if (this.isWeb) return super.grabAttributeFrom(locator, attr); - return super.grabAttributeFrom(parseLocator.call(this, locator), attr); - } - - /** - * Can be used for apps only with several values ("contentDescription", "text", "className", "resourceId") - * Retrieves an array of attributes from elements located by CSS or XPath and returns it to test. - * Resumes test execution, so **should be used inside async with `await`** operator. - * - * ```js - * let hints = await I.grabAttributeFromAll('.tooltip', 'title'); - * ``` - * @param {string | object} locator element located by CSS|XPath|strict locator. - * @param {string} attr attribute name. - * @returns {Promise} attribute value - * - */ - async grabAttributeFromAll(locator, attr) { - if (this.isWeb) return super.grabAttributeFromAll(locator, attr); - return super.grabAttributeFromAll(parseLocator.call(this, locator), attr); - } - - /** - * Retrieves an array of value from a form located by CSS or XPath and returns it to test. - * Resumes test execution, so **should be used inside async function with `await`** operator. - * - * ```js - * let inputs = await I.grabValueFromAll('//form/input'); - * ``` - * @param {string | object} locator field located by label|name|CSS|XPath|strict locator. - * @returns {Promise} attribute value - * - * - */ - async grabValueFromAll(locator) { - if (this.isWeb) return super.grabValueFromAll(locator); - return super.grabValueFromAll(parseLocator.call(this, locator)); - } - - /** - * Retrieves a value from a form element located by CSS or XPath and returns it to test. - * Resumes test execution, so **should be used inside async function with `await`** operator. - * If more than one element is found - value of first element is returned. - * - * ```js - * let email = await I.grabValueFrom('input[name=email]'); - * ``` - * @param {string | object} locator field located by label|name|CSS|XPath|strict locator. - * @returns {Promise} attribute value - * - * - */ - async grabValueFrom(locator) { - if (this.isWeb) return super.grabValueFrom(locator); - return super.grabValueFrom(parseLocator.call(this, locator)); - } - - /** - * Saves a screenshot to ouput folder (set in codecept.conf.ts or codecept.conf.js). - * Filename is relative to output folder. - * - * ```js - * I.saveScreenshot('debug.png'); - * ``` - * - * @param {string} fileName file name to save. - * @return {Promise} - */ - async saveScreenshot(fileName) { - return super.saveScreenshot(fileName, false); - } - - /** - * Scroll element into viewport. - * - * ```js - * I.scrollIntoView('#submit'); - * I.scrollIntoView('#submit', true); - * I.scrollIntoView('#submit', { behavior: "smooth", block: "center", inline: "center" }); - * ``` - * - * @param {string | object} locator located by CSS|XPath|strict locator. - * @param {ScrollIntoViewOptions|boolean} scrollIntoViewOptions either alignToTop=true|false or scrollIntoViewOptions. See https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView. - * @returns {void} automatically synchronized promise through #recorder - * - * - * Supported only for web testing - */ - async scrollIntoView(locator, scrollIntoViewOptions) { - if (this.isWeb) return super.scrollIntoView(locator, scrollIntoViewOptions); - } - - /** - * Verifies that the specified checkbox is checked. - * - * ```js - * I.seeCheckboxIsChecked('Agree'); - * I.seeCheckboxIsChecked('#agree'); // I suppose user agreed to terms - * I.seeCheckboxIsChecked({css: '#signup_form input[type=checkbox]'}); - * ``` - * - * @param {string | object} field located by label|name|CSS|XPath|strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async seeCheckboxIsChecked(field) { - if (this.isWeb) return super.seeCheckboxIsChecked(field); - return super.seeCheckboxIsChecked(parseLocator.call(this, field)); - } - - /** - * Checks that a given Element is visible - * Element is located by CSS or XPath. - * - * ```js - * I.seeElement('#modal'); - * ``` - * @param {string | object} locator located by CSS|XPath|strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async seeElement(locator) { - if (this.isWeb) return super.seeElement(locator); - return super.seeElement(parseLocator.call(this, locator)); - } - - /** - * Checks that the given input field or textarea equals to given value. - * For fuzzy locators, fields are matched by label text, the "name" attribute, CSS, and XPath. - * - * ```js - * I.seeInField('Username', 'davert'); - * I.seeInField({css: 'form textarea'},'Type your comment here'); - * I.seeInField('form input[type=hidden]','hidden_value'); - * I.seeInField('#searchform input','Search'); - * ``` - * @param {string | object} field located by label|name|CSS|XPath|strict locator. - * @param {string | object} value value to check. - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async seeInField(field, value) { - const _value = (typeof value === 'boolean') ? value : value.toString(); - if (this.isWeb) return super.seeInField(field, _value); - return super.seeInField(parseLocator.call(this, field), _value); - } - - /** - * Checks that a page contains a visible text. - * Use context parameter to narrow down the search. - * - * ```js - * I.see('Welcome'); // text welcome on a page - * I.see('Welcome', '.content'); // text inside .content div - * I.see('Register', {css: 'form.register'}); // use strict locator - * ``` - * @param {string} text expected on page. - * @param {?string | object} [context=null] (optional, `null` by default) element located by CSS|Xpath|strict locator in which to search for text. - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async see(text, context) { - if (this.isWeb) return super.see(text, context); - return super.see(text, parseLocator.call(this, context)); - } - - /** - * Selects an option in a drop-down select. - * Field is searched by label | name | CSS | XPath. - * Option is selected by visible text or by value. - * - * ```js - * I.selectOption('Choose Plan', 'Monthly'); // select by label - * I.selectOption('subscription', 'Monthly'); // match option by text - * I.selectOption('subscription', '0'); // or by value - * I.selectOption('//form/select[@name=account]','Premium'); - * I.selectOption('form select[name=account]', 'Premium'); - * I.selectOption({css: 'form select[name=account]'}, 'Premium'); - * ``` - * - * Provide an array for the second argument to select multiple options. - * - * ```js - * I.selectOption('Which OS do you use?', ['Android', 'iOS']); - * ``` - * @param {string | object} select field located by label|name|CSS|XPath|strict locator. - * @param {string|Array<*>} option visible text or value of option. - * @returns {void} automatically synchronized promise through #recorder - * - * - * Supported only for web testing - */ - async selectOption(select, option) { - if (this.isWeb) return super.selectOption(select, option); - throw new Error('Should be used only in Web context. In native context use \'click\' method instead'); - } - - /** - * Waits for element to be present on page (by default waits for 1sec). - * Element can be located by CSS or XPath. - * - * ```js - * I.waitForElement('.btn.continue'); - * I.waitForElement('.btn.continue', 5); // wait for 5 secs - * ``` - * - * @param {string | object} locator element located by CSS|XPath|strict locator. - * @param {number} [sec] (optional, `1` by default) time in seconds to wait - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async waitForElement(locator, sec = null) { - if (this.isWeb) return super.waitForElement(locator, sec); - return super.waitForElement(parseLocator.call(this, locator), sec); - } - - /** - * Waits for an element to become visible on a page (by default waits for 1sec). - * Element can be located by CSS or XPath. - * - * ```js - * I.waitForVisible('#popup'); - * ``` - * - * @param {string | object} locator element located by CSS|XPath|strict locator. - * @param {number} [sec=1] (optional, `1` by default) time in seconds to wait - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async waitForVisible(locator, sec = null) { - if (this.isWeb) return super.waitForVisible(locator, sec); - return super.waitForVisible(parseLocator.call(this, locator), sec); - } - - /** - * Waits for an element to be removed or become invisible on a page (by default waits for 1sec). - * Element can be located by CSS or XPath. - * - * ```js - * I.waitForInvisible('#popup'); - * ``` - * - * @param {string | object} locator element located by CSS|XPath|strict locator. - * @param {number} [sec=1] (optional, `1` by default) time in seconds to wait - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async waitForInvisible(locator, sec = null) { - if (this.isWeb) return super.waitForInvisible(locator, sec); - return super.waitForInvisible(parseLocator.call(this, locator), sec); - } - - /** - * Waits for a text to appear (by default waits for 1sec). - * Element can be located by CSS or XPath. - * Narrow down search results by providing context. - * - * ```js - * I.waitForText('Thank you, form has been submitted'); - * I.waitForText('Thank you, form has been submitted', 5, '#modal'); - * ``` - * - * @param {string }text to wait for. - * @param {number} [sec=1] (optional, `1` by default) time in seconds to wait - * @param {string | object} [context] (optional) element located by CSS|XPath|strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async waitForText(text, sec = null, context = null) { - if (this.isWeb) return super.waitForText(text, sec, context); - return super.waitForText(text, sec, parseLocator.call(this, context)); - } -} - -function parseLocator(locator) { - if (!locator) return null; - - if (typeof locator === 'object') { - if (locator.web && this.isWeb) { - return parseLocator.call(this, locator.web); - } - - if (locator.android && this.platform === 'android') { - if (typeof locator.android === 'string') { - return parseLocator.call(this, locator.android); - } - // The locator is an Android DataMatcher or ViewMatcher locator so return as is - return locator.android; - } - - if (locator.ios && this.platform === 'ios') { - return parseLocator.call(this, locator.ios); - } - } - - if (typeof locator === 'string') { - if (locator[0] === '~') return locator; - if (locator.substr(0, 2) === '//') return locator; - if (locator[0] === '#' && !this.isWeb) { - // hook before webdriverio supports native # locators - return parseLocator.call(this, { id: locator.slice(1) }); - } - - if (this.platform === 'android' && !this.isWeb) { - const isNativeLocator = /^\-?android=?/.exec(locator); - return isNativeLocator - ? locator - : `android=new UiSelector().text("${locator}")`; - } - } - - locator = new Locator(locator, 'xpath'); - if (locator.type === 'css' && !this.isWeb) throw new Error('Unable to use css locators in apps. Locator strategies for this request: xpath, id, class name or accessibility id'); - if (locator.type === 'name' && !this.isWeb) throw new Error("Can't locate element by name in Native context. Use either ID, class name or accessibility id"); - if (locator.type === 'id' && !this.isWeb && this.platform === 'android') return `//*[@resource-id='${locator.value}']`; - return locator.simplify(); -} - -// in the end of a file -function onlyForApps(expectedPlatform) { - const stack = new Error().stack || ''; - const re = /Appium.(\w+)/g; - const caller = stack.split('\n')[2].trim(); - const m = re.exec(caller); - - if (!m) { - throw new Error(`Invalid caller ${caller}`); - } - - const callerName = m[1] || m[2]; - if (!expectedPlatform) { - if (!this.platform) { - throw new Error(`${callerName} method can be used only with apps`); - } - } else if (this.platform !== expectedPlatform.toLowerCase()) { - throw new Error(`${callerName} method can be used only with ${expectedPlatform} apps`); - } -} - -module.exports = Appium; diff --git a/docs/build/Expect.js b/docs/build/Expect.js deleted file mode 100644 index c9de3693c..000000000 --- a/docs/build/Expect.js +++ /dev/null @@ -1,425 +0,0 @@ -const output = require('../output'); - -let expect; - -import('chai').then(chai => { - expect = chai.expect; - chai.use(require('chai-string')); - // @ts-ignore - chai.use(require('chai-exclude')); - chai.use(require('chai-match-pattern')); - chai.use(require('chai-json-schema')); -}); - -/** - * This helper allows performing assertions based on Chai. - * - * ### Examples - * - * Zero-configuration when paired with other helpers like REST, Playwright: - * - * ```js - * // inside codecept.conf.js - *{ - * helpers: { - * Playwright: {...}, - * Expect: {}, - * } - *} - * ``` - * - * ## Methods - */ -class ExpectHelper { - /** - * - * @param {*} actualValue - * @param {*} expectedValue - * @param {*} [customErrorMsg] - */ - expectEqual(actualValue, expectedValue, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(actualValue)}" to equal "${JSON.stringify(expectedValue)}"`); - return expect(actualValue, customErrorMsg).to.equal(expectedValue); - } - - /** - * - * @param {*} actualValue - * @param {*} expectedValue - * @param {*} [customErrorMsg] - */ - expectNotEqual(actualValue, expectedValue, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(actualValue)}" to not equal "${JSON.stringify(expectedValue)}"`); - return expect(actualValue, customErrorMsg).not.to.equal(expectedValue); - } - - /** - * - * @param {*} actualValue - * @param {*} expectedValue - * @param {*} [customErrorMsg] - - */ - expectDeepEqual(actualValue, expectedValue, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(actualValue)}" to deep equal "${JSON.stringify(expectedValue)}"`); - return expect(actualValue, customErrorMsg).to.deep.equal(expectedValue); - } - - /** - * - * @param {*} actualValue - * @param {*} expectedValue - * @param {*} [customErrorMsg] - */ - expectNotDeepEqual(actualValue, expectedValue, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(actualValue)}" to not deep equal "${JSON.stringify(expectedValue)}"`); - return expect(actualValue, customErrorMsg).to.not.deep.equal(expectedValue); - } - - /** - * - * @param {*} actualValue - * @param {*} expectedValueToContain - * @param {*} [customErrorMsg] - */ - expectContain(actualValue, expectedValueToContain, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(actualValue)}" to contain "${JSON.stringify(expectedValueToContain)}"`); - return expect(actualValue, customErrorMsg).to.contain( - expectedValueToContain, - ); - } - - /** - * - * @param {*} actualValue - * @param {*} expectedValueToNotContain - * @param {*} [customErrorMsg] - */ - expectNotContain( - actualValue, - expectedValueToNotContain, - customErrorMsg = '', - ) { - // @ts-ignore - output.step(`I expect "${JSON.stringify(actualValue)}" to not contain "${JSON.stringify(expectedValueToNotContain)}"`); - return expect(actualValue, customErrorMsg).not.to.contain( - expectedValueToNotContain, - ); - } - - /** - * - * @param {*} actualValue - * @param {*} expectedValueToStartWith - * @param {*} [customErrorMsg] - */ - expectStartsWith(actualValue, expectedValueToStartWith, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(actualValue)}" to start with "${JSON.stringify(expectedValueToStartWith)}"`); - return expect(actualValue, customErrorMsg).to.startsWith( - expectedValueToStartWith, - ); - } - - /** - * - * @param {*} actualValue - * @param {*} expectedValueToNotStartWith - * @param {*} [customErrorMsg] - */ - expectNotStartsWith( - actualValue, - expectedValueToNotStartWith, - customErrorMsg = '', - ) { - // @ts-ignore - output.step(`I expect "${JSON.stringify(actualValue)}" to not start with "${JSON.stringify(expectedValueToNotStartWith)}"`); - return expect(actualValue, customErrorMsg).not.to.startsWith( - expectedValueToNotStartWith, - ); - } - - /** - * @param {*} actualValue - * @param {*} expectedValueToEndWith - * @param {*} [customErrorMsg] - */ - expectEndsWith(actualValue, expectedValueToEndWith, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(actualValue)}" to end with "${JSON.stringify(expectedValueToEndWith)}"`); - return expect(actualValue, customErrorMsg).to.endsWith( - expectedValueToEndWith, - ); - } - - /** - * @param {*} actualValue - * @param {*} expectedValueToNotEndWith - * @param {*} [customErrorMsg] - */ - expectNotEndsWith( - actualValue, - expectedValueToNotEndWith, - customErrorMsg = '', - ) { - // @ts-ignore - output.step(`I expect "${JSON.stringify(actualValue)}" to not end with "${JSON.stringify(expectedValueToNotEndWith)}"`); - return expect(actualValue, customErrorMsg).not.to.endsWith( - expectedValueToNotEndWith, - ); - } - - /** - * @param {*} targetData - * @param {*} jsonSchema - * @param {*} [customErrorMsg] - */ - expectJsonSchema(targetData, jsonSchema, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(targetData)}" to match this JSON schema "${JSON.stringify(jsonSchema)}"`); - - return expect(targetData, customErrorMsg).to.be.jsonSchema(jsonSchema); - } - - /** - * @param {*} targetData - * @param {*} jsonSchema - * @param {*} [customErrorMsg] - * @param {*} [ajvOptions] Pass AJV options - */ - expectJsonSchemaUsingAJV( - targetData, - jsonSchema, - customErrorMsg = '', - ajvOptions = { allErrors: true }, - ) { - // @ts-ignore - output.step(`I expect "${JSON.stringify(targetData)}" to match this JSON schema using AJV "${JSON.stringify(jsonSchema)}"`); - chai.use(require('chai-json-schema-ajv').create(ajvOptions)); - return expect(targetData, customErrorMsg).to.be.jsonSchema(jsonSchema); - } - - /** - * @param {*} targetData - * @param {*} propertyName - * @param {*} [customErrorMsg] - */ - expectHasProperty(targetData, propertyName, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(targetData)}" to have property: "${JSON.stringify(propertyName)}"`); - return expect(targetData, customErrorMsg).to.have.property(propertyName); - } - - /** - * @param {*} targetData - * @param {*} propertyName - * @param {*} [customErrorMsg] - */ - expectHasAProperty(targetData, propertyName, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(targetData)}" to have a property: "${JSON.stringify(propertyName)}"`); - return expect(targetData, customErrorMsg).to.have.a.property(propertyName); - } - - /** - * @param {*} targetData - * @param {*} type - * @param {*} [customErrorMsg] - */ - expectToBeA(targetData, type, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(targetData)}" to be a "${JSON.stringify(type)}"`); - return expect(targetData, customErrorMsg).to.be.a(type); - } - - /** - * @param {*} targetData - * @param {*} type - * @param {*} [customErrorMsg] - */ - expectToBeAn(targetData, type, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(targetData)}" to be an "${JSON.stringify(type)}"`); - return expect(targetData, customErrorMsg).to.be.an(type); - } - - /** - * @param {*} targetData - * @param {*} regex - * @param {*} [customErrorMsg] - */ - expectMatchRegex(targetData, regex, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(targetData)}" to match the regex "${JSON.stringify(regex)}"`); - return expect(targetData, customErrorMsg).to.match(regex); - } - - /** - * @param {*} targetData - * @param {*} length - * @param {*} [customErrorMsg] - */ - expectLengthOf(targetData, length, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(targetData)}" to have length of "${JSON.stringify(length)}"`); - return expect(targetData, customErrorMsg).to.have.lengthOf(length); - } - - /** - * @param {*} targetData - * @param {*} [customErrorMsg] - */ - expectEmpty(targetData, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(targetData)}" to be empty`); - return expect(targetData, customErrorMsg).to.be.empty; - } - - /** - * @param {*} targetData - * @param {*} [customErrorMsg] - */ - expectTrue(targetData, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(targetData)}" to be true`); - return expect(targetData, customErrorMsg).to.be.true; - } - - /** - * @param {*} targetData - * @param {*} [customErrorMsg] - */ - expectFalse(targetData, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(targetData)}" to be false`); - return expect(targetData, customErrorMsg).to.be.false; - } - - /** - * @param {*} targetData - * @param {*} aboveThan - * @param {*} [customErrorMsg] - */ - expectAbove(targetData, aboveThan, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(targetData)}" to be above ${JSON.stringify(aboveThan)}`); - return expect(targetData, customErrorMsg).to.be.above(aboveThan); - } - - /** - * @param {*} targetData - * @param {*} belowThan - * @param {*} [customErrorMsg] - */ - expectBelow(targetData, belowThan, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(targetData)}" to be below ${JSON.stringify(belowThan)}`); - return expect(targetData, customErrorMsg).to.be.below(belowThan); - } - - /** - * @param {*} targetData - * @param {*} lengthAboveThan - * @param {*} [customErrorMsg] - */ - expectLengthAboveThan(targetData, lengthAboveThan, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(targetData)}" to have length of above ${JSON.stringify(lengthAboveThan)}`); - return expect(targetData, customErrorMsg).to.have.lengthOf.above( - lengthAboveThan, - ); - } - - /** - * @param {*} targetData - * @param {*} lengthBelowThan - * @param {*} [customErrorMsg] - */ - expectLengthBelowThan(targetData, lengthBelowThan, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(targetData)}" to have length of below ${JSON.stringify(lengthBelowThan)}`); - return expect(targetData, customErrorMsg).to.have.lengthOf.below( - lengthBelowThan, - ); - } - - /** - * @param {*} actualValue - * @param {*} expectedValue - * @param {*} [customErrorMsg] - */ - expectEqualIgnoreCase(actualValue, expectedValue, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect and ingore case "${JSON.stringify(actualValue)}" to equal "${JSON.stringify(expectedValue)}"`); - return expect(actualValue, customErrorMsg).to.equalIgnoreCase( - expectedValue, - ); - } - - /** - * expects members of two arrays are deeply equal - * @param {*} actualValue - * @param {*} expectedValue - * @param {*} [customErrorMsg] - */ - expectDeepMembers(actualValue, expectedValue, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect members of "${JSON.stringify(actualValue)}" and "${JSON.stringify(expectedValue)}" arrays are deeply equal`); - return expect(actualValue, customErrorMsg).to.have.deep.members( - expectedValue, - ); - } - - /** - * expects an array to be a superset of another array - * @param {*} superset - * @param {*} set - * @param {*} [customErrorMsg] - */ - expectDeepIncludeMembers(superset, set, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(superset)}" array to be a superset of "${JSON.stringify(set)}" array`); - return expect(superset, customErrorMsg).to.deep.include.members( - set, - ); - } - - /** - * expects members of two JSON objects are deeply equal excluding some properties - * @param {*} actualValue - * @param {*} expectedValue - * @param {*} fieldsToExclude - * @param {*} [customErrorMsg] - */ - expectDeepEqualExcluding( - actualValue, - expectedValue, - fieldsToExclude, - customErrorMsg = '', - ) { - // @ts-ignore - output.step(`I expect members of "${JSON.stringify(actualValue)}" and "${JSON.stringify(expectedValue)}" JSON objects are deeply equal excluding properties: ${JSON.stringify(fieldsToExclude)}`); - return expect(actualValue, customErrorMsg) - .excludingEvery(fieldsToExclude) - .to.deep.equal(expectedValue); - } - - /** - * expects a JSON object matches a provided pattern - * @param {*} actualValue - * @param {*} expectedPattern - * @param {*} [customErrorMsg] - */ - expectMatchesPattern(actualValue, expectedPattern, customErrorMsg = '') { - // @ts-ignore - output.step(`I expect "${JSON.stringify(actualValue)}" to match the ${JSON.stringify(expectedPattern)} pattern`); - return expect(actualValue, customErrorMsg).to.matchPattern(expectedPattern); - } -} - -module.exports = ExpectHelper; diff --git a/docs/build/FileSystem.js b/docs/build/FileSystem.js deleted file mode 100644 index 830936c7d..000000000 --- a/docs/build/FileSystem.js +++ /dev/null @@ -1,228 +0,0 @@ -const assert = require('assert'); -const path = require('path'); -const fs = require('fs'); - -const Helper = require('@codeceptjs/helper'); -const { fileExists } = require('../utils'); -const { fileIncludes } = require('../assert/include'); -const { fileEquals } = require('../assert/equal'); - -/** - * Helper for testing filesystem. - * Can be easily used to check file structures: - * - * ```js - * I.amInPath('test'); - * I.seeFile('codecept.js'); - * I.seeInThisFile('FileSystem'); - * I.dontSeeInThisFile("WebDriver"); - * ``` - * - * ## Configuration - * - * Enable helper in config file: - * - * ```js - * helpers: { - * FileSystem: {}, - * } - * ``` - * - * ## Methods - */ -class FileSystem extends Helper { - constructor() { - super(); - this.dir = global.codecept_dir; - this.file = ''; - } - - _before() { - this.debugSection('Dir', this.dir); - } - - /** - * Enters a directory In local filesystem. - * Starts from a current directory - * @param {string} openPath - */ - amInPath(openPath) { - this.dir = path.join(global.codecept_dir, openPath); - this.debugSection('Dir', this.dir); - } - - /** - * Writes text to file - * @param {string} name - * @param {string} text - */ - writeToFile(name, text) { - fs.writeFileSync(path.join(this.dir, name), text); - } - - /** - * Checks that file exists - * @param {string} name - */ - seeFile(name) { - this.file = path.join(this.dir, name); - this.debugSection('File', this.file); - assert.ok(fileExists(this.file), `File ${name} not found in ${this.dir}`); - } - - /** - * Waits for the file to be present in the current directory. - * - * ```js - * I.handleDownloads('downloads/largeFilesName.txt'); - * I.click('Download large File'); - * I.amInPath('output/downloads'); - * I.waitForFile('largeFilesName.txt', 10); // wait 10 seconds for file - * ``` - * @param {string} name - * @param {number} [sec=1] seconds to wait - */ - async waitForFile(name, sec = 1) { - if (sec === 0) assert.fail('Use `seeFile` instead of waiting 0 seconds!'); - const waitTimeout = sec * 1000; - this.file = path.join(this.dir, name); - this.debugSection('File', this.file); - return isFileExists(this.file, waitTimeout).catch(() => { - throw new Error(`file (${name}) still not present in directory ${this.dir} after ${waitTimeout / 1000} sec`); - }); - } - - /** - * Checks that file with a name including given text exists in the current directory. - * - *```js - * I.handleDownloads(); - * I.click('Download as PDF'); - * I.amInPath('output/downloads'); - * I.seeFileNameMatching('.pdf'); - * ``` - * @param {string} text - */ - seeFileNameMatching(text) { - assert.ok( - this.grabFileNames().some(file => file.includes(text)), - `File name which contains ${text} not found in ${this.dir}`, - ); - } - - /** - * Checks that file found by `seeFile` includes a text. - * @param {string} text - * @param {string} [encoding='utf8'] - */ - seeInThisFile(text, encoding = 'utf8') { - const content = getFileContents(this.file, encoding); - fileIncludes(this.file).assert(text, content); - } - - /** - * Checks that file found by `seeFile` doesn't include text. - * @param {string} text - * @param {string} [encoding='utf8'] - */ - dontSeeInThisFile(text, encoding = 'utf8') { - const content = getFileContents(this.file, encoding); - fileIncludes(this.file).negate(text, content); - } - - /** - * Checks that contents of file found by `seeFile` equal to text. - * @param {string} text - * @param {string} [encoding='utf8'] - */ - seeFileContentsEqual(text, encoding = 'utf8') { - const content = getFileContents(this.file, encoding); - fileEquals(this.file).assert(text, content); - } - - /** - * Checks that contents of the file found by `seeFile` equal to contents of the file at `pathToReferenceFile`. - * @param {string} pathToReferenceFile - * @param {string} [encoding='utf8'] - * @param {string} [encodingReference='utf8'] - */ - seeFileContentsEqualReferenceFile(pathToReferenceFile, encoding = 'utf8', encodingReference = '') { - const content = getFileContents(this.file, encoding); - assert.ok(fileExists(pathToReferenceFile), `Reference file ${pathToReferenceFile} not found.`); - encodingReference = encodingReference || encoding; - const expectedContent = getFileContents(pathToReferenceFile, encodingReference); - fileEquals(this.file).assert(expectedContent, content); - } - - /** - * Checks that contents of file found by `seeFile` doesn't equal to text. - * @param {string} text - * @param {string} [encoding='utf8'] - */ - dontSeeFileContentsEqual(text, encoding = 'utf8') { - const content = getFileContents(this.file, encoding); - fileEquals(this.file).negate(text, content); - } - - /** - * Returns file names in current directory. - * - * ```js - * I.handleDownloads(); - * I.click('Download Files'); - * I.amInPath('output/downloads'); - * const downloadedFileNames = I.grabFileNames(); - * ``` - */ - grabFileNames() { - return fs.readdirSync(this.dir) - .filter(item => !fs.lstatSync(path.join(this.dir, item)).isDirectory()); - } -} - -module.exports = FileSystem; - -/** - * @param {string} file - * @param {string} [encoding='utf8'] - * @private - * @returns {string} - */ -function getFileContents(file, encoding = 'utf8') { - if (!file) assert.fail('No files were opened, please use seeFile action'); - if (encoding === '') assert.fail('Encoding is an empty string, please set a valid encoding'); - return fs.readFileSync(file, encoding); -} - -/** - * @param {string} file - * @param {number} timeout - * @private - * @returns {Promise} - */ -function isFileExists(file, timeout) { - return new Promise(((resolve, reject) => { - const timer = setTimeout(() => { - watcher.close(); - reject(new Error('File did not exists and was not created during the timeout.')); - }, timeout); - - const dir = path.dirname(file); - const basename = path.basename(file); - const watcher = fs.watch(dir, (eventType, filename) => { - if (eventType === 'rename' && filename === basename) { - clearTimeout(timer); - watcher.close(); - resolve(); - } - }); - - fs.access(file, fs.constants.R_OK, (err) => { - if (!err) { - clearTimeout(timer); - watcher.close(); - resolve(); - } - }); - })); -} diff --git a/docs/build/GraphQL.js b/docs/build/GraphQL.js deleted file mode 100644 index fb87f16a2..000000000 --- a/docs/build/GraphQL.js +++ /dev/null @@ -1,229 +0,0 @@ -const axios = require('axios').default; -const Helper = require('@codeceptjs/helper'); - -/** - * GraphQL helper allows to send additional requests to a GraphQl endpoint during acceptance tests. - * [Axios](https://github.com/axios/axios) library is used to perform requests. - * - * ## Configuration - * - * * endpoint: GraphQL base URL - * * timeout: timeout for requests in milliseconds. 10000ms by default - * * defaultHeaders: a list of default headers - * * onRequest: a async function which can update request object. - * - * ## Example - * - * ```js - * GraphQL: { - * endpoint: 'http://site.com/graphql/', - * onRequest: (request) => { - * request.headers.auth = '123'; - * } - * } - * ``` - * - * ## Access From Helpers - * - * Send GraphQL requests by accessing `_executeQuery` method: - * - * ```js - * this.helpers['GraphQL']._executeQuery({ - * url, - * data, - * }); - * ``` - * - * ## Methods - */ -class GraphQL extends Helper { - constructor(config) { - super(config); - this.axios = axios.create(); - this.headers = {}; - this.options = { - timeout: 10000, - defaultHeaders: {}, - endpoint: '', - }; - this.options = Object.assign(this.options, config); - this.headers = { ...this.options.defaultHeaders }; - this.axios.defaults.headers = this.options.defaultHeaders; - } - - static _checkRequirements() { - try { - require('axios'); - } catch (e) { - return ['axios']; - } - } - - static _config() { - return [ - { name: 'endpoint', message: 'Endpoint of API you are going to test', default: 'http://localhost:3000/graphql' }, - ]; - } - - /** - * Executes query via axios call - * - * @param {object} request - */ - async _executeQuery(request) { - this.axios.defaults.timeout = request.timeout || this.options.timeout; - - if (this.headers && this.headers.auth) { - request.auth = this.headers.auth; - } - - request.headers = Object.assign(request.headers, { - 'Content-Type': 'application/json', - }); - - request.headers = { ...this.headers, ...request.headers }; - - if (this.config.onRequest) { - await this.config.onRequest(request); - } - - this.debugSection('Request', JSON.stringify(request)); - - let response; - try { - response = await this.axios(request); - } catch (err) { - if (!err.response) throw err; - this.debugSection( - 'Response', - `Response error. Status code: ${err.response.status}`, - ); - response = err.response; - } - - if (this.config.onResponse) { - await this.config.onResponse(response); - } - - this.debugSection('Response', JSON.stringify(response.data)); - return response; - } - - /** - * Prepares request for axios call - * - * @param {object} operation - * @param {object} headers - * @return {object} graphQLRequest - */ - _prepareGraphQLRequest(operation, headers) { - return { - baseURL: this.options.endpoint, - method: 'POST', - data: operation, - headers, - }; - } - - /** - * Send query to GraphQL endpoint over http. - * Returns a response as a promise. - * - * ```js - * - * const response = await I.sendQuery('{ users { name email }}'); - * // with variables - * const response = await I.sendQuery( - * 'query getUser($id: ID) { user(id: $id) { name email }}', - * { id: 1 }, - * ) - * const user = response.data.data; - * ``` - * - * @param {String} query - * @param {object} [variables] that may go along with the query - * @param {object} [options] are additional query options - * @param {object} [headers] - * @return Promise - */ - async sendQuery(query, variables, options = {}, headers = {}) { - if (typeof query !== 'string') { - throw new Error(`query expected to be a String, instead received ${typeof query}`); - } - const operation = { - query, - variables, - ...options, - }; - const request = this._prepareGraphQLRequest(operation, headers); - return this._executeQuery(request); - } - - /** - * Send query to GraphQL endpoint over http - * - * ```js - * I.sendMutation(` - * mutation createUser($user: UserInput!) { - * createUser(user: $user) { - * id - * name - * email - * } - * } - * `, - * { user: { - * name: 'John Doe', - * email: 'john@xmail.com' - * } - * }, - * }); - * ``` - * - * @param {String} mutation - * @param {object} [variables] that may go along with the mutation - * @param {object} [options] are additional query options - * @param {object} [headers] - * @return Promise - */ - async sendMutation(mutation, variables, options = {}, headers = {}) { - if (typeof mutation !== 'string') { - throw new Error(`mutation expected to be a String, instead received ${typeof mutation}`); - } - const operation = { - query: mutation, - variables, - ...options, - }; - const request = this._prepareGraphQLRequest(operation, headers); - return this._executeQuery(request); - } - - _setRequestTimeout(newTimeout) { - this.options.timeout = newTimeout; - } - - /** - * Sets request headers for all requests of this test - * - * @param {object} headers headers list - */ - haveRequestHeaders(headers) { - this.headers = { ...this.headers, ...headers }; - } - - /** - * Adds a header for Bearer authentication - * - * ```js - * // we use secret function to hide token from logs - * I.amBearerAuthenticated(secret('heregoestoken')) - * ``` - * - * @param {string | CodeceptJS.Secret} accessToken Bearer access token - */ - amBearerAuthenticated(accessToken) { - this.haveRequestHeaders({ Authorization: `Bearer ${accessToken}` }); - } -} -module.exports = GraphQL; diff --git a/docs/build/GraphQLDataFactory.js b/docs/build/GraphQLDataFactory.js deleted file mode 100644 index 9a6d802e6..000000000 --- a/docs/build/GraphQLDataFactory.js +++ /dev/null @@ -1,309 +0,0 @@ -const path = require('path'); - -const Helper = require('@codeceptjs/helper'); -const GraphQL = require('./GraphQL'); - -/** - * Helper for managing remote data using GraphQL queries. - * Uses data generators like [rosie](https://github.com/rosiejs/rosie) or factory girl to create new record. - * - * By defining a factory you set the rules of how data is generated. - * This data will be saved on server via GraphQL queries and deleted in the end of a test. - * - * ## Use Case - * - * Acceptance tests interact with a websites using UI and real browser. - * There is no way to create data for a specific test other than from user interface. - * That makes tests slow and fragile. Instead of testing a single feature you need to follow all creation/removal process. - * - * This helper solves this problem. - * If a web application has GraphQL support, it can be used to create and delete test records. - * By combining GraphQL with Factories you can easily create records for tests: - * - * ```js - * I.mutateData('createUser', { name: 'davert', email: 'davert@mail.com' }); - * let user = await I.mutateData('createUser', { name: 'davert'}); - * I.mutateMultiple('createPost', 3, {post_id: user.id}); - * ``` - * - * To make this work you need - * - * 1. GraphQL endpoint which allows to perform create / delete requests and - * 2. define data generation rules - * - * ### Setup - * - * Install [Rosie](https://github.com/rosiejs/rosie) and [Faker](https://www.npmjs.com/package/faker) libraries. - * - * ```sh - * npm i rosie @faker-js/faker --save-dev - * ``` - * - * Create a factory file for a resource. - * - * See the example for Users factories: - * - * ```js - * // tests/factories/users.js - * - * const { Factory } = require('rosie').Factory; - * const { faker } = require('@faker-js/faker'); - * - * // Used with a constructor function passed to Factory, so that the final build - * // object matches the necessary pattern to be sent as the variables object. - * module.exports = new Factory((buildObj) => ({ - * input: { ...buildObj }, - * })) - * // 'attr'-id can be left out depending on the GraphQl resolvers - * .attr('name', () => faker.name.findName()) - * .attr('email', () => faker.interact.email()) - * ``` - * For more options see [rosie documentation](https://github.com/rosiejs/rosie). - * - * Then configure GraphQLDataHelper to match factories and GraphQL schema: - - * ### Configuration - * - * GraphQLDataFactory has following config options: - * - * * `endpoint`: URL for the GraphQL server. - * * `cleanup` (default: true): should inserted records be deleted up after tests - * * `factories`: list of defined factories - * * `headers`: list of headers - * * `GraphQL`: configuration for GraphQL requests. - * - * - * See the example: - * - * ```js - * GraphQLDataFactory: { - * endpoint: "http://user.com/graphql", - * cleanup: true, - * headers: { - * 'Content-Type': 'application/json', - * 'Accept': 'application/json', - * }, - * factories: { - * createUser: { - * query: 'mutation createUser($input: UserInput!) { createUser(input: $input) { id name }}', - * factory: './factories/users', - * revert: (data) => ({ - * query: 'mutation deleteUser($id: ID!) { deleteUser(id: $id) }', - * variables: { id : data.id}, - * }), - * }, - * } - * } - * ``` - - * It is required to set GraphQL `endpoint` which is the URL to which all the queries go to. - * Factory file is expected to be passed via `factory` option. - * - * This Helper uses [GraphQL](http://codecept.io/helpers/GraphQL/) helper and accepts its configuration in "GraphQL" section. - * For instance, to set timeout you should add: - * - * ```js - * "GraphQLDataFactory": { - * "GraphQL": { - * "timeout": "100000", - * } - * } - * ``` - * - * ### Factory - * - * Factory contains operations - - * - * * `operation`: The operation/mutation that needs to be performed for creating a record in the backend. - * - * Each operation must have the following: - * - * * `query`: The mutation(query) string. It is expected to use variables to send data with the query. - * * `factory`: The path to factory file. The object built by the factory in this file will be passed - * as the 'variables' object to go along with the mutation. - * * `revert`: A function called with the data returned when an item is created. The object returned by - * this function is will be used to later delete the items created. So, make sure RELEVANT DATA IS RETURNED - * when a record is created by a mutation. - * - * ### Requests - * - * Requests can be updated on the fly by using `onRequest` function. For instance, you can pass in current session from a cookie. - * - * ```js - * onRequest: async (request) => { - * // using global codeceptjs instance - * let cookie = await codeceptjs.container.helpers('WebDriver').grabCookie('session'); - * request.headers = { Cookie: `session=${cookie.value}` }; - * } - * ``` - * - * ### Responses - * - * By default `I.mutateData()` returns a promise with created data as specified in operation query string: - * - * ```js - * let client = await I.mutateData('createClient'); - * ``` - * - * Data of created records are collected and used in the end of a test for the cleanup. - * - * ## Methods - */ -class GraphQLDataFactory extends Helper { - constructor(config) { - super(config); - - const defaultConfig = { - cleanup: true, - GraphQL: {}, - factories: {}, - }; - this.config = Object.assign(defaultConfig, this.config); - - if (this.config.headers) { - this.config.GraphQL.defaultHeaders = this.config.headers; - } - if (this.config.onRequest) { - this.config.GraphQL.onRequest = this.config.onRequest; - } - this.graphqlHelper = new GraphQL(Object.assign(this.config.GraphQL, { endpoint: this.config.endpoint })); - this.factories = this.config.factories; - - this.created = {}; - Object.keys(this.factories).forEach(f => (this.created[f] = [])); - } - - static _checkRequirements() { - try { - require('axios'); - require('rosie'); - } catch (e) { - return ['axios', 'rosie']; - } - } - - _after() { - if (!this.config.cleanup) { - return Promise.resolve(); - } - const promises = []; - // clean up all created items - for (const mutationName in this.created) { - const createdItems = this.created[mutationName]; - if (!createdItems.length) continue; - this.debug(`Deleting ${createdItems.length} ${mutationName}(s)`); - for (const itemData of createdItems) { - promises.push(this._requestDelete(mutationName, itemData)); - } - } - return Promise.all(promises); - } - - /** - * Generates a new record using factory, sends a GraphQL mutation to store it. - * - * ```js - * // create a user - * I.mutateData('createUser'); - * // create user with defined email - * // and receive it when inside async function - * const user = await I.mutateData('createUser', { email: 'user@user.com'}); - * ``` - * - * @param {string} operation to be performed - * @param {*} params predefined parameters - */ - mutateData(operation, params) { - const variables = this._createItem(operation, params); - this.debug(`Creating ${operation} ${JSON.stringify(variables)}`); - return this._requestCreate(operation, variables); - } - - /** - * Generates bunch of records and sends multiple GraphQL mutation requests to store them. - * - * ```js - * // create 3 users - * I.mutateMultiple('createUser', 3); - * - * // create 3 users of same age - * I.mutateMultiple('createUser', 3, { age: 25 }); - * ``` - * - * @param {string} operation - * @param {number} times - * @param {*} params - */ - mutateMultiple(operation, times, params) { - const promises = []; - for (let i = 0; i < times; i++) { - promises.push(this.mutateData(operation, params)); - } - return Promise.all(promises); - } - - _createItem(operation, data) { - if (!this.factories[operation]) { - throw new Error(`Mutation ${operation} is not defined in config.factories`); - } - let modulePath = this.factories[operation].factory; - try { - try { - require.resolve(modulePath); - } catch (e) { - modulePath = path.join(global.codecept_dir, modulePath); - } - const builder = require(modulePath); - return builder.build(data); - } catch (err) { - throw new Error(`Couldn't load factory file from ${modulePath}, check that - - "factories": { - "${operation}": { - "factory": "./path/to/factory" - - points to valid factory file. - Factory file should export an object with build method. - - Current file error: ${err.message}`); - } - } - - /** - * Executes request to create a record to the GraphQL endpoint. - * Can be replaced from a custom helper. - * - * @param {string} operation - * @param {*} variables to be sent along with the query - */ - _requestCreate(operation, variables) { - const { query } = this.factories[operation]; - return this.graphqlHelper.sendMutation(query, variables).then((response) => { - const data = response.data.data[operation]; - this.created[operation].push(data); - this.debugSection('Created', `record: ${data}`); - return data; - }); - } - - /** - * Executes request to delete a record to the GraphQL endpoint. - * Can be replaced from a custom helper. - * - * @param {string} operation - * @param {*} data of the record to be deleted. - */ - _requestDelete(operation, data) { - const deleteOperation = this.factories[operation].revert(data); - const { query, variables } = deleteOperation; - - return this.graphqlHelper.sendMutation(query, variables) - .then((response) => { - const idx = this.created[operation].indexOf(data); - this.debugSection('Deleted', `record: ${response.data.data}`); - this.created[operation].splice(idx, 1); - }); - } -} - -module.exports = GraphQLDataFactory; diff --git a/docs/build/JSONResponse.js b/docs/build/JSONResponse.js deleted file mode 100644 index e651a6833..000000000 --- a/docs/build/JSONResponse.js +++ /dev/null @@ -1,338 +0,0 @@ -const Helper = require('@codeceptjs/helper'); - -let expect; - -import('chai').then(chai => { - expect = chai.expect; - chai.use(require('chai-deep-match')); -}); - -const joi = require('joi'); - -/** - * This helper allows performing assertions on JSON responses paired with following helpers: - * - * * REST - * * GraphQL - * * Playwright - * - * It can check status codes, response data, response structure. - * - * - * ## Configuration - * - * * `requestHelper` - a helper which will perform requests. `REST` by default, also `Playwright` or `GraphQL` can be used. Custom helpers must have `onResponse` hook in their config, which will be executed when request is performed. - * - * ### Examples - * - * Zero-configuration when paired with REST: - * - * ```js - * // inside codecept.conf.js - *{ - * helpers: { - * REST: { - * endpoint: 'http://site.com/api', - * }, - * JSONResponse: {} - * } - *} - * ``` - * Explicitly setting request helper if you use `makeApiRequest` of Playwright to perform requests and not paired REST: - * - * ```js - * // inside codecept.conf.js - * // ... - * helpers: { - * Playwright: { - * url: 'https://localhost', - * browser: 'chromium', - * }, - * JSONResponse: { - * requestHelper: 'Playwright', - * } - * } - * ``` - * ## Access From Helpers - * - * If you plan to add custom assertions it is recommended to create a helper that will retrieve response object from JSONResponse: - * - * - * ```js - * // inside custom helper - * const response = this.helpers.JSONResponse.response; - * ``` - * - * ## Methods - */ -class JSONResponse extends Helper { - constructor(config = {}) { - super(config); - this.options = { - requestHelper: 'REST', - }; - this.options = { ...this.options, ...config }; - } - - _beforeSuite() { - this.response = null; - if (!this.helpers[this.options.requestHelper]) { - throw new Error(`Error setting JSONResponse, helper ${this.options.requestHelper} is not enabled in config, helpers: ${Object.keys(this.helpers)}`); - } - // connect to REST helper - this.helpers[this.options.requestHelper].config.onResponse = (response) => { - this.response = response; - }; - } - - _before() { - this.response = null; - } - - static _checkRequirements() { - try { - require('joi'); - } catch (e) { - return ['joi']; - } - } - - /** - * Checks that response code is equal to the provided one - * - * ```js - * I.seeResponseCodeIs(200); - * ``` - * - * @param {number} code - */ - seeResponseCodeIs(code) { - this._checkResponseReady(); - expect(this.response.status).to.eql(code, 'Response code is not the same as expected'); - } - - /** - * Checks that response code is not equal to the provided one - * - * ```js - * I.dontSeeResponseCodeIs(500); - * ``` - * - * @param {number} code - */ - dontSeeResponseCodeIs(code) { - this._checkResponseReady(); - expect(this.response.status).not.to.eql(code); - } - - /** - * Checks that the response code is 4xx - */ - seeResponseCodeIsClientError() { - this._checkResponseReady(); - expect(this.response.status).to.be.gte(400); - expect(this.response.status).to.be.lt(500); - } - - /** - * Checks that the response code is 3xx - */ - seeResponseCodeIsRedirection() { - this._checkResponseReady(); - expect(this.response.status).to.be.gte(300); - expect(this.response.status).to.be.lt(400); - } - - /** - * Checks that the response code is 5xx - */ - seeResponseCodeIsServerError() { - this._checkResponseReady(); - expect(this.response.status).to.be.gte(500); - expect(this.response.status).to.be.lt(600); - } - - /** - * Checks that the response code is 2xx - * Use it instead of seeResponseCodeIs(200) if server can return 204 instead. - * - * ```js - * I.seeResponseCodeIsSuccessful(); - * ``` - */ - seeResponseCodeIsSuccessful() { - this._checkResponseReady(); - expect(this.response.status).to.be.gte(200); - expect(this.response.status).to.be.lt(300); - } - - /** - * Checks for deep inclusion of a provided json in a response data. - * - * ```js - * // response.data == { user: { name: 'jon', email: 'jon@doe.com' } } - * - * I.seeResponseContainsJson({ user: { email: 'jon@doe.com' } }); - * ``` - * If an array is received, checks that at least one element contains JSON - * ```js - * // response.data == [{ user: { name: 'jon', email: 'jon@doe.com' } }] - * - * I.seeResponseContainsJson({ user: { email: 'jon@doe.com' } }); - * ``` - * - * @param {object} json - */ - seeResponseContainsJson(json = {}) { - this._checkResponseReady(); - if (Array.isArray(this.response.data)) { - let fails = 0; - for (const el of this.response.data) { - try { - expect(el).to.deep.match(json); - } catch (err) { - fails++; - } - } - expect(fails < this.response.data.length, `No elements in array matched ${JSON.stringify(json)}`).to.be.true; - } else { - expect(this.response.data).to.deep.match(json); - } - } - - /** - * Checks for deep inclusion of a provided json in a response data. - * - * ```js - * // response.data == { data: { user: 1 } } - * - * I.dontSeeResponseContainsJson({ user: 2 }); - * ``` - * If an array is received, checks that no element of array contains json: - * ```js - * // response.data == [{ user: 1 }, { user: 3 }] - * - * I.dontSeeResponseContainsJson({ user: 2 }); - * ``` - * - * @param {object} json - */ - dontSeeResponseContainsJson(json = {}) { - this._checkResponseReady(); - if (Array.isArray(this.response.data)) { - this.response.data.forEach(data => expect(data).not.to.deep.match(json)); - } else { - expect(this.response.data).not.to.deep.match(json); - } - } - - /** - * Checks for deep inclusion of a provided json in a response data. - * - * ```js - * // response.data == { user: { name: 'jon', email: 'jon@doe.com' } } - * - * I.seeResponseContainsKeys(['user']); - * ``` - * - * If an array is received, check is performed for each element of array: - * - * ```js - * // response.data == [{ user: 'jon' }, { user: 'matt'}] - * - * I.seeResponseContainsKeys(['user']); - * ``` - * - * @param {array} keys - */ - seeResponseContainsKeys(keys = []) { - this._checkResponseReady(); - if (Array.isArray(this.response.data)) { - this.response.data.forEach(data => expect(data).to.include.keys(keys)); - } else { - expect(this.response.data).to.include.keys(keys); - } - } - - /** - * Executes a callback function passing in `response` object and chai assertions with `expect` - * Use it to perform custom checks of response data - * - * ```js - * I.seeResponseValidByCallback(({ data, status, expect }) => { - * expect(status).to.eql(200); - * expect(data).keys.to.include(['user', 'company']); - * }); - * ``` - * - * @param {function} fn - */ - seeResponseValidByCallback(fn) { - this._checkResponseReady(); - fn({ ...this.response, expect }); - const body = fn.toString(); - fn.toString = () => `${body.split('\n')[1]}...`; - } - - /** - * Checks that response data equals to expected: - * - * ```js - * // response.data is { error: 'Not allowed' } - * - * I.seeResponseEquals({ error: 'Not allowed' }) - * ``` - * @param {object} resp - */ - seeResponseEquals(resp) { - this._checkResponseReady(); - expect(this.response.data).to.deep.equal(resp); - } - - /** - * Validates JSON structure of response using [joi library](https://joi.dev). - * See [joi API](https://joi.dev/api/) for complete reference on usage. - * - * Use pre-initialized joi instance by passing function callback: - * - * ```js - * // response.data is { name: 'jon', id: 1 } - * - * I.seeResponseMatchesJsonSchema(joi => { - * return joi.object({ - * name: joi.string(), - * id: joi.number() - * }) - * }); - * - * // or pass a valid schema - * const joi = require('joi'); - * - * I.seeResponseMatchesJsonSchema(joi.object({ - * name: joi.string(), - * id: joi.number(); - * }); - * ``` - * - * @param {any} fnOrSchema - */ - seeResponseMatchesJsonSchema(fnOrSchema) { - this._checkResponseReady(); - let schema = fnOrSchema; - if (typeof fnOrSchema === 'function') { - schema = fnOrSchema(joi); - const body = fnOrSchema.toString(); - fnOrSchema.toString = () => `${body.split('\n')[1]}...`; - } - if (!schema) throw new Error('Empty Joi schema provided, see https://joi.dev/ for details'); - if (!joi.isSchema(schema)) throw new Error('Invalid Joi schema provided, see https://joi.dev/ for details'); - schema.toString = () => schema.describe(); - joi.assert(this.response.data, schema); - } - - _checkResponseReady() { - if (!this.response) throw new Error('Response is not available'); - } -} - -module.exports = JSONResponse; diff --git a/docs/build/Mochawesome.js b/docs/build/Mochawesome.js deleted file mode 100644 index 826b2f232..000000000 --- a/docs/build/Mochawesome.js +++ /dev/null @@ -1,71 +0,0 @@ -let addMochawesomeContext; -let currentTest; -let currentSuite; - -const Helper = require('@codeceptjs/helper'); -const { clearString } = require('../utils'); - -class Mochawesome extends Helper { - constructor(config) { - super(config); - - // set defaults - this.options = { - uniqueScreenshotNames: false, - disableScreenshots: false, - }; - - addMochawesomeContext = require('mochawesome/addContext'); - this._createConfig(config); - } - - _createConfig(config) { - // override defaults with config - Object.assign(this.options, config); - } - - _beforeSuite(suite) { - currentSuite = suite; - currentTest = ''; - } - - _before() { - if (currentSuite && currentSuite.ctx) { - currentTest = { test: currentSuite.ctx.currentTest }; - } - } - - _test(test) { - currentTest = { test }; - } - - _failed(test) { - if (this.options.disableScreenshots) return; - let fileName; - // Get proper name if we are fail on hook - if (test.ctx.test.type === 'hook') { - currentTest = { test: test.ctx.test }; - // ignore retries if we are in hook - test._retries = -1; - fileName = clearString(`${test.title}_${currentTest.test.title}`); - } else { - currentTest = { test }; - fileName = clearString(test.title); - } - if (this.options.uniqueScreenshotNames) { - const uuid = test.uuid || test.ctx.test.uuid; - fileName = `${fileName.substring(0, 10)}_${uuid}`; - } - if (test._retries < 1 || test._retries === test.retryNum) { - fileName = `${fileName}.failed.png`; - return addMochawesomeContext(currentTest, fileName); - } - } - - addMochawesomeContext(context) { - if (currentTest === '') currentTest = { test: currentSuite.ctx.test }; - return addMochawesomeContext(currentTest, context); - } -} - -module.exports = Mochawesome; diff --git a/docs/build/MockServer.js b/docs/build/MockServer.js deleted file mode 100644 index 9cb748808..000000000 --- a/docs/build/MockServer.js +++ /dev/null @@ -1,221 +0,0 @@ -const { mock, settings } = require('pactum'); - -/** - * ## Configuration - * - * This helper should be configured in codecept.conf.(js|ts) - * - * @typedef MockServerConfig - * @type {object} - * @prop {number} [port=9393] - Mock server port - * @prop {string} [host="0.0.0.0"] - Mock server host - * @prop {object} [httpsOpts] - key & cert values are the paths to .key and .crt files - */ -let config = { - port: 9393, - host: '0.0.0.0', - httpsOpts: { - key: '', - cert: '', - }, -}; - -/** - * MockServer - * - * The MockServer Helper in CodeceptJS empowers you to mock any server or service via HTTP or HTTPS, making it an excellent tool for simulating REST endpoints and other HTTP-based APIs. - * - * - * - * #### Examples - * - * You can seamlessly integrate MockServer with other helpers like REST or Playwright. Here's a configuration example inside the `codecept.conf.js` file: - * - * ```javascript - * { - * helpers: { - * REST: {...}, - * MockServer: { - * // default mock server config - * port: 9393, - * host: '0.0.0.0', - * httpsOpts: { - * key: '', - * cert: '', - * }, - * }, - * } - * } - * ``` - * - * #### Adding Interactions - * - * Interactions add behavior to the mock server. Use the `I.addInteractionToMockServer()` method to include interactions. It takes an interaction object as an argument, containing request and response details. - * - * ```javascript - * I.addInteractionToMockServer({ - * request: { - * method: 'GET', - * path: '/api/hello' - * }, - * response: { - * status: 200, - * body: { - * 'say': 'hello to mock server' - * } - * } - * }); - * ``` - * - * #### Request Matching - * - * When a real request is sent to the mock server, it matches the received request with the interactions. If a match is found, it returns the specified response; otherwise, a 404 status code is returned. - * - * - Strong match on HTTP Method, Path, Query Params & JSON body. - * - Loose match on Headers. - * - * ##### Strong Match on Query Params - * - * You can send different responses based on query parameters: - * - * ```javascript - * I.addInteractionToMockServer({ - * request: { - * method: 'GET', - * path: '/api/users', - * queryParams: { - * id: 1 - * } - * }, - * response: { - * status: 200, - * body: 'user 1' - * } - * }); - * - * I.addInteractionToMockServer({ - * request: { - * method: 'GET', - * path: '/api/users', - * queryParams: { - * id: 2 - * } - * }, - * response: { - * status: 200, - * body: 'user 2' - * } - * }); - * ``` - * - * - GET to `/api/users?id=1` will return 'user 1'. - * - GET to `/api/users?id=2` will return 'user 2'. - * - For all other requests, it returns a 404 status code. - * - * ##### Loose Match on Body - * - * When `strict` is set to false, it performs a loose match on query params and response body: - * - * ```javascript - * I.addInteractionToMockServer({ - * strict: false, - * request: { - * method: 'POST', - * path: '/api/users', - * body: { - * name: 'john' - * } - * }, - * response: { - * status: 200 - * } - * }); - * ``` - * - * - POST to `/api/users` with the body containing `name` as 'john' will return a 200 status code. - * - POST to `/api/users` without the `name` property in the body will return a 404 status code. - * - * Happy testing with MockServer in CodeceptJS! 🚀 - * - * ## Methods - */ -class MockServer { - constructor(passedConfig) { - settings.setLogLevel('SILENT'); - config = { ...passedConfig }; - if (global.debugMode) { - settings.setLogLevel('VERBOSE'); - } - } - - /** - * Start the mock server - * @param {number} [port] start the mock server with given port - * - * @returns void - */ - async startMockServer(port) { - const _config = { ...config }; - if (port) _config.port = port; - await mock.setDefaults(_config); - await mock.start(); - } - - /** - * Stop the mock server - * - * @returns void - * - */ - async stopMockServer() { - await mock.stop(); - } - - /** - * An interaction adds behavior to the mock server - * - * - * ```js - * I.addInteractionToMockServer({ - * request: { - * method: 'GET', - * path: '/api/hello' - * }, - * response: { - * status: 200, - * body: { - * 'say': 'hello to mock server' - * } - * } - * }); - * ``` - * ```js - * // with query params - * I.addInteractionToMockServer({ - * request: { - * method: 'GET', - * path: '/api/hello', - * queryParams: { - * id: 2 - * } - * }, - * response: { - * status: 200, - * body: { - * 'say': 'hello to mock server' - * } - * } - * }); - * ``` - * - * @param {CodeceptJS.MockInteraction|object} interaction add behavior to the mock server - * - * @returns void - * - */ - async addInteractionToMockServer(interaction) { - await mock.addInteraction(interaction); - } -} - -module.exports = MockServer; diff --git a/docs/build/Nightmare.js b/docs/build/Nightmare.js deleted file mode 100644 index 18174d700..000000000 --- a/docs/build/Nightmare.js +++ /dev/null @@ -1,2152 +0,0 @@ -const path = require('path'); - -const urlResolve = require('url').resolve; - -const Helper = require('@codeceptjs/helper'); -const { includes: stringIncludes } = require('../assert/include'); -const { urlEquals } = require('../assert/equal'); -const { equals } = require('../assert/equal'); -const { empty } = require('../assert/empty'); -const { truth } = require('../assert/truth'); -const Locator = require('../locator'); -const ElementNotFound = require('./errors/ElementNotFound'); -const { - xpathLocator, - fileExists, - screenshotOutputFolder, - toCamelCase, -} = require('../utils'); - -const specialKeys = { - Backspace: '\u0008', - Enter: '\u000d', - Delete: '\u007f', -}; - -let withinStatus = false; - -/** - * Nightmare helper wraps [Nightmare](https://github.com/segmentio/nightmare) library to provide - * fastest headless testing using Electron engine. Unlike Selenium-based drivers this uses - * Chromium-based browser with Electron with lots of client side scripts, thus should be less stable and - * less trusted. - * - * Requires `nightmare` package to be installed. - * - * ## Configuration - * - * This helper should be configured in codecept.conf.ts or codecept.conf.js - * - * * `url` - base url of website to be tested - * * `restart` (optional, default: true) - restart browser between tests. - * * `disableScreenshots` (optional, default: false) - don't save screenshot on failure. - * * `uniqueScreenshotNames` (optional, default: false) - option to prevent screenshot override if you have scenarios with the same name in different suites. - * * `fullPageScreenshots` (optional, default: false) - make full page screenshots on failure. - * * `keepBrowserState` (optional, default: false) - keep browser state between tests when `restart` set to false. - * * `keepCookies` (optional, default: false) - keep cookies between tests when `restart` set to false. - * * `waitForAction`: (optional) how long to wait after click, doubleClick or PressKey actions in ms. Default: 500. - * * `waitForTimeout`: (optional) default wait* timeout in ms. Default: 1000. - * * `windowSize`: (optional) default window size. Set a dimension like `640x480`. - * - * + options from [Nightmare configuration](https://github.com/segmentio/nightmare#api) - * - * ## Methods - */ -class Nightmare extends Helper { - constructor(config) { - super(config); - - this.isRunning = false; - - // override defaults with config - this._setConfig(config); - } - - _validateConfig(config) { - const defaults = { - waitForAction: 500, - waitForTimeout: 1000, - fullPageScreenshots: false, - disableScreenshots: false, - uniqueScreenshotNames: false, - rootElement: 'body', - restart: true, - keepBrowserState: false, - keepCookies: false, - js_errors: null, - enableHAR: false, - }; - - return Object.assign(defaults, config); - } - - static _config() { - return [ - { name: 'url', message: 'Base url of site to be tested', default: 'http://localhost' }, - { - name: 'show', message: 'Show browser window', default: true, type: 'confirm', - }, - ]; - } - - static _checkRequirements() { - try { - require('nightmare'); - } catch (e) { - return ['nightmare']; - } - } - - async _init() { - this.Nightmare = require('nightmare'); - - if (this.options.enableHAR) { - require('nightmare-har-plugin').install(this.Nightmare); - } - - this.Nightmare.action('findElements', function (locator, contextEl, done) { - if (!done) { - done = contextEl; - contextEl = null; - } - - const by = Object.keys(locator)[0]; - const value = locator[by]; - - this.evaluate_now((by, locator, contextEl) => window.codeceptjs.findAndStoreElements(by, locator, contextEl), done, by, value, contextEl); - }); - - this.Nightmare.action('findElement', function (locator, contextEl, done) { - if (!done) { - done = contextEl; - contextEl = null; - } - - const by = Object.keys(locator)[0]; - const value = locator[by]; - - this.evaluate_now((by, locator, contextEl) => { - const res = window.codeceptjs.findAndStoreElement(by, locator, contextEl); - if (res === null) { - throw new Error(`Element ${(new Locator(locator))} couldn't be located by ${by}`); - } - return res; - }, done, by, value, contextEl); - }); - - this.Nightmare.action('asyncScript', function () { - let args = Array.prototype.slice.call(arguments); - const done = args.pop(); - args = args.splice(1, 0, done); - this.evaluate_now.apply(this, args); - }); - - this.Nightmare.action('enterText', function (el, text, clean, done) { - const child = this.child; - const typeFn = () => child.call('type', text, done); - - this.evaluate_now((el, clean) => { - const element = window.codeceptjs.fetchElement(el); - if (clean) element.value = ''; - element.focus(); - }, () => { - if (clean) return typeFn(); - child.call('pressKey', 'End', typeFn); // type End before - }, el, clean); - }); - - this.Nightmare.action('pressKey', (ns, options, parent, win, renderer, done) => { - parent.respondTo('pressKey', (ch, done) => { - win.webContents.sendInputEvent({ - type: 'keyDown', - keyCode: ch, - }); - - win.webContents.sendInputEvent({ - type: 'char', - keyCode: ch, - }); - - win.webContents.sendInputEvent({ - type: 'keyUp', - keyCode: ch, - }); - done(); - }); - done(); - }, function (key, done) { - this.child.call('pressKey', key, done); - }); - - this.Nightmare.action('triggerMouseEvent', (ns, options, parent, win, renderer, done) => { - parent.respondTo('triggerMouseEvent', (evt, done) => { - win.webContents.sendInputEvent(evt); - done(); - }); - done(); - }, function (event, done) { - this.child.call('triggerMouseEvent', event, done); - }); - - this.Nightmare.action( - 'upload', - (ns, options, parent, win, renderer, done) => { - parent.respondTo('upload', (selector, pathsToUpload, done) => { - parent.emit('log', 'paths', pathsToUpload); - try { - // attach the debugger - // NOTE: this will fail if devtools is open - win.webContents.debugger.attach('1.1'); - } catch (e) { - parent.emit('log', 'problem attaching', e); - return done(e); - } - - win.webContents.debugger.sendCommand('DOM.getDocument', {}, (err, domDocument) => { - win.webContents.debugger.sendCommand('DOM.querySelector', { - nodeId: domDocument.root.nodeId, - selector, - }, (err, queryResult) => { - // HACK: chromium errors appear to be unpopulated objects? - if (Object.keys(err) - .length > 0) { - parent.emit('log', 'problem selecting', err); - return done(err); - } - win.webContents.debugger.sendCommand('DOM.setFileInputFiles', { - nodeId: queryResult.nodeId, - files: pathsToUpload, - }, (err) => { - if (Object.keys(err) - .length > 0) { - parent.emit('log', 'problem setting input', err); - return done(err); - } - win.webContents.debugger.detach(); - done(null, pathsToUpload); - }); - }); - }); - }); - done(); - }, - function (selector, pathsToUpload, done) { - if (!Array.isArray(pathsToUpload)) { - pathsToUpload = [pathsToUpload]; - } - this.child.call('upload', selector, pathsToUpload, (err, stuff) => { - done(err, stuff); - }); - }, - ); - - return Promise.resolve(); - } - - async _beforeSuite() { - if (!this.options.restart && !this.isRunning) { - this.debugSection('Session', 'Starting singleton browser session'); - return this._startBrowser(); - } - } - - async _before() { - if (this.options.restart) return this._startBrowser(); - if (!this.isRunning) return this._startBrowser(); - return this.browser; - } - - async _after() { - if (!this.isRunning) return; - if (this.options.restart) { - this.isRunning = false; - return this._stopBrowser(); - } - if (this.options.enableHAR) { - await this.browser.resetHAR(); - } - if (this.options.keepBrowserState) return; - if (this.options.keepCookies) { - await this.browser.cookies.clearAll(); - } - this.debugSection('Session', 'cleaning up'); - return this.executeScript(() => localStorage.clear()); - } - - _afterSuite() { - } - - _finishTest() { - if (!this.options.restart && this.isRunning) { - this._stopBrowser(); - } - } - - async _startBrowser() { - this.context = this.options.rootElement; - if (this.options.enableHAR) { - this.browser = this.Nightmare(Object.assign(require('nightmare-har-plugin').getDevtoolsOptions(), this.options)); - await this.browser; - await this.browser.waitForDevtools(); - } else { - this.browser = this.Nightmare(this.options); - await this.browser; - } - await this.browser.goto('about:blank'); // Load a blank page so .saveScreenshot (/evaluate) will work - this.isRunning = true; - this.browser.on('dom-ready', () => this._injectClientScripts()); - this.browser.on('did-start-loading', () => this._injectClientScripts()); - this.browser.on('will-navigate', () => this._injectClientScripts()); - this.browser.on('console', (type, message) => { - this.debug(`${type}: ${message}`); - }); - - if (this.options.windowSize) { - const size = this.options.windowSize.split('x'); - return this.browser.viewport(parseInt(size[0], 10), parseInt(size[1], 10)); - } - } - - /** - * Get HAR - * - * ```js - * let har = await I.grabHAR(); - * fs.writeFileSync('sample.har', JSON.stringify({log: har})); - * ``` - */ - async grabHAR() { - return this.browser.getHAR(); - } - - async saveHAR(fileName) { - const outputFile = path.join(global.output_dir, fileName); - this.debug(`HAR is saving to ${outputFile}`); - - await this.browser.getHAR().then((har) => { - require('fs').writeFileSync(outputFile, JSON.stringify({ log: har })); - }); - } - - async resetHAR() { - await this.browser.resetHAR(); - } - - async _stopBrowser() { - return this.browser.end().catch((error) => { - this.debugSection('Error on End', error); - }); - } - - async _withinBegin(locator) { - this.context = locator; - locator = new Locator(locator, 'css'); - withinStatus = true; - return this.browser.evaluate((by, locator) => { - const el = window.codeceptjs.findElement(by, locator); - if (!el) throw new Error(`Element by ${by}: ${locator} not found`); - window.codeceptjs.within = el; - }, locator.type, locator.value); - } - - _withinEnd() { - this.context = this.options.rootElement; - withinStatus = false; - return this.browser.evaluate(() => { - window.codeceptjs.within = null; - }); - } - - /** - * Locate elements by different locator types, including strict locator. - * Should be used in custom helpers. - * - * This method return promise with array of IDs of found elements. - * Actual elements can be accessed inside `evaluate` by using `codeceptjs.fetchElement()` - * client-side function: - * - * ```js - * // get an inner text of an element - * - * let browser = this.helpers['Nightmare'].browser; - * let value = this.helpers['Nightmare']._locate({name: 'password'}).then(function(els) { - * return browser.evaluate(function(el) { - * return codeceptjs.fetchElement(el).value; - * }, els[0]); - * }); - * ``` - */ - _locate(locator) { - locator = new Locator(locator, 'css'); - return this.browser.evaluate((by, locator) => { - return window.codeceptjs.findAndStoreElements(by, locator); - }, locator.type, locator.value); - } - - /** - * Add a header override for all HTTP requests. If header is undefined, the header overrides will be reset. - * - * ```js - * I.haveHeader('x-my-custom-header', 'some value'); - * I.haveHeader(); // clear headers - * ``` - */ - haveHeader(header, value) { - return this.browser.header(header, value); - } - - /** - * Opens a web page in a browser. Requires relative or absolute url. - * If url starts with `/`, opens a web page of a site defined in `url` config parameter. - * - * ```js - * I.amOnPage('/'); // opens main page of website - * I.amOnPage('https://github.com'); // opens github - * I.amOnPage('/login'); // opens a login page - * ``` - * - * @param {string} url url path or global url. - * @returns {void} automatically synchronized promise through #recorder - * - * @param {?object} headers list of request headers can be passed - * - */ - async amOnPage(url, headers = null) { - if (!(/^\w+\:\/\//.test(url))) { - url = urlResolve(this.options.url, url); - } - const currentUrl = await this.browser.url(); - if (url === currentUrl) { - // navigating to the same url will cause an error in nightmare, so don't do it - return; - } - return this.browser.goto(url, headers).then((res) => { - this.debugSection('URL', res.url); - this.debugSection('Code', res.code); - this.debugSection('Headers', JSON.stringify(res.headers)); - }); - } - - /** - * Checks that title contains text. - * - * ```js - * I.seeInTitle('Home Page'); - * ``` - * - * @param {string} text text value to check. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async seeInTitle(text) { - const title = await this.browser.title(); - stringIncludes('web page title').assert(text, title); - } - - /** - * Checks that title does not contain text. - * - * ```js - * I.dontSeeInTitle('Error'); - * ``` - * - * @param {string} text value to check. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async dontSeeInTitle(text) { - const title = await this.browser.title(); - stringIncludes('web page title').negate(text, title); - } - - /** - * Retrieves a page title and returns it to test. - * Resumes test execution, so **should be used inside async with `await`** operator. - * - * ```js - * let title = await I.grabTitle(); - * ``` - * - * @returns {Promise} title - */ - async grabTitle() { - return this.browser.title(); - } - - /** - * Get current URL from browser. - * Resumes test execution, so should be used inside an async function. - * - * ```js - * let url = await I.grabCurrentUrl(); - * console.log(`Current URL is [${url}]`); - * ``` - * - * @returns {Promise} current URL - */ - async grabCurrentUrl() { - return this.browser.url(); - } - - /** - * Checks that current url contains a provided fragment. - * - * ```js - * I.seeInCurrentUrl('/register'); // we are on registration page - * ``` - * - * @param {string} url a fragment to check - * @returns {void} automatically synchronized promise through #recorder - * - */ - async seeInCurrentUrl(url) { - const currentUrl = await this.browser.url(); - stringIncludes('url').assert(url, currentUrl); - } - - /** - * Checks that current url does not contain a provided fragment. - * - * @param {string} url value to check. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async dontSeeInCurrentUrl(url) { - const currentUrl = await this.browser.url(); - stringIncludes('url').negate(url, currentUrl); - } - - /** - * Checks that current url is equal to provided one. - * If a relative url provided, a configured url will be prepended to it. - * So both examples will work: - * - * ```js - * I.seeCurrentUrlEquals('/register'); - * I.seeCurrentUrlEquals('http://my.site.com/register'); - * ``` - * - * @param {string} url value to check. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async seeCurrentUrlEquals(url) { - const currentUrl = await this.browser.url(); - urlEquals(this.options.url).assert(url, currentUrl); - } - - /** - * Checks that current url is not equal to provided one. - * If a relative url provided, a configured url will be prepended to it. - * - * ```js - * I.dontSeeCurrentUrlEquals('/login'); // relative url are ok - * I.dontSeeCurrentUrlEquals('http://mysite.com/login'); // absolute urls are also ok - * ``` - * - * @param {string} url value to check. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async dontSeeCurrentUrlEquals(url) { - const currentUrl = await this.browser.url(); - urlEquals(this.options.url).negate(url, currentUrl); - } - - /** - * Checks that a page contains a visible text. - * Use context parameter to narrow down the search. - * - * ```js - * I.see('Welcome'); // text welcome on a page - * I.see('Welcome', '.content'); // text inside .content div - * I.see('Register', {css: 'form.register'}); // use strict locator - * ``` - * @param {string} text expected on page. - * @param {?string | object} [context=null] (optional, `null` by default) element located by CSS|Xpath|strict locator in which to search for text. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async see(text, context = null) { - return proceedSee.call(this, 'assert', text, context); - } - - /** - * Opposite to `see`. Checks that a text is not present on a page. - * Use context parameter to narrow down the search. - * - * ```js - * I.dontSee('Login'); // assume we are already logged in. - * I.dontSee('Login', '.nav'); // no login inside .nav element - * ``` - * - * @param {string} text which is not present. - * @param {string | object} [context] (optional) element located by CSS|XPath|strict locator in which to perfrom search. - * @returns {void} automatically synchronized promise through #recorder - * - */ - dontSee(text, context = null) { - return proceedSee.call(this, 'negate', text, context); - } - - /** - * Checks that a given Element is visible - * Element is located by CSS or XPath. - * - * ```js - * I.seeElement('#modal'); - * ``` - * @param {string | object} locator located by CSS|XPath|strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async seeElement(locator) { - locator = new Locator(locator, 'css'); - const num = await this.browser.evaluate((by, locator) => { - return window.codeceptjs.findElements(by, locator).filter(e => e.offsetWidth > 0 && e.offsetHeight > 0).length; - }, locator.type, locator.value); - equals('number of elements on a page').negate(0, num); - } - - /** - * Opposite to `seeElement`. Checks that element is not visible (or in DOM) - * - * ```js - * I.dontSeeElement('.modal'); // modal is not shown - * ``` - * - * @param {string | object} locator located by CSS|XPath|Strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async dontSeeElement(locator) { - locator = new Locator(locator, 'css'); - locator = new Locator(locator, 'css'); - const num = await this.browser.evaluate((by, locator) => { - return window.codeceptjs.findElements(by, locator).filter(e => e.offsetWidth > 0 && e.offsetHeight > 0).length; - }, locator.type, locator.value); - equals('number of elements on a page').assert(0, num); - } - - /** - * Checks that a given Element is present in the DOM - * Element is located by CSS or XPath. - * - * ```js - * I.seeElementInDOM('#modal'); - * ``` - * @param {string | object} locator element located by CSS|XPath|strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async seeElementInDOM(locator) { - locator = new Locator(locator, 'css'); - const els = await this.browser.findElements(locator.toStrict()); - empty('elements').negate(els.fill('ELEMENT')); - } - - /** - * Opposite to `seeElementInDOM`. Checks that element is not on page. - * - * ```js - * I.dontSeeElementInDOM('.nav'); // checks that element is not on page visible or not - * ``` - * - * @param {string | object} locator located by CSS|XPath|Strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async dontSeeElementInDOM(locator) { - locator = new Locator(locator, 'css'); - const els = await this.browser.findElements(locator.toStrict()); - empty('elements').assert(els.fill('ELEMENT')); - } - - /** - * Checks that the current page contains the given string in its raw source code. - * - * ```js - * I.seeInSource('

Green eggs & ham

'); - * ``` - * @param {string} text value to check. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async seeInSource(text) { - const source = await this.browser.evaluate(() => document.documentElement.outerHTML); - stringIncludes('HTML source of a page').assert(text, source); - } - - /** - * Checks that the current page does not contains the given string in its raw source code. - * - * ```js - * I.dontSeeInSource(' - * - * #### Video Recording Customization - * - * By default, video is saved to `output/video` dir. You can customize this path by passing `dir` option to `recordVideo` option. - * - * `video`: enables video recording for failed tests; videos are saved into `output/videos` folder - * * `keepVideoForPassedTests`: - save videos for passed tests - * * `recordVideo`: [additional options for videos customization](https://playwright.dev/docs/next/api/class-browser#browser-new-context) - * - * #### Trace Recording Customization - * - * Trace recording provides complete information on test execution and includes DOM snapshots, screenshots, and network requests logged during run. - * Traces will be saved to `output/trace` - * - * * `trace`: enables trace recording for failed tests; trace are saved into `output/trace` folder - * * `keepTraceForPassedTests`: - save trace for passed tests - * - * #### HAR Recording Customization - * - * A HAR file is an HTTP Archive file that contains a record of all the network requests that are made when a page is loaded. - * It contains information about the request and response headers, cookies, content, timings, and more. You can use HAR files to mock network requests in your tests. - * HAR will be saved to `output/har`. More info could be found here https://playwright.dev/docs/api/class-browser#browser-new-context-option-record-har. - * - * ``` - * ... - * recordHar: { - * mode: 'minimal', // possible values: 'minimal'|'full'. - * content: 'embed' // possible values: "omit"|"embed"|"attach". - * } - * ... - *``` - * - * #### Example #1: Wait for 0 network connections. - * - * ```js - * { - * helpers: { - * Playwright : { - * url: "http://localhost", - * restart: false, - * waitForNavigation: "networkidle0", - * waitForAction: 500 - * } - * } - * } - * ``` - * - * #### Example #2: Wait for DOMContentLoaded event - * - * ```js - * { - * helpers: { - * Playwright : { - * url: "http://localhost", - * restart: false, - * waitForNavigation: "domcontentloaded", - * waitForAction: 500 - * } - * } - * } - * ``` - * - * #### Example #3: Debug in window mode - * - * ```js - * { - * helpers: { - * Playwright : { - * url: "http://localhost", - * show: true - * } - * } - * } - * ``` - * - * #### Example #4: Connect to remote browser by specifying [websocket endpoint](https://playwright.dev/docs/api/class-browsertype#browsertypeconnectparams) - * - * ```js - * { - * helpers: { - * Playwright: { - * url: "http://localhost", - * chromium: { - * browserWSEndpoint: 'ws://localhost:9222/devtools/browser/c5aa6160-b5bc-4d53-bb49-6ecb36cd2e0a', - * cdpConnection: false // default is false - * } - * } - * } - * } - * ``` - * - * #### Example #5: Testing with Chromium extensions - * - * [official docs](https://github.com/microsoft/playwright/blob/v0.11.0/docs/api.md#working-with-chrome-extensions) - * - * ```js - * { - * helpers: { - * Playwright: { - * url: "http://localhost", - * show: true // headless mode not supported for extensions - * chromium: { - * // Note: due to this would launch persistent context, so to avoid the error when running tests with run-workers a timestamp would be appended to the defined folder name. For instance: playwright-tmp_1692715649511 - * userDataDir: '/tmp/playwright-tmp', // necessary to launch the browser in normal mode instead of incognito, - * args: [ - * `--disable-extensions-except=${pathToExtension}`, - * `--load-extension=${pathToExtension}` - * ] - * } - * } - * } - * } - * ``` - * - * #### Example #6: Launch tests emulating iPhone 6 - * - * - * - * ```js - * const { devices } = require('playwright'); - * - * { - * helpers: { - * Playwright: { - * url: "http://localhost", - * emulate: devices['iPhone 6'], - * } - * } - * } - * ``` - * - * #### Example #7: Launch test with a specific user locale - * - * ```js - * { - * helpers: { - * Playwright : { - * url: "http://localhost", - * locale: "fr-FR", - * } - * } - * } - * ``` - * - * * #### Example #8: Launch test with a specific color scheme - * - * ```js - * { - * helpers: { - * Playwright : { - * url: "http://localhost", - * colorScheme: "dark", - * } - * } - * } - * ``` - * - * * #### Example #9: Launch electron test - * - * ```js - * { - * helpers: { - * Playwright: { - * browser: 'electron', - * electron: { - * executablePath: require("electron"), - * args: [path.join('../', "main.js")], - * }, - * } - * }, - * } - * ``` - * - * Note: When connecting to remote browser `show` and specific `chrome` options (e.g. `headless` or `devtools`) are ignored. - * - * ## Access From Helpers - * - * Receive Playwright client from a custom helper by accessing `browser` for the Browser object or `page` for the current Page object: - * - * ```js - * const { browser } = this.helpers.Playwright; - * await browser.pages(); // List of pages in the browser - * - * // get current page - * const { page } = this.helpers.Playwright; - * await page.url(); // Get the url of the current page - * - * const { browserContext } = this.helpers.Playwright; - * await browserContext.cookies(); // get current browser context - * ``` - */ -class Playwright extends Helper { - constructor(config) { - super(config); - - playwright = requireWithFallback('playwright', 'playwright-core'); - - // set defaults - this.isRemoteBrowser = false; - this.isRunning = false; - this.isAuthenticated = false; - this.sessionPages = {}; - this.activeSessionName = ''; - this.isElectron = false; - this.isCDPConnection = false; - this.electronSessions = []; - this.storageState = null; - - // for network stuff - this.requests = []; - this.recording = false; - this.recordedAtLeastOnce = false; - - // for websocket messages - this.webSocketMessages = []; - this.recordingWebSocketMessages = false; - this.recordedWebSocketMessagesAtLeastOnce = false; - this.cdpSession = null; - - // override defaults with config - this._setConfig(config); - } - - _validateConfig(config) { - const defaults = { - // options to emulate context - emulate: {}, - browser: 'chromium', - waitForAction: 100, - waitForTimeout: 1000, - pressKeyDelay: 10, - timeout: 5000, - fullPageScreenshots: false, - disableScreenshots: false, - ignoreLog: ['warning', 'log'], - uniqueScreenshotNames: false, - manualStart: false, - getPageTimeout: 30000, - waitForNavigation: 'load', - restart: false, - keepCookies: false, - keepBrowserState: false, - show: false, - defaultPopupAction: 'accept', - use: { actionTimeout: 0 }, - ignoreHTTPSErrors: false, // Adding it here o that context can be set up to ignore the SSL errors, - highlightElement: false, - }; - - config = Object.assign(defaults, config); - - if (availableBrowsers.indexOf(config.browser) < 0) { - throw new Error(`Invalid config. Can't use browser "${config.browser}". Accepted values: ${availableBrowsers.join(', ')}`); - } - - return config; - } - - _getOptionsForBrowser(config) { - if (config[config.browser]) { - if (config[config.browser].browserWSEndpoint && config[config.browser].browserWSEndpoint.wsEndpoint) { - config[config.browser].browserWSEndpoint = config[config.browser].browserWSEndpoint.wsEndpoint; - } - return { - ...config[config.browser], - wsEndpoint: config[config.browser].browserWSEndpoint, - }; - } - return {}; - } - - _setConfig(config) { - this.options = this._validateConfig(config); - setRestartStrategy(this.options); - this.playwrightOptions = { - headless: !this.options.show, - ...this._getOptionsForBrowser(config), - }; - - if (this.options.channel && this.options.browser === 'chromium') { - this.playwrightOptions.channel = this.options.channel; - } - - if (this.options.video) { - this.options.recordVideo = { size: parseWindowSize(this.options.windowSize) }; - } - if (this.options.recordVideo && !this.options.recordVideo.dir) { - this.options.recordVideo.dir = `${global.output_dir}/videos/`; - } - this.isRemoteBrowser = !!this.playwrightOptions.browserWSEndpoint; - this.isElectron = this.options.browser === 'electron'; - this.userDataDir = this.playwrightOptions.userDataDir ? `${this.playwrightOptions.userDataDir}_${Date.now().toString()}` : undefined; - this.isCDPConnection = this.playwrightOptions.cdpConnection; - popupStore.defaultAction = this.options.defaultPopupAction; - } - - static _config() { - return [ - { - name: 'browser', - message: 'Browser in which testing will be performed. Possible options: chromium, firefox, webkit or electron', - default: 'chromium', - }, - { - name: 'url', - message: 'Base url of site to be tested', - default: 'http://localhost', - when: (answers) => answers.Playwright_browser !== 'electron', - }, - { - name: 'show', - message: 'Show browser window', - default: true, - type: 'confirm', - when: (answers) => answers.Playwright_browser !== 'electron', - }, - ]; - } - - static _checkRequirements() { - try { - requireWithFallback('playwright', 'playwright-core'); - } catch (e) { - return ['playwright@^1.18']; - } - } - - async _init() { - // register an internal selector engine for reading value property of elements in a selector - if (defaultSelectorEnginesInitialized) return; - defaultSelectorEnginesInitialized = true; - try { - await playwright.selectors.register('__value', createValueEngine); - await playwright.selectors.register('__disabled', createDisabledEngine); - } catch (e) { - console.warn(e); - } - } - - _beforeSuite() { - if ((restartsSession() || restartsContext()) && !this.options.manualStart && !this.isRunning) { - this.debugSection('Session', 'Starting singleton browser session'); - return this._startBrowser(); - } - } - - async _before(test) { - this.currentRunningTest = test; - recorder.retry({ - retries: process.env.FAILED_STEP_RETRIES || 3, - when: err => { - if (!err || typeof (err.message) !== 'string') { - return false; - } - // ignore context errors - return err.message.includes('context'); - }, - }); - - if (restartsBrowser() && !this.options.manualStart) await this._startBrowser(); - if (!this.isRunning && !this.options.manualStart) await this._startBrowser(); - - this.isAuthenticated = false; - if (this.isElectron) { - this.browserContext = this.browser.context(); - } else if (this.playwrightOptions.userDataDir) { - this.browserContext = this.browser; - } else { - const contextOptions = { - ignoreHTTPSErrors: this.options.ignoreHTTPSErrors, - acceptDownloads: true, - ...this.options.emulate, - }; - if (this.options.basicAuth) { - contextOptions.httpCredentials = this.options.basicAuth; - this.isAuthenticated = true; - } - if (this.options.bypassCSP) contextOptions.bypassCSP = this.options.bypassCSP; - if (this.options.recordVideo) contextOptions.recordVideo = this.options.recordVideo; - if (this.options.recordHar) { - const harExt = this.options.recordHar.content && this.options.recordHar.content === 'attach' ? 'zip' : 'har'; - const fileName = `${`${global.output_dir}${path.sep}har${path.sep}${uuidv4()}_${clearString(this.currentRunningTest.title)}`.slice(0, 245)}.${harExt}`; - const dir = path.dirname(fileName); - if (!fileExists(dir)) fs.mkdirSync(dir); - this.options.recordHar.path = fileName; - this.currentRunningTest.artifacts.har = fileName; - contextOptions.recordHar = this.options.recordHar; - } - if (this.storageState) contextOptions.storageState = this.storageState; - if (this.options.userAgent) contextOptions.userAgent = this.options.userAgent; - if (this.options.locale) contextOptions.locale = this.options.locale; - if (this.options.colorScheme) contextOptions.colorScheme = this.options.colorScheme; - this.contextOptions = contextOptions; - if (!this.browserContext || !restartsSession()) { - this.browserContext = await this.browser.newContext(this.contextOptions); // Adding the HTTPSError ignore in the context so that we can ignore those errors - } - } - - let mainPage; - if (this.isElectron) { - mainPage = await this.browser.firstWindow(); - } else { - try { - const existingPages = await this.browserContext.pages(); - mainPage = existingPages[0] || await this.browserContext.newPage(); - } catch (e) { - if (this.playwrightOptions.userDataDir) { - this.browser = await playwright[this.options.browser].launchPersistentContext(this.userDataDir, this.playwrightOptions); - this.browserContext = this.browser; - const existingPages = await this.browserContext.pages(); - mainPage = existingPages[0]; - } - } - } - await targetCreatedHandler.call(this, mainPage); - - await this._setPage(mainPage); - - if (this.options.trace) await this.browserContext.tracing.start({ screenshots: true, snapshots: true }); - - return this.browser; - } - - async _after() { - if (!this.isRunning) return; - - if (this.isElectron) { - this.browser.close(); - this.electronSessions.forEach(session => session.close()); - return; - } - - if (restartsSession()) { - return refreshContextSession.bind(this)(); - } - - if (restartsBrowser()) { - this.isRunning = false; - return this._stopBrowser(); - } - - // close other sessions - try { - if ((await this.browser)._type === 'Browser') { - const contexts = await this.browser.contexts(); - const currentContext = contexts[0]; - if (currentContext && (this.options.keepCookies || this.options.keepBrowserState)) { - this.storageState = await currentContext.storageState(); - } - - await Promise.all(contexts.map(c => c.close())); - } - } catch (e) { - console.log(e); - } - - // await this.closeOtherTabs(); - return this.browser; - } - - _afterSuite() {} - - async _finishTest() { - if ((restartsSession() || restartsContext()) && this.isRunning) return this._stopBrowser(); - } - - _session() { - const defaultContext = this.browserContext; - return { - start: async (sessionName = '', config) => { - this.debugSection('New Context', config ? JSON.stringify(config) : 'opened'); - this.activeSessionName = sessionName; - - let browserContext; - let page; - if (this.isElectron) { - const browser = await playwright._electron.launch(this.playwrightOptions); - this.electronSessions.push(browser); - browserContext = browser.context(); - page = await browser.firstWindow(); - } else { - try { - browserContext = await this.browser.newContext(Object.assign(this.contextOptions, config)); - page = await browserContext.newPage(); - } catch (e) { - if (this.playwrightOptions.userDataDir) { - browserContext = await playwright[this.options.browser].launchPersistentContext(`${this.userDataDir}_${this.activeSessionName}`, this.playwrightOptions); - this.browser = browserContext; - page = await browserContext.pages()[0]; - } - } - } - - if (this.options.trace) await browserContext.tracing.start({ screenshots: true, snapshots: true }); - await targetCreatedHandler.call(this, page); - await this._setPage(page); - // Create a new page inside context. - return browserContext; - }, - stop: async () => { - // is closed by _after - }, - loadVars: async (context) => { - if (context) { - this.browserContext = context; - const existingPages = await context.pages(); - this.sessionPages[this.activeSessionName] = existingPages[0]; - return this._setPage(this.sessionPages[this.activeSessionName]); - } - }, - restoreVars: async (session) => { - this.withinLocator = null; - this.browserContext = defaultContext; - - if (!session) { - this.activeSessionName = ''; - } else { - this.activeSessionName = session; - } - const existingPages = await this.browserContext.pages(); - await this._setPage(existingPages[0]); - - return this._waitForAction(); - }, - }; - } - - /** - * Use Playwright API inside a test. - * - * First argument is a description of an action. - * Second argument is async function that gets this helper as parameter. - * - * { [`page`](https://github.com/microsoft/playwright/blob/main/docs/src/api/class-page.md), [`browserContext`](https://github.com/microsoft/playwright/blob/main/docs/src/api/class-browsercontext.md) [`browser`](https://github.com/microsoft/playwright/blob/main/docs/src/api/class-browser.md) } objects from Playwright API are available. - * - * ```js - * I.usePlaywrightTo('emulate offline mode', async ({ browserContext }) => { - * await browserContext.setOffline(true); - * }); - * ``` - * - * @param {string} description used to show in logs. - * @param {function} fn async function that executed with Playwright helper as arguments - */ - usePlaywrightTo(description, fn) { - return this._useTo(...arguments); - } - - /** - * Set the automatic popup response to Accept. - * This must be set before a popup is triggered. - * - * ```js - * I.amAcceptingPopups(); - * I.click('#triggerPopup'); - * I.acceptPopup(); - * ``` - */ - amAcceptingPopups() { - popupStore.actionType = 'accept'; - } - - /** - * Accepts the active JavaScript native popup window, as created by window.alert|window.confirm|window.prompt. - * Don't confuse popups with modal windows, as created by [various - * libraries](http://jster.net/category/windows-modals-popups). - */ - acceptPopup() { - popupStore.assertPopupActionType('accept'); - } - - /** - * Set the automatic popup response to Cancel/Dismiss. - * This must be set before a popup is triggered. - * - * ```js - * I.amCancellingPopups(); - * I.click('#triggerPopup'); - * I.cancelPopup(); - * ``` - */ - amCancellingPopups() { - popupStore.actionType = 'cancel'; - } - - /** - * Dismisses the active JavaScript popup, as created by window.alert|window.confirm|window.prompt. - */ - cancelPopup() { - popupStore.assertPopupActionType('cancel'); - } - - /** - * Checks that the active JavaScript popup, as created by `window.alert|window.confirm|window.prompt`, contains the - * given string. - * - * ```js - * I.seeInPopup('Popup text'); - * ``` - * @param {string} text value to check. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async seeInPopup(text) { - popupStore.assertPopupVisible(); - const popupText = await popupStore.popup.message(); - stringIncludes('text in popup').assert(text, popupText); - } - - /** - * Set current page - * @param {object} page page to set - */ - async _setPage(page) { - page = await page; - this._addPopupListener(page); - this.page = page; - if (!page) return; - this.browserContext.setDefaultTimeout(0); - page.setDefaultNavigationTimeout(this.options.getPageTimeout); - page.setDefaultTimeout(this.options.timeout); - - page.on('crash', async () => { - console.log('ERROR: Page has crashed, closing page!'); - await page.close(); - }); - this.context = await this.page; - this.contextLocator = null; - await page.bringToFront(); - } - - /** - * Add the 'dialog' event listener to a page - * @page {playwright.Page} - * - * The popup listener handles the dialog with the predefined action when it appears on the page. - * It also saves a reference to the object which is used in seeInPopup. - */ - _addPopupListener(page) { - if (!page) { - return; - } - page.removeAllListeners('dialog'); - page.on('dialog', async (dialog) => { - popupStore.popup = dialog; - const action = popupStore.actionType || this.options.defaultPopupAction; - await this._waitForAction(); - - switch (action) { - case 'accept': - return dialog.accept(); - - case 'cancel': - return dialog.dismiss(); - - default: { - throw new Error('Unknown popup action type. Only "accept" or "cancel" are accepted'); - } - } - }); - } - - /** - * Gets page URL including hash. - */ - async _getPageUrl() { - return this.executeScript(() => window.location.href); - } - - /** - * Grab the text within the popup. If no popup is visible then it will return null - * - * ```js - * await I.grabPopupText(); - * ``` - * @return {Promise} - */ - async grabPopupText() { - if (popupStore.popup) { - return popupStore.popup.message(); - } - return null; - } - - async _startBrowser() { - if (this.isElectron) { - this.browser = await playwright._electron.launch(this.playwrightOptions); - } else if (this.isRemoteBrowser && this.isCDPConnection) { - try { - this.browser = await playwright[this.options.browser].connectOverCDP(this.playwrightOptions); - } catch (err) { - if (err.toString().indexOf('ECONNREFUSED')) { - throw new RemoteBrowserConnectionRefused(err); - } - throw err; - } - } else if (this.isRemoteBrowser) { - try { - this.browser = await playwright[this.options.browser].connect(this.playwrightOptions); - } catch (err) { - if (err.toString().indexOf('ECONNREFUSED')) { - throw new RemoteBrowserConnectionRefused(err); - } - throw err; - } - } else if (this.playwrightOptions.userDataDir) { - this.browser = await playwright[this.options.browser].launchPersistentContext(this.userDataDir, this.playwrightOptions); - } else { - this.browser = await playwright[this.options.browser].launch(this.playwrightOptions); - } - - // works only for Chromium - this.browser.on('targetchanged', (target) => { - this.debugSection('Url', target.url()); - }); - - this.isRunning = true; - return this.browser; - } - - /** - * Create a new browser context with a page. \ - * Usually it should be run from a custom helper after call of `_startBrowser()` - * @param {object} [contextOptions] See https://playwright.dev/docs/api/class-browser#browser-new-context - */ - async _createContextPage(contextOptions) { - this.browserContext = await this.browser.newContext(contextOptions); - const page = await this.browserContext.newPage(); - targetCreatedHandler.call(this, page); - await this._setPage(page); - } - - _getType() { - return this.browser._type; - } - - async _stopBrowser() { - this.withinLocator = null; - await this._setPage(null); - this.context = null; - this.frame = null; - popupStore.clear(); - if (this.options.recordHar) await this.browserContext.close(); - await this.browser.close(); - } - - async _evaluateHandeInContext(...args) { - const context = await this._getContext(); - return context.evaluateHandle(...args); - } - - async _withinBegin(locator) { - if (this.withinLocator) { - throw new Error('Can\'t start within block inside another within block'); - } - - const frame = isFrameLocator(locator); - - if (frame) { - if (Array.isArray(frame)) { - await this.switchTo(null); - return frame.reduce((p, frameLocator) => p.then(() => this.switchTo(frameLocator)), Promise.resolve()); - } - await this.switchTo(frame); - this.withinLocator = new Locator(frame); - return; - } - - const el = await this._locateElement(locator); - assertElementExists(el, locator); - this.context = el; - this.contextLocator = locator; - - this.withinLocator = new Locator(locator); - } - - async _withinEnd() { - this.withinLocator = null; - this.context = await this.page; - this.contextLocator = null; - this.frame = null; - } - - _extractDataFromPerformanceTiming(timing, ...dataNames) { - const navigationStart = timing.navigationStart; - - const extractedData = {}; - dataNames.forEach((name) => { - extractedData[name] = timing[name] - navigationStart; - }); - - return extractedData; - } - - /** - * Opens a web page in a browser. Requires relative or absolute url. - * If url starts with `/`, opens a web page of a site defined in `url` config parameter. - * - * ```js - * I.amOnPage('/'); // opens main page of website - * I.amOnPage('https://github.com'); // opens github - * I.amOnPage('/login'); // opens a login page - * ``` - * - * @param {string} url url path or global url. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async amOnPage(url) { - if (this.isElectron) { - throw new Error('Cannot open pages inside an Electron container'); - } - if (!(/^\w+\:(\/\/|.+)/.test(url))) { - url = this.options.url + (url.startsWith('/') ? url : `/${url}`); - } - - if (this.options.basicAuth && (this.isAuthenticated !== true)) { - if (url.includes(this.options.url)) { - await this.browserContext.setHTTPCredentials(this.options.basicAuth); - this.isAuthenticated = true; - } - } - - await this.page.goto(url, { waitUntil: this.options.waitForNavigation }); - - const performanceTiming = JSON.parse(await this.page.evaluate(() => JSON.stringify(window.performance.timing))); - - perfTiming = this._extractDataFromPerformanceTiming( - performanceTiming, - 'responseEnd', - 'domInteractive', - 'domContentLoadedEventEnd', - 'loadEventEnd', - ); - - return this._waitForAction(); - } - - /** - * - * Unlike other drivers Playwright changes the size of a viewport, not the window! - * Playwright does not control the window of a browser, so it can't adjust its real size. - * It also can't maximize a window. - * - * Update configuration to change real window size on start: - * - * ```js - * // inside codecept.conf.js - * // @codeceptjs/configure package must be installed - * { setWindowSize } = require('@codeceptjs/configure'); - * ```` - * - * Resize the current window to provided width and height. - * First parameter can be set to `maximize`. - * - * @param {number} width width in pixels or `maximize`. - * @param {number} height height in pixels. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async resizeWindow(width, height) { - if (width === 'maximize') { - throw new Error('Playwright can\'t control windows, so it can\'t maximize it'); - } - - await this.page.setViewportSize({ width, height }); - return this._waitForAction(); - } - - /** - * Set headers for all next requests - * - * ```js - * I.setPlaywrightRequestHeaders({ - * 'X-Sent-By': 'CodeceptJS', - * }); - * ``` - * - * @param {object} customHeaders headers to set - */ - async setPlaywrightRequestHeaders(customHeaders) { - if (!customHeaders) { - throw new Error('Cannot send empty headers.'); - } - return this.browserContext.setExtraHTTPHeaders(customHeaders); - } - - /** - * Moves cursor to element matched by locator. - * Extra shift can be set with offsetX and offsetY options. - * - * ```js - * I.moveCursorTo('.tooltip'); - * I.moveCursorTo('#submit', 5,5); - * ``` - * - * @param {string | object} locator located by CSS|XPath|strict locator. - * @param {number} [offsetX=0] (optional, `0` by default) X-axis offset. - * @param {number} [offsetY=0] (optional, `0` by default) Y-axis offset. - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async moveCursorTo(locator, offsetX = 0, offsetY = 0) { - const el = await this._locateElement(locator); - assertElementExists(el, locator); - - // Use manual mouse.move instead of .hover() so the offset can be added to the coordinates - const { x, y } = await clickablePoint(el); - await this.page.mouse.move(x + offsetX, y + offsetY); - return this._waitForAction(); - } - - /** - * Calls [focus](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus) on the matching element. - * - * Examples: - * - * ```js - * I.dontSee('#add-to-cart-btn'); - * I.focus('#product-tile') - * I.see('#add-to-cart-bnt'); - * ``` - * - * @param {string | object} locator field located by label|name|CSS|XPath|strict locator. - * @param {any} [options] Playwright only: [Additional options](https://playwright.dev/docs/api/class-locator#locator-focus) for available options object as 2nd argument. - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async focus(locator, options = {}) { - const el = await this._locateElement(locator); - assertElementExists(el, locator, 'Element to focus'); - - await el.focus(options); - return this._waitForAction(); - } - - /** - * Remove focus from a text input, button, etc. - * Calls [blur](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus) on the element. - * - * Examples: - * - * ```js - * I.blur('.text-area') - * ``` - * ```js - * //element `#product-tile` is focused - * I.see('#add-to-cart-btn'); - * I.blur('#product-tile') - * I.dontSee('#add-to-cart-btn'); - * ``` - * - * @param {string | object} locator field located by label|name|CSS|XPath|strict locator. - * @param {any} [options] Playwright only: [Additional options](https://playwright.dev/docs/api/class-locator#locator-blur) for available options object as 2nd argument. - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async blur(locator, options = {}) { - const el = await this._locateElement(locator); - assertElementExists(el, locator, 'Element to blur'); - - await el.blur(options); - return this._waitForAction(); - } - /** - * Return the checked status of given element. - * - * @param {string | object} locator element located by CSS|XPath|strict locator. - * @param {object} [options] See https://playwright.dev/docs/api/class-locator#locator-is-checked - * @return {Promise} - * - */ - - async grabCheckedElementStatus(locator, options = {}) { - const supportedTypes = ['checkbox', 'radio']; - const el = await this._locateElement(locator); - const type = await el.getAttribute('type'); - - if (supportedTypes.includes(type)) { - return el.isChecked(options); - } - throw new Error(`Element is not a ${supportedTypes.join(' or ')} input`); - } - /** - * Return the disabled status of given element. - * - * @param {string | object} locator element located by CSS|XPath|strict locator. - * @param {object} [options] See https://playwright.dev/docs/api/class-locator#locator-is-disabled - * @return {Promise} - * - */ - - async grabDisabledElementStatus(locator, options = {}) { - const el = await this._locateElement(locator); - return el.isDisabled(options); - } - - /** - * - * ```js - * // specify coordinates for source position - * I.dragAndDrop('img.src', 'img.dst', { sourcePosition: {x: 10, y: 10} }) - * ``` - * - * > When no option is set, custom drag and drop would be used, to use the dragAndDrop API from Playwright, please set options, for example `force: true` - * - * Drag an item to a destination element. - * - * ```js - * I.dragAndDrop('#dragHandle', '#container'); - * ``` - * - * @param {string | object} srcElement located by CSS|XPath|strict locator. - * @param {string | object} destElement located by CSS|XPath|strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - * @param {any} [options] [Additional options](https://playwright.dev/docs/api/class-page#page-drag-and-drop) can be passed as 3rd argument. - * - */ - async dragAndDrop(srcElement, destElement, options) { - const src = new Locator(srcElement); - const dst = new Locator(destElement); - - if (options) { - return this.page.dragAndDrop(buildLocatorString(src), buildLocatorString(dst), options); - } - - const _smallWaitInMs = 600; - await this.page.locator(buildLocatorString(src)).hover(); - await this.page.mouse.down(); - await this.page.waitForTimeout(_smallWaitInMs); - - const destElBox = await this.page.locator(buildLocatorString(dst)).boundingBox(); - - await this.page.mouse.move(destElBox.x + destElBox.width / 2, destElBox.y + destElBox.height / 2); - await this.page.locator(buildLocatorString(dst)).hover({ position: { x: 10, y: 10 } }); - await this.page.waitForTimeout(_smallWaitInMs); - await this.page.mouse.up(); - } - - /** - * Restart browser with a new context and a new page - * - * ```js - * // Restart browser and use a new timezone - * I.restartBrowser({ timezoneId: 'America/Phoenix' }); - * // Open URL in a new page in changed timezone - * I.amOnPage('/'); - * // Restart browser, allow reading/copying of text from/into clipboard in Chrome - * I.restartBrowser({ permissions: ['clipboard-read', 'clipboard-write'] }); - * ``` - * - * @param {object} [contextOptions] [Options for browser context](https://playwright.dev/docs/api/class-browser#browser-new-context) when starting new browser - */ - async restartBrowser(contextOptions) { - await this._stopBrowser(); - await this._startBrowser(); - await this._createContextPage(contextOptions); - } - - /** - * Reload the current page. - * - * ```js - * I.refreshPage(); - * ``` - * @returns {void} automatically synchronized promise through #recorder - * - */ - async refreshPage() { - return this.page.reload({ timeout: this.options.getPageTimeout, waitUntil: this.options.waitForNavigation }); - } - - /** - * Replaying from HAR - * - * ```js - * // Replay API requests from HAR. - * // Either use a matching response from the HAR, - * // or abort the request if nothing matches. - * I.replayFromHar('./output/har/something.har', { url: "*\/**\/api/v1/fruits" }); - * I.amOnPage('https://demo.playwright.dev/api-mocking'); - * I.see('CodeceptJS'); - * ``` - * - * @param {string} harFilePath Path to recorded HAR file - * @param {object} [opts] [Options for replaying from HAR](https://playwright.dev/docs/api/class-page#page-route-from-har) - * - * @returns Promise - */ - async replayFromHar(harFilePath, opts) { - const file = path.join(global.codecept_dir, harFilePath); - - if (!fileExists(file)) { - throw new Error(`File at ${file} cannot be found on local system`); - } - - await this.page.routeFromHAR(harFilePath, opts); - } - - /** - * Scroll page to the top. - * - * ```js - * I.scrollPageToTop(); - * ``` - * @returns {void} automatically synchronized promise through #recorder - * - */ - scrollPageToTop() { - return this.executeScript(() => { - window.scrollTo(0, 0); - }); - } - - /** - * Scroll page to the bottom. - * - * ```js - * I.scrollPageToBottom(); - * ``` - * @returns {void} automatically synchronized promise through #recorder - * - */ - async scrollPageToBottom() { - return this.executeScript(() => { - const body = document.body; - const html = document.documentElement; - window.scrollTo(0, Math.max( - body.scrollHeight, - body.offsetHeight, - html.clientHeight, - html.scrollHeight, - html.offsetHeight, - )); - }); - } - - /** - * Scrolls to element matched by locator. - * Extra shift can be set with offsetX and offsetY options. - * - * ```js - * I.scrollTo('footer'); - * I.scrollTo('#submit', 5, 5); - * ``` - * - * @param {string | object} locator located by CSS|XPath|strict locator. - * @param {number} [offsetX=0] (optional, `0` by default) X-axis offset. - * @param {number} [offsetY=0] (optional, `0` by default) Y-axis offset. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async scrollTo(locator, offsetX = 0, offsetY = 0) { - if (typeof locator === 'number' && typeof offsetX === 'number') { - offsetY = offsetX; - offsetX = locator; - locator = null; - } - - if (locator) { - const el = await this._locateElement(locator); - assertElementExists(el, locator, 'Element'); - await el.scrollIntoViewIfNeeded(); - const elementCoordinates = await clickablePoint(el); - await this.executeScript((offsetX, offsetY) => window.scrollBy(offsetX, offsetY), { offsetX: elementCoordinates.x + offsetX, offsetY: elementCoordinates.y + offsetY }); - } else { - await this.executeScript(({ offsetX, offsetY }) => window.scrollTo(offsetX, offsetY), { offsetX, offsetY }); - } - return this._waitForAction(); - } - - /** - * Checks that title contains text. - * - * ```js - * I.seeInTitle('Home Page'); - * ``` - * - * @param {string} text text value to check. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async seeInTitle(text) { - const title = await this.page.title(); - stringIncludes('web page title').assert(text, title); - } - - /** - * Retrieves a page scroll position and returns it to test. - * Resumes test execution, so **should be used inside an async function with `await`** operator. - * - * ```js - * let { x, y } = await I.grabPageScrollPosition(); - * ``` - * - * @returns {Promise} scroll position - * - */ - async grabPageScrollPosition() { - /* eslint-disable comma-dangle */ - function getScrollPosition() { - return { - x: window.pageXOffset, - y: window.pageYOffset - }; - } - /* eslint-enable comma-dangle */ - return this.executeScript(getScrollPosition); - } - - /** - * Checks that title is equal to provided one. - * - * ```js - * I.seeTitleEquals('Test title.'); - * ``` - * - * @param {string} text value to check. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async seeTitleEquals(text) { - const title = await this.page.title(); - return equals('web page title').assert(title, text); - } - - /** - * Checks that title does not contain text. - * - * ```js - * I.dontSeeInTitle('Error'); - * ``` - * - * @param {string} text value to check. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async dontSeeInTitle(text) { - const title = await this.page.title(); - stringIncludes('web page title').negate(text, title); - } - - /** - * Retrieves a page title and returns it to test. - * Resumes test execution, so **should be used inside async with `await`** operator. - * - * ```js - * let title = await I.grabTitle(); - * ``` - * - * @returns {Promise} title - */ - async grabTitle() { - return this.page.title(); - } - - /** - * Get elements by different locator types, including strict locator - * Should be used in custom helpers: - * - * ```js - * const elements = await this.helpers['Playwright']._locate({name: 'password'}); - * ``` - */ - async _locate(locator) { - const context = await this.context || await this._getContext(); - - if (this.frame) return findElements(this.frame, locator); - - return findElements(context, locator); - } - - /** - * Get the first element by different locator types, including strict locator - * Should be used in custom helpers: - * - * ```js - * const element = await this.helpers['Playwright']._locateElement({name: 'password'}); - * ``` - */ - async _locateElement(locator) { - const context = await this.context || await this._getContext(); - return findElement(context, locator); - } - - /** - * Find a checkbox by providing human-readable text: - * NOTE: Assumes the checkable element exists - * - * ```js - * this.helpers['Playwright']._locateCheckable('I agree with terms and conditions').then // ... - * ``` - */ - async _locateCheckable(locator, providedContext = null) { - const context = providedContext || await this._getContext(); - const els = await findCheckable.call(this, locator, context); - assertElementExists(els[0], locator, 'Checkbox or radio'); - return els[0]; - } - - /** - * Find a clickable element by providing human-readable text: - * - * ```js - * this.helpers['Playwright']._locateClickable('Next page').then // ... - * ``` - */ - async _locateClickable(locator) { - const context = await this._getContext(); - return findClickable.call(this, context, locator); - } - - /** - * Find field elements by providing human-readable text: - * - * ```js - * this.helpers['Playwright']._locateFields('Your email').then // ... - * ``` - */ - async _locateFields(locator) { - return findFields.call(this, locator); - } - - /** - * Grab WebElements for given locator - * Resumes test execution, so **should be used inside an async function with `await`** operator. - * - * ```js - * const webElements = await I.grabWebElements('#button'); - * ``` - * - * @param {string | object} locator element located by CSS|XPath|strict locator. - * @returns {Promise<*>} WebElement of being used Web helper - * - * - */ - async grabWebElements(locator) { - return this._locate(locator); - } - - /** - * Grab WebElement for given locator - * Resumes test execution, so **should be used inside an async function with `await`** operator. - * - * ```js - * const webElement = await I.grabWebElement('#button'); - * ``` - * - * @param {string | object} locator element located by CSS|XPath|strict locator. - * @returns {Promise<*>} WebElement of being used Web helper - * - * - */ - async grabWebElement(locator) { - return this._locateElement(locator); - } - - /** - * Switch focus to a particular tab by its number. It waits tabs loading and then switch tab - * - * ```js - * I.switchToNextTab(); - * I.switchToNextTab(2); - * ``` - * - * @param {number} [num=1] - */ - async switchToNextTab(num = 1) { - if (this.isElectron) { - throw new Error('Cannot switch tabs inside an Electron container'); - } - const pages = await this.browserContext.pages(); - - const index = pages.indexOf(this.page); - this.withinLocator = null; - const page = pages[index + num]; - - if (!page) { - throw new Error(`There is no ability to switch to next tab with offset ${num}`); - } - await targetCreatedHandler.call(this, page); - await this._setPage(page); - return this._waitForAction(); - } - - /** - * Switch focus to a particular tab by its number. It waits tabs loading and then switch tab - * - * ```js - * I.switchToPreviousTab(); - * I.switchToPreviousTab(2); - * ``` - * @param {number} [num=1] - */ - async switchToPreviousTab(num = 1) { - if (this.isElectron) { - throw new Error('Cannot switch tabs inside an Electron container'); - } - const pages = await this.browserContext.pages(); - const index = pages.indexOf(this.page); - this.withinLocator = null; - const page = pages[index - num]; - - if (!page) { - throw new Error(`There is no ability to switch to previous tab with offset ${num}`); - } - - await this._setPage(page); - return this._waitForAction(); - } - - /** - * Close current tab and switches to previous. - * - * ```js - * I.closeCurrentTab(); - * ``` - */ - async closeCurrentTab() { - if (this.isElectron) { - throw new Error('Cannot close current tab inside an Electron container'); - } - const oldPage = this.page; - await this.switchToPreviousTab(); - await oldPage.close(); - return this._waitForAction(); - } - - /** - * Close all tabs except for the current one. - * - * ```js - * I.closeOtherTabs(); - * ``` - */ - async closeOtherTabs() { - const pages = await this.browserContext.pages(); - const otherPages = pages.filter(page => page !== this.page); - if (otherPages.length) { - this.debug(`Closing ${otherPages.length} tabs`); - return Promise.all(otherPages.map(p => p.close())); - } - return Promise.resolve(); - } - - /** - * Open new tab and automatically switched to new tab - * - * ```js - * I.openNewTab(); - * ``` - * - * You can pass in [page options](https://github.com/microsoft/playwright/blob/main/docs/api.md#browsernewpageoptions) to emulate device on this page - * - * ```js - * // enable mobile - * I.openNewTab({ isMobile: true }); - * ``` - */ - async openNewTab(options) { - if (this.isElectron) { - throw new Error('Cannot open new tabs inside an Electron container'); - } - const page = await this.browserContext.newPage(options); - await targetCreatedHandler.call(this, page); - await this._setPage(page); - return this._waitForAction(); - } - - /** - * Grab number of open tabs. - * Resumes test execution, so **should be used inside async function with `await`** operator. - * - * ```js - * let tabs = await I.grabNumberOfOpenTabs(); - * ``` - * - * @returns {Promise} number of open tabs - * - */ - async grabNumberOfOpenTabs() { - const pages = await this.browserContext.pages(); - return pages.length; - } - - /** - * Checks that a given Element is visible - * Element is located by CSS or XPath. - * - * ```js - * I.seeElement('#modal'); - * ``` - * @param {string | object} locator located by CSS|XPath|strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async seeElement(locator) { - let els = await this._locate(locator); - els = await Promise.all(els.map(el => el.isVisible())); - try { - return empty('visible elements').negate(els.filter(v => v).fill('ELEMENT')); - } catch (e) { - dontSeeElementError(locator); - } - } - - /** - * Opposite to `seeElement`. Checks that element is not visible (or in DOM) - * - * ```js - * I.dontSeeElement('.modal'); // modal is not shown - * ``` - * - * @param {string | object} locator located by CSS|XPath|Strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async dontSeeElement(locator) { - let els = await this._locate(locator); - els = await Promise.all(els.map(el => el.isVisible())); - try { - return empty('visible elements').assert(els.filter(v => v).fill('ELEMENT')); - } catch (e) { - seeElementError(locator); - } - } - - /** - * Checks that a given Element is present in the DOM - * Element is located by CSS or XPath. - * - * ```js - * I.seeElementInDOM('#modal'); - * ``` - * @param {string | object} locator element located by CSS|XPath|strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async seeElementInDOM(locator) { - const els = await this._locate(locator); - try { - return empty('elements on page').negate(els.filter(v => v).fill('ELEMENT')); - } catch (e) { - dontSeeElementInDOMError(locator); - } - } - - /** - * Opposite to `seeElementInDOM`. Checks that element is not on page. - * - * ```js - * I.dontSeeElementInDOM('.nav'); // checks that element is not on page visible or not - * ``` - * - * @param {string | object} locator located by CSS|XPath|Strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async dontSeeElementInDOM(locator) { - const els = await this._locate(locator); - try { - return empty('elements on a page').assert(els.filter(v => v).fill('ELEMENT')); - } catch (e) { - seeElementInDOMError(locator); - } - } - - /** - * Handles a file download. A file name is required to save the file on disk. - * Files are saved to "output" directory. - * - * Should be used with [FileSystem helper](https://codecept.io/helpers/FileSystem) to check that file were downloaded correctly. - * - * ```js - * I.handleDownloads('downloads/avatar.jpg'); - * I.click('Download Avatar'); - * I.amInPath('output/downloads'); - * I.waitForFile('avatar.jpg', 5); - * - * ``` - * - * @param {string} fileName set filename for downloaded file - * @return {Promise} - */ - async handleDownloads(fileName) { - this.page.waitForEvent('download').then(async (download) => { - const filePath = await download.path(); - fileName = fileName || `downloads/${path.basename(filePath)}`; - - const downloadPath = path.join(global.output_dir, fileName); - if (!fs.existsSync(path.dirname(downloadPath))) { - fs.mkdirSync(path.dirname(downloadPath), '0777'); - } - fs.copyFileSync(filePath, downloadPath); - this.debug('Download completed'); - this.debugSection('Downloaded From', await download.url()); - this.debugSection('Downloaded To', downloadPath); - }); - } - - /** - * Perform a click on a link or a button, given by a locator. - * If a fuzzy locator is given, the page will be searched for a button, link, or image matching the locator string. - * For buttons, the "value" attribute, "name" attribute, and inner text are searched. For links, the link text is searched. - * For images, the "alt" attribute and inner text of any parent links are searched. - * - * The second parameter is a context (CSS or XPath locator) to narrow the search. - * - * ```js - * // simple link - * I.click('Logout'); - * // button of form - * I.click('Submit'); - * // CSS button - * I.click('#form input[type=submit]'); - * // XPath - * I.click('//form/*[@type=submit]'); - * // link in context - * I.click('Logout', '#nav'); - * // using strict locator - * I.click({css: 'nav a.login'}); - * ``` - * - * @param {string | object} locator clickable link or button located by text, or any element located by CSS|XPath|strict locator. - * @param {?string | object | null} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - * - * @param {any} [options] [Additional options](https://playwright.dev/docs/api/class-page#page-click) for click available as 3rd argument. - * - * @example - * - * ```js - * // click on element at position - * I.click('canvas', '.model', { position: { x: 20, y: 40 } }) - * - * // make ctrl-click - * I.click('.edit', null, { modifiers: ['Ctrl'] } ) - * ``` - * - */ - async click(locator, context = null, options = {}) { - return proceedClick.call(this, locator, context, options); - } - - /** - * Clicks link and waits for navigation (deprecated) - */ - async clickLink(locator, context = null) { - console.log('clickLink deprecated: Playwright automatically waits for navigation to happen.'); - console.log('Replace I.clickLink with I.click'); - return this.click(locator, context); - } - - /** - * Perform an emulated click on a link or a button, given by a locator. - * Unlike normal click instead of sending native event, emulates a click with JavaScript. - * This works on hidden, animated or inactive elements as well. - * - * If a fuzzy locator is given, the page will be searched for a button, link, or image matching the locator string. - * For buttons, the "value" attribute, "name" attribute, and inner text are searched. For links, the link text is searched. - * For images, the "alt" attribute and inner text of any parent links are searched. - * - * The second parameter is a context (CSS or XPath locator) to narrow the search. - * - * ```js - * // simple link - * I.forceClick('Logout'); - * // button of form - * I.forceClick('Submit'); - * // CSS button - * I.forceClick('#form input[type=submit]'); - * // XPath - * I.forceClick('//form/*[@type=submit]'); - * // link in context - * I.forceClick('Logout', '#nav'); - * // using strict locator - * I.forceClick({css: 'nav a.login'}); - * ``` - * - * @param {string | object} locator clickable link or button located by text, or any element located by CSS|XPath|strict locator. - * @param {?string | object} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async forceClick(locator, context = null) { - return proceedClick.call(this, locator, context, { force: true }); - } - - /** - * Performs a double-click on an element matched by link|button|label|CSS or XPath. - * Context can be specified as second parameter to narrow search. - * - * ```js - * I.doubleClick('Edit'); - * I.doubleClick('Edit', '.actions'); - * I.doubleClick({css: 'button.accept'}); - * I.doubleClick('.btn.edit'); - * ``` - * - * @param {string | object} locator clickable link or button located by text, or any element located by CSS|XPath|strict locator. - * @param {?string | object} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async doubleClick(locator, context = null) { - return proceedClick.call(this, locator, context, { clickCount: 2 }); - } - - /** - * Performs right click on a clickable element matched by semantic locator, CSS or XPath. - * - * ```js - * // right click element with id el - * I.rightClick('#el'); - * // right click link or button with text "Click me" - * I.rightClick('Click me'); - * // right click button with text "Click me" inside .context - * I.rightClick('Click me', '.context'); - * ``` - * - * @param {string | object} locator clickable element located by CSS|XPath|strict locator. - * @param {?string | object} [context=null] (optional, `null` by default) element located by CSS|XPath|strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async rightClick(locator, context = null) { - return proceedClick.call(this, locator, context, { button: 'right' }); - } - - /** - * - * [Additional options](https://playwright.dev/docs/api/class-elementhandle#element-handle-check) for check available as 3rd argument. - * - * Examples: - * - * ```js - * // click on element at position - * I.checkOption('Agree', '.signup', { position: { x: 5, y: 5 } }) - * ``` - * > ⚠️ To avoid flakiness, option `force: true` is set by default - * - * Selects a checkbox or radio button. - * Element is located by label or name or CSS or XPath. - * - * The second parameter is a context (CSS or XPath locator) to narrow the search. - * - * ```js - * I.checkOption('#agree'); - * I.checkOption('I Agree to Terms and Conditions'); - * I.checkOption('agree', '//form'); - * ``` - * @param {string | object} field checkbox located by label | name | CSS | XPath | strict locator. - * @param {?string | object} [context=null] (optional, `null` by default) element located by CSS | XPath | strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async checkOption(field, context = null, options = { force: true }) { - const elm = await this._locateCheckable(field, context); - await elm.check(options); - return this._waitForAction(); - } - - /** - * - * [Additional options](https://playwright.dev/docs/api/class-elementhandle#element-handle-uncheck) for uncheck available as 3rd argument. - * - * Examples: - * - * ```js - * // click on element at position - * I.uncheckOption('Agree', '.signup', { position: { x: 5, y: 5 } }) - * ``` - * > ⚠️ To avoid flakiness, option `force: true` is set by default - * - * Unselects a checkbox or radio button. - * Element is located by label or name or CSS or XPath. - * - * The second parameter is a context (CSS or XPath locator) to narrow the search. - * - * ```js - * I.uncheckOption('#agree'); - * I.uncheckOption('I Agree to Terms and Conditions'); - * I.uncheckOption('agree', '//form'); - * ``` - * @param {string | object} field checkbox located by label | name | CSS | XPath | strict locator. - * @param {?string | object} [context=null] (optional, `null` by default) element located by CSS | XPath | strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async uncheckOption(field, context = null, options = { force: true }) { - const elm = await this._locateCheckable(field, context); - await elm.uncheck(options); - return this._waitForAction(); - } - - /** - * Verifies that the specified checkbox is checked. - * - * ```js - * I.seeCheckboxIsChecked('Agree'); - * I.seeCheckboxIsChecked('#agree'); // I suppose user agreed to terms - * I.seeCheckboxIsChecked({css: '#signup_form input[type=checkbox]'}); - * ``` - * - * @param {string | object} field located by label|name|CSS|XPath|strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async seeCheckboxIsChecked(field) { - return proceedIsChecked.call(this, 'assert', field); - } - - /** - * Verifies that the specified checkbox is not checked. - * - * ```js - * I.dontSeeCheckboxIsChecked('#agree'); // located by ID - * I.dontSeeCheckboxIsChecked('I agree to terms'); // located by label - * I.dontSeeCheckboxIsChecked('agree'); // located by name - * ``` - * - * @param {string | object} field located by label|name|CSS|XPath|strict locator. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async dontSeeCheckboxIsChecked(field) { - return proceedIsChecked.call(this, 'negate', field); - } - - /** - * Presses a key in the browser and leaves it in a down state. - * - * To make combinations with modifier key and user operation (e.g. `'Control'` + [`click`](#click)). - * - * ```js - * I.pressKeyDown('Control'); - * I.click('#element'); - * I.pressKeyUp('Control'); - * ``` - * - * @param {string} key name of key to press down. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async pressKeyDown(key) { - key = getNormalizedKey.call(this, key); - await this.page.keyboard.down(key); - return this._waitForAction(); - } - - /** - * Releases a key in the browser which was previously set to a down state. - * - * To make combinations with modifier key and user operation (e.g. `'Control'` + [`click`](#click)). - * - * ```js - * I.pressKeyDown('Control'); - * I.click('#element'); - * I.pressKeyUp('Control'); - * ``` - * - * @param {string} key name of key to release. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async pressKeyUp(key) { - key = getNormalizedKey.call(this, key); - await this.page.keyboard.up(key); - return this._waitForAction(); - } - - /** - * - * _Note:_ Shortcuts like `'Meta'` + `'A'` do not work on macOS ([GoogleChrome/Puppeteer#1313](https://github.com/GoogleChrome/puppeteer/issues/1313)). - * - * Presses a key in the browser (on a focused element). - * - * _Hint:_ For populating text field or textarea, it is recommended to use [`fillField`](#fillfield). - * - * ```js - * I.pressKey('Backspace'); - * ``` - * - * To press a key in combination with modifier keys, pass the sequence as an array. All modifier keys (`'Alt'`, `'Control'`, `'Meta'`, `'Shift'`) will be released afterwards. - * - * ```js - * I.pressKey(['Control', 'Z']); - * ``` - * - * For specifying operation modifier key based on operating system it is suggested to use `'CommandOrControl'`. - * This will press `'Command'` (also known as `'Meta'`) on macOS machines and `'Control'` on non-macOS machines. - * - * ```js - * I.pressKey(['CommandOrControl', 'Z']); - * ``` - * - * Some of the supported key names are: - * - `'AltLeft'` or `'Alt'` - * - `'AltRight'` - * - `'ArrowDown'` - * - `'ArrowLeft'` - * - `'ArrowRight'` - * - `'ArrowUp'` - * - `'Backspace'` - * - `'Clear'` - * - `'ControlLeft'` or `'Control'` - * - `'ControlRight'` - * - `'Command'` - * - `'CommandOrControl'` - * - `'Delete'` - * - `'End'` - * - `'Enter'` - * - `'Escape'` - * - `'F1'` to `'F12'` - * - `'Home'` - * - `'Insert'` - * - `'MetaLeft'` or `'Meta'` - * - `'MetaRight'` - * - `'Numpad0'` to `'Numpad9'` - * - `'NumpadAdd'` - * - `'NumpadDecimal'` - * - `'NumpadDivide'` - * - `'NumpadMultiply'` - * - `'NumpadSubtract'` - * - `'PageDown'` - * - `'PageUp'` - * - `'Pause'` - * - `'Return'` - * - `'ShiftLeft'` or `'Shift'` - * - `'ShiftRight'` - * - `'Space'` - * - `'Tab'` - * - * @param {string|string[]} key key or array of keys to press. - * @returns {void} automatically synchronized promise through #recorder - * - */ - async pressKey(key) { - const modifiers = []; - if (Array.isArray(key)) { - for (let k of key) { - k = getNormalizedKey.call(this, k); - if (isModifierKey(k)) { - modifiers.push(k); - } else { - key = k; - break; - } - } - } else { - key = getNormalizedKey.call(this, key); - } - for (const modifier of modifiers) { - await this.page.keyboard.down(modifier); - } - await this.page.keyboard.press(key); - for (const modifier of modifiers) { - await this.page.keyboard.up(modifier); - } - return this._waitForAction(); - } - - /** - * Types out the given text into an active field. - * To slow down typing use a second parameter, to set interval between key presses. - * _Note:_ Should be used when [`fillField`](#fillfield) is not an option. - * - * ```js - * // passing in a string - * I.type('Type this out.'); - * - * // typing values with a 100ms interval - * I.type('4141555311111111', 100); - * - * // passing in an array - * I.type(['T', 'E', 'X', 'T']); - * - * // passing a secret - * I.type(secret('123456')); - * ``` - * - * @param {string|string[]} key or array of keys to type. - * @param {?number} [delay=null] (optional) delay in ms between key presses - * @returns {void} automatically synchronized promise through #recorder - * - */ - async type(keys, delay = null) { - if (!Array.isArray(keys)) { - keys = keys.toString(); - keys = keys.split(''); - } - - for (const key of keys) { - await this.page.keyboard.press(key); - if (delay) await this.wait(delay / 1000); - } - } - - /** - * Fills a text field or textarea, after clearing its value, with the given string. - * Field is located by name, label, CSS, or XPath. - * - * ```js - * // by label - * I.fillField('Email', 'hello@world.com'); - * // by name - * I.fillField('password', secret('123456')); - * // by CSS - * I.fillField('form#login input[name=username]', 'John'); - * // or by strict locator - * I.fillField({css: 'form#login input[name=username]'}, 'John'); - * ``` - * @param {string | object} field located by label|name|CSS|XPath|strict locator. - * @param {string | object} value text value to fill. - * @returns {void} automatically synchronized promise through #recorder - * - * - */ - async fillField(field, value) { - const els = await findFields.call(this, field); - assertElementExists(els, field, 'Field'); - const el = els[0]; - - await el.clear(); - - await highlightActiveElement.call(this, el); - - await el.type(value.toString(), { delay: this.options.pressKeyDelay }); - - return this._waitForAction(); - } - - /** - * Clears the text input element: ``, `