UNPKG

codeceptjs

Version:

Modern Era Acceptance Testing Framework for NodeJS

1,755 lines (1,518 loc) 98.6 kB
const requireg = require('requireg'); const Helper = require('../helper'); const Locator = require('../locator'); const recorder = require('../recorder'); const stringIncludes = require('../assert/include').includes; const { urlEquals } = require('../assert/equal'); const { equals } = require('../assert/equal'); const { empty } = require('../assert/empty'); const { truth } = require('../assert/truth'); const isElementClickable = require('./scripts/isElementClickable'); const { xpathLocator, ucfirst, fileExists, chunkArray, convertCssPropertiesToCamelCase, screenshotOutputFolder, getNormalizedKeyAttributeValue, isModifierKey, } = require('../utils'); const { isColorProperty, convertColorToRGBA, } = require('../colorUtils'); const path = require('path'); const ElementNotFound = require('./errors/ElementNotFound'); const RemoteBrowserConnectionRefused = require('./errors/RemoteBrowserConnectionRefused'); const Popup = require('./extras/Popup'); const Console = require('./extras/Console'); const findReact = require('./extras/React'); const axios = require('axios'); const fs = require('fs'); const fsExtra = require('fs-extra'); let puppeteer; let perfTiming; const popupStore = new Popup(); const consoleLogStore = new Console(); /** * Uses [Google Chrome's Puppeteer](https://github.com/GoogleChrome/puppeteer) library to run tests inside headless Chrome. * Browser control is executed via DevTools Protocol (instead of Selenium). * This helper works with a browser out of the box with no additional tools required to install. * * Requires `puppeteer` package to be installed. * * > Experimental Firefox support [can be activated](https://codecept.io/helpers/Puppeteer-firefox). * * ## Configuration * * This helper should be configured in codecept.json or codecept.conf.js * * * `url`: base url of website to be tested * * `show`: (optional, default: false) - show Google Chrome window for debug. * * `restart`: (optional, default: true) - restart browser between tests. * * `disableScreenshots`: (optional, default: false) - don't save screenshot on failure. * * `fullPageScreenshots` (optional, default: false) - make full page screenshots on failure. * * `uniqueScreenshotNames`: (optional, default: false) - option to prevent screenshot override if you have scenarios with the same name in different suites. * * `keepBrowserState`: (optional, default: false) - keep browser state between tests when `restart` is set to false. * * `keepCookies`: (optional, default: false) - keep cookies between tests when `restart` is set to false. * * `waitForAction`: (optional) how long to wait after click, doubleClick or PressKey actions in ms. Default: 100. * * `waitForNavigation`: (optional, default: 'load'). When to consider navigation succeeded. Possible options: `load`, `domcontentloaded`, `networkidle0`, `networkidle2`. See [Puppeteer API](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagewaitfornavigationoptions). Array values are accepted as well. * * `pressKeyDelay`: (optional, default: '10'). Delay between key presses in ms. Used when calling Puppeteers page.type(...) in fillField/appendField * * `getPageTimeout` (optional, default: '0') config option to set maximum navigation time in milliseconds. * * `waitForTimeout`: (optional) default wait* timeout in ms. Default: 1000. * * `windowSize`: (optional) default window size. Set a dimension like `640x480`. * * `userAgent`: (optional) user-agent string. * * `manualStart`: (optional, default: false) - do not start browser before a test, start it manually inside a helper with `this.helpers["Puppeteer"]._startBrowser()`. * * `browser`: (optional, default: chrome) - can be changed to `firefox` when using [puppeteer-firefox](https://codecept.io/helpers/Puppeteer-firefox). * * `chrome`: (optional) pass additional [Puppeteer run options](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#puppeteerlaunchoptions). * * * #### Example #1: Wait for 0 network connections. * * ```js * { * helpers: { * Puppeteer : { * url: "http://localhost", * restart: false, * waitForNavigation: "networkidle0", * waitForAction: 500 * } * } * } * ``` * * #### Example #2: Wait for DOMContentLoaded event and 0 network connections * * ```js * { * helpers: { * Puppeteer : { * url: "http://localhost", * restart: false, * waitForNavigation: [ "domcontentloaded", "networkidle0" ], * waitForAction: 500 * } * } * } * ``` * * #### Example #3: Debug in window mode * * ```js * { * helpers: { * Puppeteer : { * url: "http://localhost", * show: true * } * } * } * ``` * * #### Example #4: Connect to remote browser by specifying [websocket endpoint](https://chromedevtools.github.io/devtools-protocol/#how-do-i-access-the-browser-target) * * ```js * { * helpers: { * Puppeteer: { * url: "http://localhost", * chrome: { * browserWSEndpoint: "ws://localhost:9222/devtools/browser/c5aa6160-b5bc-4d53-bb49-6ecb36cd2e0a" * } * } * } * } * ``` * * Note: When connecting to remote browser `show` and specific `chrome` options (e.g. `headless` or `devtools`) are ignored. * * ## Access From Helpers * * Receive Puppeteer client from a custom helper by accessing `browser` for the Browser object or `page` for the current Page object: * * ```js * const { browser } = this.helpers.Puppeteer; * await browser.pages(); // List of pages in the browser * * const { page } = this.helpers.Puppeteer; * await page.url(); // Get the url of the current page * ``` * * ## Methods */ class Puppeteer extends Helper { constructor(config) { super(config); puppeteer = requireg(config.browser === 'firefox' ? 'puppeteer-firefox' : 'puppeteer'); // set defaults this.isRemoteBrowser = false; this.isRunning = false; // override defaults with config this._setConfig(config); } _validateConfig(config) { const defaults = { browser: 'chrome', waitForAction: 100, waitForTimeout: 1000, pressKeyDelay: 10, fullPageScreenshots: false, disableScreenshots: false, uniqueScreenshotNames: false, manualStart: false, getPageTimeout: 0, waitForNavigation: 'load', restart: true, keepCookies: false, keepBrowserState: false, show: false, defaultPopupAction: 'accept', }; return Object.assign(defaults, config); } _getOptions(config) { return config.browser === 'firefox' ? this.options.firefox : this.options.chrome; } _setConfig(config) { this.options = this._validateConfig(config); this.puppeteerOptions = Object.assign({ headless: !this.options.show }, this._getOptions(config)); this.isRemoteBrowser = !!this.puppeteerOptions.browserWSEndpoint; popupStore.defaultAction = this.options.defaultPopupAction; } 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 { requireg('puppeteer'); } catch (e) { return ['puppeteer@^1.6.0']; } } _init() { } _beforeSuite() { if (!this.options.restart && !this.options.manualStart && !this.isRunning) { this.debugSection('Session', 'Starting singleton browser session'); return this._startBrowser(); } } async _before() { recorder.retry({ retries: 5, when: err => err.message.indexOf('context') > -1, // ignore context errors }); if (this.options.restart && !this.options.manualStart) return this._startBrowser(); if (!this.isRunning && !this.options.manualStart) return this._startBrowser(); return this.browser; } async _after() { if (!this.isRunning) return; // close other sessions const contexts = this.browser.browserContexts(); contexts.shift(); await Promise.all(contexts.map(c => c.close())); if (this.options.restart) { this.isRunning = false; return this._stopBrowser(); } if (this.options.keepBrowserState) return; if (!this.options.keepCookies) { this.debugSection('Session', 'cleaning cookies and localStorage'); await this.clearCookie(); } const currentUrl = await this.grabCurrentUrl(); if (currentUrl.startsWith('http')) { await this.executeScript('localStorage.clear();').catch((err) => { if (!(err.message.indexOf("Storage is disabled inside 'data:' URLs.") > -1)) throw err; }); } await this.closeOtherTabs(); return this.browser; } _afterSuite() { } _finishTest() { if (!this.options.restart && this.isRunning) return this._stopBrowser(); } _session() { return { start: async () => { this.debugSection('Incognito Tab', 'opened'); const bc = await this.browser.createIncognitoBrowserContext(); await bc.newPage(); // Create a new page inside context. return bc; }, stop: async (context) => { // is closed by _after }, loadVars: async (context) => { const existingPages = context.targets().filter(t => t.type() === 'page'); return this._setPage(await existingPages[0].page()); }, restoreVars: async () => { this.withinLocator = null; const existingPages = await this.browser.targets().filter(t => t.type() === 'page'); await this._setPage(await existingPages[0].page()); return this._waitForAction(); }, }; } /** * 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. */ 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; page.setDefaultNavigationTimeout(this.options.getPageTimeout); this.context = await this.page.$('body'); if (this.config.browser === 'chrome') { await page.bringToFront(); } } /** * Add the 'dialog' event listener to a page * @page {Puppeteer.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.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<string | null>} */ async grabPopupText() { if (popupStore.popup) { return popupStore.popup.message(); } return null; } async _startBrowser() { if (this.isRemoteBrowser) { try { this.browser = await puppeteer.connect(this.puppeteerOptions); } catch (err) { if (err.toString().indexOf('ECONNREFUSED')) { throw new RemoteBrowserConnectionRefused(err); } throw err; } } else { this.browser = await puppeteer.launch(this.puppeteerOptions); } this.browser.on('targetcreated', target => target.page().then(page => targetCreatedHandler.call(this, page))); this.browser.on('targetchanged', (target) => { this.debugSection('Url', target.url()); }); const existingPages = await this.browser.pages(); const mainPage = existingPages[0] || await this.browser.newPage(); if (existingPages.length) { // Run the handler as it will not be triggered if the page already exists targetCreatedHandler.call(this, mainPage); } await this._setPage(mainPage); await this.closeOtherTabs(); this.isRunning = true; } async _stopBrowser() { this.withinLocator = null; this._setPage(null); this.context = null; popupStore.clear(); if (this.isRemoteBrowser) { await this.browser.disconnect(); } else { await this.browser.close(); } } async _evaluateHandeInContext(...args) { let context = await this._getContext(); if (context.constructor.name === 'Frame') { // Currently there is no evalateHandle for the Frame object // https://github.com/GoogleChrome/puppeteer/issues/1051 context = await context.executionContext(); } 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)) { return this.switchTo(null) .then(() => frame.reduce((p, frameLocator) => p.then(() => this.switchTo(frameLocator)), Promise.resolve())); } await this.switchTo(locator); this.withinLocator = new Locator(locator); return; } const els = await this._locate(locator); assertElementExists(els, locator); this.context = els[0]; this.withinLocator = new Locator(locator); } async _withinEnd() { this.withinLocator = null; this.context = await this.page.mainFrame().$('body'); } _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. */ async amOnPage(url) { if (!(/^\w+\:\/\//.test(url))) { url = this.options.url + url; } 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(); } /** * * 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. * * Unlike other drivers Puppeteer changes the size of a viewport, not the window! * Puppeteer does not control the window of a browser so it can't adjust its real size. * It also can't maximize a window. */ async resizeWindow(width, height) { if (width === 'maximize') { throw new Error('Puppeteer can\'t control windows, so it can\'t maximize it'); } await this.page.setViewport({ width, height }); return this._waitForAction(); } /** * Set headers for all next requests * * ```js * I.haveRequestHeaders({ * 'X-Sent-By': 'CodeceptJS', * }); * ``` * * @param {object} customHeaders headers to set */ async haveRequestHeaders(customHeaders) { if (!customHeaders) { throw new Error('Cannot send empty headers.'); } return this.page.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 {CodeceptJS.LocatorOrString} 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. * {{ react }} */ async moveCursorTo(locator, offsetX = 0, offsetY = 0) { const els = await this._locate(locator); assertElementExists(els); // Use manual mouse.move instead of .hover() so the offset can be added to the coordinates const { x, y } = await els[0]._clickablePoint(); await this.page.mouse.move(x + offsetX, y + offsetY); return this._waitForAction(); } /** * * 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. */ async dragAndDrop(srcElement, destElement) { return proceedDragAndDrop.call(this, srcElement, destElement); } /** * * Reload the current page. ```js I.refreshPage(); ``` */ async refreshPage() { return this.page.reload({ timeout: this.options.getPageTimeout, waitUntil: this.options.waitForNavigation }); } /** * * Scroll page to the top. ```js I.scrollPageToTop(); ``` */ scrollPageToTop() { return this.executeScript(() => { window.scrollTo(0, 0); }); } /** * * Scroll page to the bottom. ```js I.scrollPageToBottom(); ``` */ 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 {CodeceptJS.LocatorOrString} 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. */ async scrollTo(locator, offsetX = 0, offsetY = 0) { if (typeof locator === 'number' && typeof offsetX === 'number') { offsetY = offsetX; offsetX = locator; locator = null; } if (locator) { const els = await this._locate(locator); assertElementExists(els, locator, 'Element'); await els[0]._scrollIntoViewIfNeeded(); const elementCoordinates = await els[0]._clickablePoint(); await this.executeScript((x, y) => window.scrollBy(x, y), elementCoordinates.x + offsetX, elementCoordinates.y + offsetY); } else { await this.executeScript((x, y) => window.scrollTo(x, y), offsetX, offsetY); } return this._waitForAction(); } /** * * Checks that title contains text. ```js I.seeInTitle('Home Page'); ``` @param {string} text text value to check. */ 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<Object<string, *>>} 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.'); * ``` */ 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. */ 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<string>} 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['Puppeteer']._locate({name: 'password'}); * ``` * * {{ react }} */ async _locate(locator) { return findElements(await this.context, locator); } /** * Find a checkbox by providing human readable text: * NOTE: Assumes the checkable element exists * * ```js * this.helpers['Puppeteer']._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['Puppeteer']._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['Puppeteer']._locateFields('Your email').then // ... * ``` */ async _locateFields(locator) { return findFields.call(this, 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) { const pages = await this.browser.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 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) { const pages = await this.browser.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() { 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.browser.pages(); const otherPages = pages.filter(page => page !== this.page); let p = Promise.resolve(); otherPages.forEach((page) => { p = p.then(() => page.close()); }); await p; return this._waitForAction(); } /** * Open new tab and switch to it * * ```js * I.openNewTab(); * ``` */ async openNewTab() { await this._setPage(await this.browser.newPage()); return this._waitForAction(); } /** * * Grab number of open tabs. ```js let tabs = await I.grabNumberOfOpenTabs(); ``` @returns {Promise<number>} number of open tabs */ async grabNumberOfOpenTabs() { const pages = await this.browser.pages(); return pages.length; } /** * * Checks that a given Element is visible Element is located by CSS or XPath. ```js I.seeElement('#modal'); ``` @param {CodeceptJS.LocatorOrString} locator located by CSS|XPath|strict locator. * {{ react }} */ async seeElement(locator) { let els = await this._locate(locator); els = await Promise.all(els.map(el => el.boundingBox())); return empty('visible elements').negate(els.filter(v => v).fill('ELEMENT')); } /** * * Opposite to `seeElement`. Checks that element is not visible (or in DOM) ```js I.dontSeeElement('.modal'); // modal is not shown ``` @param {CodeceptJS.LocatorOrString} locator located by CSS|XPath|Strict locator. * {{ react }} */ async dontSeeElement(locator) { let els = await this._locate(locator); els = await Promise.all(els.map(el => el.boundingBox())); return empty('visible elements').assert(els.filter(v => v).fill('ELEMENT')); } /** * * Checks that a given Element is present in the DOM Element is located by CSS or XPath. ```js I.seeElementInDOM('#modal'); ``` @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator. */ async seeElementInDOM(locator) { const els = await this._locate(locator); return empty('elements on page').negate(els.filter(v => v).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 {CodeceptJS.LocatorOrString} locator located by CSS|XPath|Strict locator. */ async dontSeeElementInDOM(locator) { const els = await this._locate(locator); return empty('elements on a page').assert(els.filter(v => v).fill('ELEMENT')); } /** * * 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 {CodeceptJS.LocatorOrString} locator clickable link or button located by text, or any element located by CSS|XPath|strict locator. @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator. * * {{ react }} */ async click(locator, context = null) { return proceedClick.call(this, locator, context); } /** * * Performs a click on a link and waits for navigation before moving on. ```js I.clickLink('Logout', '#nav'); ``` @param {CodeceptJS.LocatorOrString} locator clickable link or button located by text, or any element located by CSS|XPath|strict locator @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator * * {{ react }} */ async clickLink(locator, context = null) { return proceedClick.call(this, locator, context, { waitForNavigation: true }); } /** * Sets a directory to where save files. Allows to test file downloads. * Should be used with [FileSystem helper](https://codecept.io/helpers/FileSystem) to check that file were downloaded correctly. * * By default files are saved to `output/downloads`. * This directory is cleaned on every `handleDownloads` call, to ensure no old files are kept. * * ```js * I.handleDownloads(); * I.click('Download Avatar'); * I.amInPath('output/downloads'); * I.seeFile('avatar.jpg'); * * ``` * * @param {string} [downloadPath='downloads'] change this parameter to set another directory for saving */ async handleDownloads(downloadPath = 'downloads') { downloadPath = path.join(global.output_dir, downloadPath); if (!fs.existsSync(downloadPath)) { fs.mkdirSync(downloadPath, '0777'); } fsExtra.emptyDirSync(downloadPath); return this.page._client.send('Page.setDownloadBehavior', { behavior: 'allow', downloadPath }); } /** * This method is **deprecated**. * * Please use `handleDownloads()` instead. */ async downloadFile(locator, customName) { let fileName; await this.page.setRequestInterception(true); const xRequest = await new Promise((resolve) => { this.page.on('request', (request) => { console.log('rq', request, customName); const grabbedFileName = request.url().split('/')[request.url().split('/').length - 1]; const fileExtension = request.url().split('/')[request.url().split('/').length - 1].split('.')[1]; console.log('nm', customName, fileExtension); if (customName && path.extname(customName) !== fileExtension) { console.log('bypassing a request'); request.continue(); return; } customName ? fileName = `${customName}.${fileExtension}` : fileName = grabbedFileName; request.abort(); resolve(request); }); }); await this.click(locator); const options = { encoding: null, method: xRequest._method, uri: xRequest._url, body: xRequest._postData, headers: xRequest._headers, }; const cookies = await this.page.cookies(); options.headers.Cookie = cookies.map(ck => `${ck.name}=${ck.value}`).join(';'); const response = await axios({ method: options.method, url: options.uri, headers: options.headers, responseType: 'arraybuffer', onDownloadProgress(e) { console.log('+', e); }, }); const outputFile = path.join(`${global.output_dir}/${fileName}`); try { await new Promise((resolve, reject) => { const wstream = fs.createWriteStream(outputFile); console.log(response); wstream.write(response.data); wstream.end(); this.debug(`File is downloaded in ${outputFile}`); wstream.on('finish', () => { resolve(fileName); }); wstream.on('error', reject); }); } catch (error) { throw new Error(`There is something wrong with downloaded file. ${error}`); } } /** * * 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 {CodeceptJS.LocatorOrString} locator clickable link or button located by text, or any element located by CSS|XPath|strict locator. @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator. * * {{ react }} */ 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 {CodeceptJS.LocatorOrString} locator clickable element located by CSS|XPath|strict locator. @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS|XPath|strict locator. * * {{ react }} */ async rightClick(locator, context = null) { return proceedClick.call(this, locator, context, { button: 'right' }); } /** * * 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 {CodeceptJS.LocatorOrString} field checkbox located by label | name | CSS | XPath | strict locator. @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS | XPath | strict locator. */ async checkOption(field, context = null) { const elm = await this._locateCheckable(field, context); const curentlyChecked = await elm.getProperty('checked') .then(checkedProperty => checkedProperty.jsonValue()); // Only check if NOT currently checked if (!curentlyChecked) { await elm.click(); return this._waitForAction(); } } /** * * 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 {CodeceptJS.LocatorOrString} field checkbox located by label | name | CSS | XPath | strict locator. @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS | XPath | strict locator. */ async uncheckOption(field, context = null) { const elm = await this._locateCheckable(field, context); const curentlyChecked = await elm.getProperty('checked') .then(checkedProperty => checkedProperty.jsonValue()); // Only uncheck if currently checked if (curentlyChecked) { await elm.click(); 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 {CodeceptJS.LocatorOrString} field located by label|name|CSS|XPath|strict locator. */ 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 {CodeceptJS.LocatorOrString} field located by label|name|CSS|XPath|strict locator. */ 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. */ 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. */ async pressKeyUp(key) { key = getNormalizedKey.call(this, key); await this.page.keyboard.up(key); return this._waitForAction(); } /** * * 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. * * _Note:_ Shortcuts like `'Meta'` + `'A'` do not work on macOS ([GoogleChrome/puppeteer#1313](https://github.com/GoogleChrome/puppeteer/issues/1313)). */ 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(); } /** * * 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 {CodeceptJS.LocatorOrString} field located by label|name|CSS|XPath|strict locator. @param {string} value text value to fill. * {{ react }} */ async fillField(field, value) { const els = await findFields.call(this, field); assertElementExists(els, field, 'Field'); const el = els[0]; const tag = await el.getProperty('tagName').then(el => el.jsonValue()); const editable = await el.getProperty('contenteditable').then(el => el.jsonValue()); if (tag === 'INPUT' || tag === 'TEXTAREA') { await this._evaluateHandeInContext(el => el.value = '', el); } else if (editable) { await this._evaluateHandeInContext(el => el.innerHTML = '', el); } await el.type(value.toString(), { delay: this.options.pressKeyDelay }); return this._waitForAction(); } /** * * Clears a `<textarea>` or text `<input>` element's value. ```js I.clearField('Email'); I.clearField('user[email]'); I.clearField('#email'); ``` @param {string|object} editable field located by label|name|CSS|XPath|strict locator. */ async clearField(field) { return this.fillField(field, ''); } /** * * Appends text to a input field or textarea. Field is located by name, label, CSS or XPath ```js I.appendField('#myTextField', 'appended'); ``` @param {CodeceptJS.LocatorOrString} field located by label|name|CSS|XPath|strict locator @param {string} value text value to append. * * {{ react }} */ async appendField(field, value) { const els = await findFields.call(this, field); assertElementExists(els, field, 'Field'); await els[0].press('End'); await els[0].type(value, { delay: this.options.pressKeyDelay }); return this._waitForAction(); } /** * * 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 {CodeceptJS.LocatorOrString} field located by label|name|CSS|XPath|strict locator. @param {string} value value to check. */ async seeInField(field, value) { return proceedSeeInField.call(this, 'assert', field, value); } /** * * 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 {CodeceptJS.LocatorOrString} field located by label|name|CSS|XPath|strict locator. @param {string} value value to check. */ async dontSeeInField(field, value) { return proceedSeeInField.call(this, 'negate', field, value); } /** * * Attaches a file to element located by label, name, CSS or XPath Path to file is relative current codecept directory (where codecept.json or codecept.conf.js is located). File will be uploaded to remote system (if tests are running remotely). ```js I.attachFile('Avatar', 'data/avatar.jpg'); I.attachFile('form input[name=avatar]', 'data/avatar.jpg'); ``` @param {CodeceptJS.LocatorOrString} locator field located by label|name|CSS|XPath|strict locator. @param {string} pathToFile local file path relative to codecept.json config file. */ async attachFile(locator, pathToFile) { const file = path.join(global.codecept_dir, pathToFile); if (!fileExists(file)) { throw new Error(`File at ${file} can not be found on local system`); } const els = await findFields.call(this, locator); assertElementExists(els, 'Field'); await els[0].uploadFile(file); return this._waitForAction(); } /** * * 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 {CodeceptJS.LocatorOrString} select field located by label|name|CSS|XPath|strict locator. @param {string|Array<*>} option visible text or value of option. */ async selectOption(select, option) { const els = await findFields.call(this, select); assertElementExists(els, select, 'Selectable field'); const el = els[0]; if (await el.getProperty('tagName').then(t => t.jsonValue()) !== 'SELECT') { throw new Error('Element is not <select>'); } if (!Array.isArray(option)) option = [option]; for (const key in option) { const opt = xpathLocator.literal(option[key]); let optEl = await findElements.call(this, el, { xpath: Locator.select.byVisibleText(opt) }); if (optEl.length) { this._evaluateHandeInContext(el => el.selected = true, optEl[0]); continue; } optEl = await findElements.call(this, el, { xpath: Locator.select.byValue(opt) }); if (optEl.length) { this._evaluateHandeInContext(el => el.selected = true, optEl[0]); } } await this._evaluateHandeInContext((element) => { element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); }, el); return this._waitForAction(); } /** * * Grab number of visible elements by locator. ```js let numOfElements = await I.grabNumberOfVisibleElements('p'); ``` @param {CodeceptJS.LocatorOrString} locator located by CSS|XPath|strict locator. @returns {Promise<number>} number of visible elements * {{ react }} */ async grabNumberOfVisibleElements(locator) { let els = await this._locate(locator); els = await Promise.all(els.map(el => el.boundingBox())); return els.filter(v => v).length; } /** * * Checks that current url contains a provided fragment. ```js I.seeInCurrentUrl('/register'); // we are on registration page ``` @param {string} url a fragment to check */ async seeInCurrentUrl(url) { stringIncludes('url').assert(url, await this._getPageUrl()); } /** * * Checks that current url does not contain a provided fragment. @param {string} url value to check. */ async dontSeeInCurrentUrl(url) { stringIncludes('url').negate(url, await this._getPageUrl()); } /** * * 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. */ async seeCurrentUrlEquals(url) { urlEquals(this.options.url).assert(url, await this._getPageUrl()); } /** * * 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. */ async dontSeeCurrentUrlEquals(url) { urlEquals(this.options.url).negate(url, await this._getPageUrl()); } /** * * 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 {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS|Xpath|strict locator in which to search for text. * * {{ react }} */ async see(text, context = null) { return proceedSee.call(this, 'assert', text, context); } /** * Checks that text is equal to provided one. * * ```js * I.seeTextEquals('text', 'h1'); * ``` */ async seeTextEquals(text, context = null) { return proceedSee.call(this, 'assert', text, context, true); } /** * * 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 {CodeceptJS.LocatorOrString} [context] (optional) element located by CSS|XPath|strict locator in which to perfrom search. * * {{ react }} */ async dontSee(text, context = null) { return proceedSee.call(this, 'negate', text, context); } /** * * Retrieves page source and returns it to test. Resumes test execution, so should be used inside an async function. ```js let pageSource = await I.grabSource(); ``` @returns {Promise<string>} source code */ async grabSource() { return this.page.content(); } /** * Get JS log from browser. * * ```js * let logs = await I.grabBrowserLogs(); * console.log(JSON.stringify(logs)) * ``` * @return {Promise<any[]>} */ async grabBrowserLogs() { const logs = consoleLogStore.entries; c