From 06513d68fbac0d222a0c2882cd94317337512a6a Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 14 Mar 2024 14:08:51 +0000 Subject: [PATCH] DOC: Autogenerate and update documentation --- docs/build/ApiDataFactory.js | 410 +++ docs/build/Appium.js | 2051 ++++++++++++ docs/build/Expect.js | 425 +++ docs/build/FileSystem.js | 228 ++ docs/build/GraphQL.js | 229 ++ docs/build/GraphQLDataFactory.js | 309 ++ docs/build/JSONResponse.js | 338 ++ docs/build/Mochawesome.js | 71 + docs/build/MockServer.js | 221 ++ docs/build/Nightmare.js | 2152 +++++++++++++ docs/build/OpenAI.js | 126 + docs/build/Playwright.js | 5053 ++++++++++++++++++++++++++++++ docs/build/Protractor.js | 2706 ++++++++++++++++ docs/build/Puppeteer.js | 4028 ++++++++++++++++++++++++ docs/build/REST.js | 347 ++ docs/build/TestCafe.js | 2125 +++++++++++++ docs/build/WebDriver.js | 4640 +++++++++++++++++++++++++++ docs/helpers/Expect.md | 2 +- 18 files changed, 25460 insertions(+), 1 deletion(-) create mode 100644 docs/build/ApiDataFactory.js create mode 100644 docs/build/Appium.js create mode 100644 docs/build/Expect.js create mode 100644 docs/build/FileSystem.js create mode 100644 docs/build/GraphQL.js create mode 100644 docs/build/GraphQLDataFactory.js create mode 100644 docs/build/JSONResponse.js create mode 100644 docs/build/Mochawesome.js create mode 100644 docs/build/MockServer.js create mode 100644 docs/build/Nightmare.js create mode 100644 docs/build/OpenAI.js create mode 100644 docs/build/Playwright.js create mode 100644 docs/build/Protractor.js create mode 100644 docs/build/Puppeteer.js create mode 100644 docs/build/REST.js create mode 100644 docs/build/TestCafe.js create mode 100644 docs/build/WebDriver.js diff --git a/docs/build/ApiDataFactory.js b/docs/build/ApiDataFactory.js new file mode 100644 index 000000000..e8f9c5dd6 --- /dev/null +++ b/docs/build/ApiDataFactory.js @@ -0,0 +1,410 @@ +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 new file mode 100644 index 000000000..8a5dfe509 --- /dev/null +++ b/docs/build/Appium.js @@ -0,0 +1,2051 @@ +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 new file mode 100644 index 000000000..c9de3693c --- /dev/null +++ b/docs/build/Expect.js @@ -0,0 +1,425 @@ +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 new file mode 100644 index 000000000..830936c7d --- /dev/null +++ b/docs/build/FileSystem.js @@ -0,0 +1,228 @@ +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 new file mode 100644 index 000000000..fb87f16a2 --- /dev/null +++ b/docs/build/GraphQL.js @@ -0,0 +1,229 @@ +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 new file mode 100644 index 000000000..9a6d802e6 --- /dev/null +++ b/docs/build/GraphQLDataFactory.js @@ -0,0 +1,309 @@ +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 new file mode 100644 index 000000000..e651a6833 --- /dev/null +++ b/docs/build/JSONResponse.js @@ -0,0 +1,338 @@ +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 new file mode 100644 index 000000000..826b2f232 --- /dev/null +++ b/docs/build/Mochawesome.js @@ -0,0 +1,71 @@ +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 new file mode 100644 index 000000000..9cb748808 --- /dev/null +++ b/docs/build/MockServer.js @@ -0,0 +1,221 @@ +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 new file mode 100644 index 000000000..18174d700 --- /dev/null +++ b/docs/build/Nightmare.js @@ -0,0 +1,2152 @@ +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: ``, `